From aea670b176418e00e93e76add7c9fbbe1a40bc83 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Tue, 21 Jul 2026 23:07:00 +0200 Subject: [PATCH 01/76] Symbolic interfaces: a generic first-order layer over the engines Formula strings are awkward to build programmatically and give no help with arities or variable names. `autstr/symbolic/` adds variables, relation and function symbols that compose with Python operators, over both a single structure (`AutomaticPresentation.symbolic`) and a uniformly automatic class (`UniformlyAutomaticClass.symbolic`). It is a frontend that lowers to nltk expressions, not a fourth engine, so `presentations`, `uniform`, `tree_presentations` and `implicit` consume it unchanged. Signature declares which relations are function graphs, which operators they bind to, and how Python values encode as elements; arities come from the automata, so a wrong-arity application is an error rather than a silently wrong query. Terms are flattened locally per atom. Cross-atom common subexpression elimination measured only ~9%, and hoisting witnesses is sound only for total functions -- not worth making correctness depend on an undeclared assumption. Two supporting fixes: * `evaluate(prepared_updates=...)` skips re-intersecting automata that this presentation already produced and that are known to be restricted to the universe. Re-injection had been re-preparing them: ~22% of runtime, 38% faster on a 328-state case, identical output. * `automata_tools.canonical()` keeps only convolutions with no all-padding column. `pad`/`unpad` deliberately leave the all-padding self-loops in place, so every non-empty relation looked infinite to a word-level cycle test. Finiteness is a question about tuples, not about words. Variables are renamed during compilation because nltk treats only `[a-df-z][0-9]*` as an individual variable and silently drops anything else from the free-variable list -- `R(foo,y)` yields free vars `['y']`, corrupting tape order with no error. Co-Authored-By: Claude Opus 4.8 --- autstr/graphs.py | 5 + autstr/presentations.py | 31 +- autstr/symbolic/__init__.py | 39 +++ autstr/symbolic/backends.py | 218 ++++++++++++++ autstr/symbolic/compiler.py | 242 ++++++++++++++++ autstr/symbolic/context.py | 400 ++++++++++++++++++++++++++ autstr/symbolic/expr.py | 506 +++++++++++++++++++++++++++++++++ autstr/symbolic/signature.py | 103 +++++++ autstr/uniform.py | 15 + autstr/utils/automata_tools.py | 33 +++ tests/test_symbolic.py | 427 ++++++++++++++++++++++++++++ 11 files changed, 2018 insertions(+), 1 deletion(-) create mode 100644 autstr/symbolic/__init__.py create mode 100644 autstr/symbolic/backends.py create mode 100644 autstr/symbolic/compiler.py create mode 100644 autstr/symbolic/context.py create mode 100644 autstr/symbolic/expr.py create mode 100644 autstr/symbolic/signature.py create mode 100644 tests/test_symbolic.py diff --git a/autstr/graphs.py b/autstr/graphs.py index bdcbb14..d5f854d 100644 --- a/autstr/graphs.py +++ b/autstr/graphs.py @@ -494,6 +494,11 @@ def evaluate(self, phi) -> Tuple[SparseDFA, List[str]]: UniformlyAutomaticClass.evaluate. Variables range over vertex sets.""" return self.cls.evaluate(phi) + def symbolic(self, signature=None): + """A symbolic interface to this class; see + UniformlyAutomaticClass.symbolic.""" + return self.cls.symbolic(signature) + def check(self, phi, graph, **sets) -> bool: """Model check a query against a single graph. diff --git a/autstr/presentations.py b/autstr/presentations.py index 731c837..8b99ab4 100644 --- a/autstr/presentations.py +++ b/autstr/presentations.py @@ -151,6 +151,20 @@ def get_relation_symbols(self) -> List[str]: """ return list(self.automata.keys()) + def symbolic(self, signature=None): + """A symbolic interface to this structure: variables, relation and + function symbols that build first-order expressions with Python + operators instead of formula strings. + + :param signature: declared functions, operators and element codec. + Relation arities are read from the automata, so a structure with + no functions needs no signature at all. + :return: a `autstr.symbolic.SymbolicContext`. + """ + from autstr.symbolic.backends import StructureBackend + from autstr.symbolic.context import SymbolicContext + return SymbolicContext(StructureBackend(self), signature) + def update(self, **kwargs) -> None: for key in kwargs: if isinstance(kwargs[key], SparseDFA): @@ -192,19 +206,34 @@ def check(self, phi: logic.Expression | str) -> bool: phi = phi.simplify() return not self._build_automaton(phi).is_empty() - def evaluate(self, phi: Union[str, logic.Expression], updates: Optional[Dict[str, Union[SparseDFA, str]]] = None) -> SparseDFA: + def evaluate(self, phi: Union[str, logic.Expression], + updates: Optional[Dict[str, Union[SparseDFA, str]]] = None, + prepared_updates: Optional[Dict[str, SparseDFA]] = None) -> SparseDFA: """Evaluates a given first-order query on the presented structure. Returns a presentation of the set of all satisfying assignments. :param phi: the first order formula. :param updates: Temporarily update the relations for the evaluation + :param prepared_updates: like `updates`, for automata already known to + be restricted to the universe -- results this presentation produced + itself. They are only re-padded, skipping the domain intersection + that `_prepare_automaton` would otherwise redo on every tape. :returns: The truth value of the formula, if the formula where all free variables are existentially quantified. """ if isinstance(phi, str): phi = logic.Expression.fromstring(phi) phi = optimize_query(phi) + if prepared_updates: + updates = dict(updates or {}) + for key, dfa in prepared_updates.items(): + updates[key] = pad(dfa, self.padding_symbol).minimize() + prepared = set(prepared_updates) + else: + prepared = set() if updates is not None: for key in updates: + if key in prepared: + continue if isinstance(updates[key], str): query = optimize_query(logic.Expression.fromstring(updates[key])) updates[key] = self._prepare_automaton(self._build_automaton(query)) diff --git a/autstr/symbolic/__init__.py b/autstr/symbolic/__init__.py new file mode 100644 index 0000000..3769fb2 --- /dev/null +++ b/autstr/symbolic/__init__.py @@ -0,0 +1,39 @@ +"""Symbolic first-order expressions over automatic structures and classes. + +Instead of writing formula strings, build them from variables and symbols the +structure hands out:: + + A = BuechiArithmeticZ() + S = A.symbolic() + x, y, z = S.vars("x y z") + phi = ((x + y).eq(z) & z.lt(10)).drop(y) + + phi.check() # satisfiable? + phi.evaluate() # presentation of the satisfying assignments + (1, 5) in phi # membership, by the sorted tape order + list(phi) # enumerate solutions + +The same expressions compile against a `UniformlyAutomaticClass`, where they +define relations uniformly across every member structure. + +Relation and function arities come from the automata; which relations are +function graphs, which Python operators they are bound to, and how Python +values encode as elements are declared in a `Signature`. +""" +from autstr.symbolic.backends import Backend, ClassBackend, StructureBackend +from autstr.symbolic.context import ( + FunctionSymbol, Relation, RelationSymbol, SymbolicContext, + SymbolicSymbolError, +) +from autstr.symbolic.compiler import CompileError +from autstr.symbolic.expr import Formula, Term, Var +from autstr.symbolic.signature import ( + ElementCodec, Function, FunctionCodec, Signature, +) + +__all__ = [ + 'Backend', 'ClassBackend', 'CompileError', 'ElementCodec', 'Formula', + 'Function', 'FunctionCodec', 'FunctionSymbol', 'Relation', + 'RelationSymbol', 'Signature', 'StructureBackend', 'SymbolicContext', + 'SymbolicSymbolError', 'Term', 'Var', +] diff --git a/autstr/symbolic/backends.py b/autstr/symbolic/backends.py new file mode 100644 index 0000000..90950c0 --- /dev/null +++ b/autstr/symbolic/backends.py @@ -0,0 +1,218 @@ +"""Evaluation targets for compiled symbolic queries. + +A backend knows how to answer a first-order query, what a relation's arity is, +and how to move between element encodings and automaton tapes. Everything +above this module is backend-agnostic, which is what lets one expression +language serve both a single structure and a whole uniformly automatic class. +""" +from __future__ import annotations + +from typing import Dict, List, Optional, Sequence + +from autstr.buildin.automata import k_longer_automaton +from autstr.utils.automata_tools import ( + canonical, iterate_language, word_automaton, +) +from autstr.utils.logic import get_free_elementary_vars + + +class Backend: + """The interface a `SymbolicContext` evaluates against.""" + + def relation_symbols(self) -> List[str]: + raise NotImplementedError + + def arity(self, symbol: str) -> Optional[int]: + """Arity of a relation symbol, or None if it is unknown here.""" + raise NotImplementedError + + def arity_of(self, dfa) -> int: + """Relation arity of an automaton produced by this backend.""" + raise NotImplementedError + + def evaluate(self, expression, updates: Dict, prepared: Dict): + """Answer a query. Returns ``(dfa, tape names)``.""" + raise NotImplementedError + + def constant_automaton(self, word: Sequence): + raise NotImplementedError + + def longer_witness_automaton(self, k: int, references: int): + raise NotImplementedError + + def accepts(self, dfa, values: Sequence, codec) -> bool: + raise NotImplementedError + + def iterate(self, dfa, codec, arity: int): + raise NotImplementedError + + def is_finite(self, dfa) -> bool: + """Whether the relation holds of finitely many *tuples*.""" + raise NotImplementedError + + def reserved_variable_names(self) -> set: + """Variable names this backend cannot represent.""" + return set() + + # -- member-level evaluation, for classes only --------------------- + def check_member(self, expression, advice, assignments, implicit): + raise NotImplementedError( + "member checking applies to a uniformly automatic class; a single " + "structure has no members, so use `check` instead") + + def evaluate_member(self, expression, advice, assignments): + raise NotImplementedError( + "member evaluation applies to a uniformly automatic class; a " + "single structure has no members, so use `evaluate` instead") + + def get_structure(self, advice): + raise NotImplementedError( + "only a uniformly automatic class instantiates member structures") + + def describe(self) -> str: + raise NotImplementedError + + +def _convolve(words: Sequence[Sequence], padding_symbol) -> List[tuple]: + """Pad a tuple of words to equal length and interleave them into the + sequence of symbol tuples an automaton reads.""" + words = [list(w) for w in words] + length = max((len(w) for w in words), default=0) + for word in words: + word.extend([padding_symbol] * (length - len(word))) + return [tuple(w[i] for w in words) for i in range(length)] + + +class StructureBackend(Backend): + """Queries answered by an `AutomaticPresentation`.""" + + def __init__(self, presentation): + self.presentation = presentation + + def relation_symbols(self): + return [s for s in self.presentation.get_relation_symbols() if s != 'U'] + + def arity(self, symbol): + dfa = self.presentation.automata.get(symbol) + return None if dfa is None else dfa.symbol_arity + + def arity_of(self, dfa): + return dfa.symbol_arity + + def evaluate(self, expression, updates, prepared): + dfa = self.presentation.evaluate( + expression, + updates=dict(updates) if updates else None, + prepared_updates=dict(prepared) if prepared else None) + return dfa, get_free_elementary_vars(expression) + + def constant_automaton(self, word): + return word_automaton(word, self.presentation.sigma, + self.presentation.padding_symbol) + + def longer_witness_automaton(self, k, references): + return k_longer_automaton(k, references, self.presentation.sigma, + self.presentation.padding_symbol) + + def accepts(self, dfa, values, codec): + if codec is None: + words = values + else: + words = [codec.encode(v) for v in values] + return dfa.accepts(_convolve(words, self.presentation.padding_symbol)) + + def iterate(self, dfa, codec, arity): + padding = self.presentation.padding_symbol + for tapes in iterate_language(dfa, backward=True, padding_symbol=padding): + # `iterate_language` builds its words right-to-left, so each tape + # comes back reversed with respect to the automaton's reading order. + words = [tape[::-1] for tape in tapes] + if codec is None: + yield tuple(words) + else: + yield tuple(codec.decode(w) for w in words) + + def is_finite(self, dfa): + # The tuple set is finite iff the canonical convolutions are; the + # automaton's own language never is, thanks to the all-padding loops. + return canonical(dfa, self.presentation.padding_symbol).is_finite() + + def describe(self): + return f"structure with relations {sorted(self.relation_symbols())}" + + +class ClassBackend(Backend): + """Queries answered by a `UniformlyAutomaticClass`. + + Formulas are written over the class signature exactly as for a single + structure; the advice tape is added and quantifiers are relativized to the + member domain by the class's own evaluator. The advice appears in results + under the reserved tape name ``'advice'``. + """ + + ADVICE = 'advice' + + def __init__(self, klass): + self.klass = klass + + def relation_symbols(self): + return [s for s in self.klass.get_relation_symbols() if s != 'U'] + + def arity(self, symbol): + dfa = self.klass.class_automata.get(symbol) + # Tape 0 carries the advice, so the relation arity is one less. + return None if dfa is None else dfa.symbol_arity - 1 + + def arity_of(self, dfa): + return dfa.symbol_arity - 1 + + def evaluate(self, expression, updates, prepared): + if updates or prepared: + raise NotImplementedError( + "constants and spliced automata are not yet supported over a " + "uniformly automatic class; use `define` to name a derived " + "class relation instead") + dfa, variables = self.klass.evaluate(expression) + return dfa, variables + + def reserved_variable_names(self): + # A user variable called 'advice' would be indistinguishable from the + # advice tape in the result's tape list. + return {self.ADVICE} + + def constant_automaton(self, word): + raise NotImplementedError( + "a class element's encoding depends on the advice, so constants " + "have no advice-free meaning") + + def longer_witness_automaton(self, k, references): + raise NotImplementedError("exinf is not yet supported over a class") + + def accepts(self, dfa, values, codec): + raise NotImplementedError( + "membership over a class needs an advice string; instantiate a " + "member with `get_structure(advice)` first") + + def iterate(self, dfa, codec, arity): + raise NotImplementedError( + "enumeration over a class needs an advice string; instantiate a " + "member with `get_structure(advice)` first") + + def is_finite(self, dfa): + raise NotImplementedError( + "finiteness over a class is a question about a member; instantiate " + "one with `get_structure(advice)` first") + + # -- member-level evaluation -------------------------------------- + def check_member(self, expression, advice, assignments, implicit): + check = self.klass.check_implicit if implicit else self.klass.check + return check(expression, advice, **assignments) + + def evaluate_member(self, expression, advice, assignments): + return self.klass.evaluate_implicit(expression, advice, **assignments) + + def get_structure(self, advice): + return self.klass.get_structure(advice) + + def describe(self): + return f"class with relations {sorted(self.relation_symbols())}" diff --git a/autstr/symbolic/compiler.py b/autstr/symbolic/compiler.py new file mode 100644 index 0000000..0186d24 --- /dev/null +++ b/autstr/symbolic/compiler.py @@ -0,0 +1,242 @@ +"""Compilation of symbolic expressions into first-order queries. + +The output is an `nltk` expression plus a table of automata to splice in, which +is exactly what every evaluation backend in the package already consumes -- +`AutomaticPresentation._build_automaton`, the relativizing class evaluator, and +the implicit engine. The symbolic layer is a frontend, not a fourth engine. + +Two things happen here that the string-formula approach could not do safely: + +**Variable renaming.** `nltk` only recognizes an argument as an individual +variable if its name matches ``[a-df-z][0-9]*``; anything else -- ``foo``, +``x_1``, ``e`` -- is silently reclassified and *drops out of the free-variable +list*, which corrupts tape order rather than raising. User-chosen names are +therefore mangled to legal ones on the way in and restored on the way out. +Free variables are numbered in sorted order with a fixed width, so the +lexicographic order the engine uses to lay out tapes agrees with the sorted +order of the user's names. + +**Term flattening.** An atom ``R(f(x), y)`` becomes +``exists w.(Graph_f(x, w) and R(w, y))``. Witnesses are introduced once per +distinct subterm within an atom, and they are quantified at the atom rather +than hoisted, so a partial function's graph keeps its meaning under negation. +""" +from __future__ import annotations + +import re +from typing import Dict, List, Sequence, Tuple + +from nltk.sem.logic import ( + AllExpression, AndExpression, ApplicationExpression, ConstantExpression, + ExistsExpression, FunctionVariableExpression, IndividualVariableExpression, + NegatedExpression, OrExpression, Variable, is_indvar, +) + +from autstr.symbolic import expr as E + +#: Prefixes for the three kinds of generated names. All are legal `nltk` +#: individual-variable initials, and the groups are disjoint by construction. +FREE_PREFIX = 'a' +BOUND_PREFIX = 'b' +WITNESS_PREFIX = 'd' + + +class CompileError(Exception): + pass + + +def _nltk_predicate(name: str): + """A predicate expression for a relation symbol.""" + variable = Variable(name) + if is_indvar(name): + return FunctionVariableExpression(variable) + return ConstantExpression(variable) + + +def _nltk_atom(symbol: str, args: Sequence[str]): + expression = _nltk_predicate(symbol) + for name in args: + expression = ApplicationExpression( + expression, IndividualVariableExpression(Variable(name))) + return expression + + +class _Names: + """Allocates `nltk`-legal names and remembers the mapping back.""" + + def __init__(self, free: Sequence[str], bound: Sequence[str]): + width = max(2, len(str(max(len(free), len(bound), 1)))) + self.to_user: Dict[str, str] = {} + self.to_internal: Dict[str, str] = {} + for i, name in enumerate(sorted(free)): + self._bind(name, f"{FREE_PREFIX}{i:0{width}d}") + for i, name in enumerate(sorted(bound)): + self._bind(name, f"{BOUND_PREFIX}{i:0{width}d}") + self._width = width + self._witnesses = 0 + + def _bind(self, user: str, internal: str) -> None: + self.to_user[internal] = user + self.to_internal[user] = internal + + def internal(self, user: str) -> str: + try: + return self.to_internal[user] + except KeyError: + raise CompileError(f"unbound variable {user!r}") from None + + def witness(self) -> str: + name = f"{WITNESS_PREFIX}{self._witnesses:0{self._width}d}" + self._witnesses += 1 + return name + + +def _collect_variables(formula: E.Formula) -> Tuple[set, set]: + """(free names, names bound by a quantifier) over the whole formula.""" + bound = set() + + def walk(node): + if isinstance(node, E._Quantifier): + bound.update(node.bound) + walk(node.body) + elif isinstance(node, E.ExInf): + bound.add(node.variable) + walk(node.body) + elif isinstance(node, E.Not): + walk(node.body) + elif isinstance(node, E._Binary): + walk(node.left) + walk(node.right) + + walk(formula) + return set(formula.variables()), bound + + +class Compiler: + """Lowers one symbolic formula. Instantiate per compilation -- it carries + the name allocator and the automata collected along the way.""" + + def __init__(self, ctx): + self.ctx = ctx + self.updates: Dict[str, object] = {} + self.prepared: Dict[str, object] = {} + self._names = None + self._spliced = 0 + # Arities of the automata spliced in during *this* compilation. Kept + # here rather than on the context so that concurrent or repeated + # compilations against one structure cannot see each other's symbols. + self._spliced_arities: Dict[str, int] = {} + + # -- entry point -------------------------------------------------- + def compile(self, formula: E.Formula): + """Returns ``(nltk expression, free variable names in tape order)``.""" + free, bound = _collect_variables(formula) + self._names = _Names(free, bound) + expression = self._formula(formula) + return expression, sorted(free) + + @property + def names(self) -> _Names: + return self._names + + def arity(self, symbol: str) -> int: + if symbol in self._spliced_arities: + return self._spliced_arities[symbol] + return self.ctx.relation_arity(symbol) + + # -- formulas ----------------------------------------------------- + def _formula(self, node: E.Formula): + if isinstance(node, E.Atom): + arity = self.arity(node.symbol) + if arity != len(node.args): + raise CompileError( + f"relation {node.symbol!r} has arity {arity}, " + f"applied to {len(node.args)} arguments") + return self._flatten(node.symbol, node.args) + if isinstance(node, E.DfaAtom): + symbol = self._splice(node) + return self._flatten(symbol, node.args) + if isinstance(node, E.Not): + return NegatedExpression(self._formula(node.body)) + if isinstance(node, E.And): + return AndExpression(self._formula(node.left), + self._formula(node.right)) + if isinstance(node, E.Or): + return OrExpression(self._formula(node.left), + self._formula(node.right)) + if isinstance(node, (E.Exists, E.Forall)): + build = (ExistsExpression if isinstance(node, E.Exists) + else AllExpression) + inner = self._formula(node.body) + for name in reversed(node.bound): + inner = build(Variable(self._names.internal(name)), inner) + return inner + if isinstance(node, E.ExInf): + return self._formula(self.ctx._expand_exinf(node)) + raise CompileError(f"cannot compile {type(node).__name__}") + + # -- atoms with terms --------------------------------------------- + def _flatten(self, symbol: str, args: Sequence[E.Term]): + """``R(t_1, ..., t_n)`` with the non-variable terms replaced by + existentially quantified witnesses.""" + witnesses: Dict[E.Term, str] = {} + conjuncts: List = [] + names = [self._term(t, witnesses, conjuncts) for t in args] + + expression = _nltk_atom(symbol, names) + for conjunct in reversed(conjuncts): + expression = AndExpression(conjunct, expression) + for name in reversed(list(witnesses.values())): + expression = ExistsExpression(Variable(name), expression) + return expression + + def _term(self, term: E.Term, witnesses: Dict, conjuncts: List) -> str: + """The variable name standing for ``term``, emitting the defining + conjunct if the term is not already a variable.""" + if isinstance(term, E.Var): + return self._names.internal(term.name) + if term in witnesses: + return witnesses[term] + + if isinstance(term, E.Const): + name = self._names.witness() + symbol = self._splice(self.ctx._constant_atom(term)) + conjuncts.append(_nltk_atom(symbol, [name])) + elif isinstance(term, E.Apply): + function = self.ctx.function(term.func) + graph_arity = self.arity(function.graph) + inputs, out = function.positions(graph_arity) + if len(inputs) != len(term.args): + raise CompileError( + f"function {term.func!r} takes {len(inputs)} arguments, " + f"applied to {len(term.args)}") + argument_names = [self._term(a, witnesses, conjuncts) + for a in term.args] + name = self._names.witness() + slots = [None] * graph_arity + for position, argument in zip(inputs, argument_names): + slots[position] = argument + slots[out] = name + conjuncts.append(_nltk_atom(function.graph, slots)) + else: + raise CompileError(f"cannot compile term {type(term).__name__}") + + witnesses[term] = name + return name + + # -- spliced automata --------------------------------------------- + def _splice(self, node) -> str: + """Register an automaton under a fresh relation symbol.""" + symbol = f"Spliced{self._spliced}" + self._spliced += 1 + if getattr(node, 'prepared', False): + self.prepared[symbol] = node.dfa + else: + self.updates[symbol] = node.dfa + self._spliced_arities[symbol] = self.ctx._relation_arity_of(node.dfa) + return symbol + + +def restore(names: _Names, internal: Sequence[str]) -> List[str]: + """Map internal tape names back to the user's variable names.""" + return [names.to_user.get(name, name) for name in internal] diff --git a/autstr/symbolic/context.py b/autstr/symbolic/context.py new file mode 100644 index 0000000..d9f515d --- /dev/null +++ b/autstr/symbolic/context.py @@ -0,0 +1,400 @@ +"""Binding a signature to a structure: the user-facing symbolic interface. + +A `SymbolicContext` is what `AutomaticPresentation.symbolic()` and +`UniformlyAutomaticClass.symbolic()` hand back. It mints variables, relation +and function symbols, and evaluates the expressions built from them against +its backend. +""" +from __future__ import annotations + +import itertools +from typing import Dict, List, Optional, Sequence, Union + +from autstr.symbolic import expr as E +from autstr.symbolic.compiler import Compiler, CompileError, restore +from autstr.symbolic.signature import Function, Signature + + +class SymbolicSymbolError(Exception): + pass + + +class RelationSymbol: + """A relation symbol of the signature, applied to build atoms.""" + + def __init__(self, ctx, symbol: str): + self.ctx = ctx + self.symbol = symbol + + @property + def arity(self) -> int: + return self.ctx.relation_arity(self.symbol) + + def __call__(self, *args) -> E.Formula: + return self.ctx.atom(self.symbol, args) + + def __repr__(self): + return f"" + + +class FunctionSymbol: + """A function symbol of the signature, applied to build terms.""" + + def __init__(self, ctx, name: str, function: Function): + self.ctx = ctx + self.name = name + self.function = function + + @property + def arity(self) -> int: + return self.ctx.relation_arity(self.function.graph) - 1 + + def __call__(self, *args) -> E.Term: + if len(args) != self.arity: + raise SymbolicSymbolError( + f"function {self.name!r} has arity {self.arity}, " + f"applied to {len(args)} arguments") + return E.Apply(self.ctx, self.name, [self.ctx.term(a) for a in args]) + + def __repr__(self): + return f"" + + +class SymbolicContext: + """The symbolic interface to one structure or one class of structures. + + :param backend: evaluation target (see `autstr.symbolic.backends`). + :param signature: declared functions, operators and codec. Relation + arities are read from the backend's automata. + """ + + def __init__(self, backend, signature: Optional[Signature] = None): + self.backend = backend + self.signature = signature if signature is not None else Signature() + self._materialized = itertools.count() + + # ------------------------------------------------------------------ + # building blocks + # ------------------------------------------------------------------ + def var(self, name: str) -> E.Var: + """A single symbolic variable. Any non-empty name works -- names are + renamed to legal ones during compilation and restored in results.""" + if not isinstance(name, str) or not name: + raise TypeError("variable names are non-empty strings") + if name in self.backend.reserved_variable_names(): + raise SymbolicSymbolError( + f"{name!r} is reserved by {self.describe()} and cannot name a " + f"variable") + return E.Var(self, name) + + def vars(self, names: Union[str, Sequence[str]]) -> tuple: + """Several symbolic variables. Accepts a list of names or a single + whitespace-separated string.""" + if isinstance(names, str): + names = names.split() + return tuple(self.var(n) for n in names) + + # The proposal's spelling, kept as the primary public name. + get_symbolic_vars = vars + + def const(self, value) -> E.Const: + """A constant term for a Python value, encoded through the codec.""" + if self.signature.codec is None: + raise SymbolicSymbolError( + f"{self.describe()} has no element codec, so Python values " + f"cannot be used as constants; pass a Signature(codec=...)") + return E.Const(self, value) + + def term(self, x) -> E.Term: + """Coerce a variable name, Python value or term into a term.""" + if isinstance(x, E.Term): + if x.ctx is not self: + raise ValueError("term belongs to a different structure") + return x + if isinstance(x, str): + return self.var(x) + return self.const(x) + + def rel(self, symbol: str) -> RelationSymbol: + """A relation symbol of the signature.""" + self.relation_arity(symbol) # fail early on unknown symbols + return RelationSymbol(self, symbol) + + get_symbolic_rel = rel + + def func(self, name: str) -> FunctionSymbol: + """A function symbol of the signature.""" + return FunctionSymbol(self, name, self.function(name)) + + get_symbolic_func = func + + def atom(self, symbol: str, args) -> E.Formula: + """The atom ``symbol(*args)``.""" + return E.Atom(self, symbol, [self.term(a) for a in args]) + + def relation(self, dfa, args, label: str = 'given') -> E.Formula: + """An atom backed by an automaton built outside the symbolic layer.""" + return E.DfaAtom(self, dfa, [self.term(a) for a in args], label) + + # ------------------------------------------------------------------ + # signature lookup + # ------------------------------------------------------------------ + def relation_arity(self, symbol: str) -> int: + arity = self.backend.arity(symbol) + if arity is None: + arity = self.signature.relations.get(symbol) + if arity is None: + raise SymbolicSymbolError( + f"{self.describe()} has no relation {symbol!r}; " + f"available: {sorted(self.backend.relation_symbols())}") + return arity + + def function(self, name: str) -> Function: + try: + return self.signature.functions[name] + except KeyError: + raise SymbolicSymbolError( + f"{self.describe()} declares no function {name!r}; " + f"available: {sorted(self.signature.functions)}") from None + + def symbols(self) -> Dict[str, List[str]]: + """A summary of what this context offers.""" + return { + 'relations': sorted(self.backend.relation_symbols()), + 'functions': sorted(self.signature.functions), + 'operators': dict(self.signature.operators), + } + + def describe(self) -> str: + return self.backend.describe() + + # ------------------------------------------------------------------ + # operator dispatch + # ------------------------------------------------------------------ + def _apply_operator(self, op: str, args) -> Union[E.Term, E.Formula]: + try: + symbol = self.signature.operators[op] + except KeyError: + raise TypeError( + f"{self.describe()} declares no operator {op!r}; " + f"available: {sorted(self.signature.operators)}") from None + args = [self.term(a) for a in args] + if symbol in self.signature.functions: + return E.Apply(self, symbol, args) + return self.atom(symbol, args) + + def _atom_from_operator(self, op: str, args) -> E.Formula: + result = self._apply_operator(op, args) + if not isinstance(result, E.Formula): + raise TypeError(f"operator {op!r} is a function, not a relation") + return result + + def _is_binary(self, op: str) -> bool: + """Whether the operator's symbol takes two arguments.""" + symbol = self.signature.operators[op] + if symbol in self.signature.functions: + function = self.signature.functions[symbol] + return self.relation_arity(function.graph) - 1 == 2 + return self.relation_arity(symbol) == 2 + + def fresh_name(self, taken) -> str: + """A user-level variable name not in ``taken``.""" + taken = set(taken) + for i in itertools.count(): + name = f"_v{i}" + if name not in taken: + return name + + # ------------------------------------------------------------------ + # constants and exists-infinity + # ------------------------------------------------------------------ + def _constant_atom(self, term: E.Const) -> E.DfaAtom: + dfa = self.backend.constant_automaton( + self.signature.codec.encode(term.value)) + return E.DfaAtom(self, dfa, [], label=f"const {term.value!r}") + + def _relation_arity_of(self, dfa) -> int: + return self.backend.arity_of(dfa) + + def _expand_exinf(self, node: E.ExInf) -> E.Formula: + """Rewrite :math:`\\exists^\\infty x.\\varphi` into an ordinary + projection against a "witnesses are unboundedly long" automaton, the + standard automatic-structure encoding. + + The body has to be compiled first -- the witness automaton's parameter + is the body automaton's state count -- so this is one of the few + places where a subformula is materialized on the way. + """ + body = node.body + variables = body.variables() + if node.variable not in variables: + raise SymbolicSymbolError( + f"exinf variable {node.variable!r} is not free in the body") + others = [v for v in variables if v != node.variable] + + compiled = self.evaluate(body) + witness = self.backend.longer_witness_automaton( + compiled.dfa.num_states + 1, len(others)) + + body_atom = E.DfaAtom(self, compiled.dfa, + [self.var(v) for v in compiled.variables], + label='exinf body', prepared=True) + witness_atom = E.DfaAtom( + self, witness, + [self.var(v) for v in others] + [self.var(node.variable)], + label='exinf witness') + return E.Exists(self, [node.variable], body_atom & witness_atom) + + # ------------------------------------------------------------------ + # evaluation + # ------------------------------------------------------------------ + def compile(self, formula: E.Formula): + """The first-order query this formula lowers to. Returned as + ``(expression, variables)`` with ``variables`` naming the tapes in the + user's own vocabulary -- useful for inspection and debugging.""" + compiler = Compiler(self) + expression, user_variables = compiler.compile(formula) + return expression, user_variables + + def evaluate(self, formula: E.Formula) -> 'Relation': + """Compile and evaluate, returning the presentation of the satisfying + assignments together with its tape order.""" + compiler = Compiler(self) + expression, _ = compiler.compile(formula) + dfa, internal = self.backend.evaluate( + expression, compiler.updates, compiler.prepared) + variables = restore(compiler.names, internal) + return Relation(self, dfa, variables) + + def check(self, formula: E.Formula) -> bool: + """True if the formula is satisfiable over the structure -- free + variables read as existentially quantified.""" + return not self.evaluate(formula).is_empty() + + # ------------------------------------------------------------------ + # member-level evaluation (uniformly automatic classes) + # ------------------------------------------------------------------ + def check_member(self, formula: E.Formula, advice, implicit: bool = False, + **assignments) -> bool: + """Model check a formula against the member structure picked out by + ``advice``. + + :param advice: the advice string identifying the member. + :param implicit: evaluate on the fly over the base automata instead of + compiling a query automaton -- the same trade-off as + `UniformlyAutomaticClass.check_implicit`, and the only route for + classes whose query automaton is infeasible to build. + :param assignments: concrete elements for free variables, named by the + variables of this expression. Unassigned free variables are read as + existentially quantified. + """ + expression, internal = self._member_query(formula, assignments) + return self.backend.check_member(expression, advice, internal, implicit) + + def evaluate_member(self, formula: E.Formula, advice, **assignments): + """The satisfying set of a formula on one member, computed implicitly. + + Returns a solution set that knows its exact size without enumerating + and yields assignments lazily; see `autstr.implicit`. + """ + expression, internal = self._member_query(formula, assignments) + return self.backend.evaluate_member(expression, advice, internal) + + def get_structure(self, advice): + """The member structure for ``advice`` as an ordinary automatic + presentation. Call `symbolic` on it to work inside that one member.""" + return self.backend.get_structure(advice) + + def _member_query(self, formula: E.Formula, assignments: Dict): + compiler = Compiler(self) + expression, user_variables = compiler.compile(formula) + unknown = set(assignments) - set(user_variables) + if unknown: + raise SymbolicSymbolError( + f"assignments for variables that are not free in the formula: " + f"{sorted(unknown)}") + internal = {compiler.names.internal(name): value + for name, value in assignments.items()} + return expression, internal + + def materialize(self, formula: E.Formula, name: str = None) -> E.Formula: + """Evaluate now, and return a formula that splices the result in.""" + compiled = self.evaluate(formula) + label = name or f"materialized{next(self._materialized)}" + return E.DfaAtom(self, compiled.dfa, + [self.var(v) for v in compiled.variables], + label=label, prepared=True) + + def __repr__(self): + return f"" + + +class Relation: + """The result of evaluating a formula: an automaton plus the tape order. + + The tape order is the sorted list of the formula's free variable names. + Membership and iteration are keyed by name, never by position, so renaming + a variable cannot silently change what a query means. + """ + + def __init__(self, ctx: SymbolicContext, dfa, variables: Sequence[str]): + self.ctx = ctx + self.dfa = dfa + self.variables = list(variables) + + @property + def arity(self) -> int: + return len(self.variables) + + def is_empty(self) -> bool: + return self.dfa.is_empty() + + def is_finite(self) -> bool: + """Whether finitely many tuples satisfy the relation.""" + return self.ctx.backend.is_finite(self.dfa) + + def reorder(self, variables: Sequence[str]) -> 'Relation': + """The same relation with its tapes permuted into the given order.""" + from autstr.utils.automata_tools import permute_tapes + variables = list(variables) + if sorted(variables) != sorted(self.variables): + raise ValueError( + f"reorder needs a permutation of {self.variables}, " + f"got {variables}") + perm = [self.variables.index(v) for v in variables] + return Relation(self.ctx, permute_tapes(self.dfa, perm), variables) + + def contains(self, *positional, **assignment) -> bool: + """Whether a tuple satisfies the relation. Positional arguments follow + `variables`; keyword arguments name them.""" + if positional and assignment: + raise TypeError("give either positional or named values") + if positional: + if len(positional) != self.arity: + raise ValueError( + f"relation has arity {self.arity}, got {len(positional)}") + assignment = dict(zip(self.variables, positional)) + missing = set(self.variables) - set(assignment) + if missing: + raise ValueError(f"no value given for {sorted(missing)}") + unknown = set(assignment) - set(self.variables) + if unknown: + raise ValueError(f"not variables of this relation: {sorted(unknown)}") + return self.ctx.backend.accepts( + self.dfa, [assignment[v] for v in self.variables], + self.ctx.signature.codec) + + def __contains__(self, item): + if not isinstance(item, tuple): + item = (item,) + return self.contains(*item) + + def __iter__(self): + """Enumerate the satisfying tuples in length-lexicographic order, + decoded through the codec when the signature has one.""" + return self.ctx.backend.iterate(self.dfa, self.ctx.signature.codec, + self.arity) + + def __repr__(self): + return (f"") diff --git a/autstr/symbolic/expr.py b/autstr/symbolic/expr.py new file mode 100644 index 0000000..b692689 --- /dev/null +++ b/autstr/symbolic/expr.py @@ -0,0 +1,506 @@ +"""The symbolic expression AST. + +Terms denote elements of a structure, formulas denote relations over it. Nodes +are immutable and compare structurally, so equal subexpressions are +interchangeable and can be used as dictionary keys. Nothing here touches an +automaton: building an expression is pure bookkeeping, and all automata +construction happens when the expression is handed to a backend (see +`autstr.symbolic.compiler`). + +Operators are not hardwired. ``x + y`` looks up ``'+'`` in the structure's +`Signature.operators` and builds an application of whatever function symbol it +names; a structure that declares no ``'+'`` raises a clear error instead of +silently meaning addition. +""" +from __future__ import annotations + +import math +from typing import Any, Iterable, List, Sequence, Tuple, Union + + +class Node: + """Common base: immutable, structurally compared, bound to a context.""" + + __slots__ = ('ctx', '_key') + + def __init__(self, ctx, key: tuple): + object.__setattr__(self, 'ctx', ctx) + object.__setattr__(self, '_key', key) + + def __setattr__(self, name, value): + raise AttributeError(f"{type(self).__name__} is immutable") + + def __eq__(self, other): + return (type(self) is type(other) + and self.ctx is other.ctx + and self._key == other._key) + + def __hash__(self): + return hash((type(self).__name__, id(self.ctx), self._key)) + + +# ====================================================================== +# Terms +# ====================================================================== + +class Term(Node): + """A term denoting an element of the structure.""" + + __slots__ = () + + # -- variables ---------------------------------------------------- + def variables(self) -> List[str]: + """Free variable names, sorted.""" + raise NotImplementedError + + # -- relational atoms --------------------------------------------- + def eq(self, other) -> 'Formula': + """The relation ``self = other``, via the structure's equality + relation.""" + return self.ctx._atom_from_operator('eq', [self, other]) + + def rel(self, symbol: str, *others) -> 'Formula': + """The atom ``symbol(self, *others)``.""" + return self.ctx.atom(symbol, [self, *others]) + + def __getattr__(self, name: str) -> Any: + """Method-name operators declared by the signature (``x.lt(y)``).""" + # `ctx` must never route through here -- it is read below, so a missing + # slot would recurse instead of raising. + if name.startswith('_') or name in ('ctx', 'name', 'value', 'args'): + raise AttributeError(name) + operators = self.ctx.signature.operators + if name not in operators: + raise AttributeError( + f"{self.ctx.describe()} declares no operator {name!r}; " + f"available: {sorted(operators)}") + + def bound(*others): + return self.ctx._apply_operator(name, [self, *others]) + + return bound + + # -- arithmetic-style operators ----------------------------------- + def _binop(self, op: str, other, swap: bool = False) -> 'Term': + args = [self.ctx.term(other), self] if swap else [self, self.ctx.term(other)] + result = self.ctx._apply_operator(op, args) + if not isinstance(result, Term): + raise TypeError(f"operator {op!r} of {self.ctx.describe()} is a " + f"relation, not a function; use it as a method") + return result + + def __add__(self, other): return self._binop('+', other) + def __radd__(self, other): return self._binop('+', other, swap=True) + def __matmul__(self, other): return self._binop('@', other) + def __rmatmul__(self, other): return self._binop('@', other, swap=True) + + def __neg__(self): + return self.ctx._apply_operator('-', [self]) + + def __sub__(self, other): + other = self.ctx.term(other) + if '-' in self.ctx.signature.operators and self.ctx._is_binary('-'): + return self._binop('-', other) + return self + (-other) + + def __rsub__(self, other): + return self.ctx.term(other) - self + + def __mul__(self, other): + if '*' in self.ctx.signature.operators: + return self._binop('*', other) + if isinstance(other, int): + return self.times(other) + raise TypeError( + f"{self.ctx.describe()} declares no operator '*'; multiplication " + f"by an integer is available as .times(n)") + + def __rmul__(self, other): + return self.__mul__(other) + + def times(self, n: int) -> 'Term': + """The ``n``-fold sum ``self + ... + self`` under the structure's + ``'+'``, built by base-2 decomposition so that only + :math:`O(\\log_2 n)` distinct subterms are created. + + Negative ``n`` requires the structure to declare a ``'-'`` inverse. + """ + if not isinstance(n, int): + raise TypeError("times(n) needs an integer") + negative = n < 0 + n = abs(n) + if n == 0: + raise ValueError( + "times(0) has no generic meaning; use the structure's zero " + "constant explicitly") + + doubling, total = self, None + for i in range(math.floor(math.log2(n)) + 1): + if i > 0: + doubling = doubling + doubling + if (n >> i) & 1: + total = doubling if total is None else total + doubling + return -total if negative else total + + def substitute(self, **replacements) -> 'Term': + """Replace free variables. Terms are immutable, so this returns a new + term and never disturbs expressions that share this one.""" + return self._substitute( + {k: self.ctx.term(v) for k, v in replacements.items()}) + + def _substitute(self, mapping) -> 'Term': + raise NotImplementedError + + +class Var(Term): + """A free variable.""" + + __slots__ = ('name',) + + def __init__(self, ctx, name: str): + super().__init__(ctx, (name,)) + object.__setattr__(self, 'name', name) + + def variables(self): + return [self.name] + + def _substitute(self, mapping): + return mapping.get(self.name, self) + + def __str__(self): + return self.name + + +class Const(Term): + """A Python value, encoded through the signature's codec.""" + + __slots__ = ('value',) + + def __init__(self, ctx, value): + super().__init__(ctx, (value,)) + object.__setattr__(self, 'value', value) + + def variables(self): + return [] + + def _substitute(self, mapping): + return self + + def __str__(self): + return repr(self.value) + + +class Apply(Term): + """An application ``f(t_1, ..., t_n)`` of a declared function symbol.""" + + __slots__ = ('func', 'args') + + def __init__(self, ctx, func: str, args: Sequence[Term]): + args = tuple(args) + super().__init__(ctx, (func, args)) + object.__setattr__(self, 'func', func) + object.__setattr__(self, 'args', args) + + def variables(self): + return sorted({v for a in self.args for v in a.variables()}) + + def _substitute(self, mapping): + return Apply(self.ctx, self.func, + [a._substitute(mapping) for a in self.args]) + + def __str__(self): + return f"{self.func}({', '.join(str(a) for a in self.args)})" + + +# ====================================================================== +# Formulas +# ====================================================================== + +class Formula(Node): + """A formula denoting a relation over the structure.""" + + __slots__ = () + + def variables(self) -> List[str]: + """Free variable names, sorted. This is also the tape order of the + automaton produced by `evaluate`.""" + raise NotImplementedError + + # -- boolean algebra ---------------------------------------------- + def __and__(self, other): return And(self.ctx, self, _formula(self.ctx, other)) + def __or__(self, other): return Or(self.ctx, self, _formula(self.ctx, other)) + def __invert__(self): return Not(self.ctx, self) + + def implies(self, other) -> 'Formula': + return ~self | _formula(self.ctx, other) + + def iff(self, other) -> 'Formula': + other = _formula(self.ctx, other) + return (~self | other) & (~other | self) + + # -- quantification ----------------------------------------------- + def drop(self, variables) -> 'Formula': + """Project the relation away from ``variables`` -- equivalently, + existentially quantify them.""" + return Exists(self.ctx, _names(variables), self) + + ex = drop + + def all(self, variables) -> 'Formula': + """Universally quantify ``variables``.""" + return Forall(self.ctx, _names(variables), self) + + def exinf(self, variable) -> 'Formula': + """:math:`\\exists^\\infty` -- the tuples extended by infinitely many + witnesses for ``variable``.""" + names = _names(variable) + if len(names) != 1: + raise ValueError("exinf quantifies a single variable") + return ExInf(self.ctx, names[0], self) + + def substitute(self, **replacements) -> 'Formula': + """Replace free variables, avoiding capture by bound variables.""" + return self._substitute( + {k: self.ctx.term(v) for k, v in replacements.items()}) + + def _substitute(self, mapping) -> 'Formula': + raise NotImplementedError + + # -- evaluation --------------------------------------------------- + def evaluate(self): + """Compile to a `Relation`: the presentation of the satisfying + assignments together with its tape order.""" + return self.ctx.evaluate(self) + + def check(self) -> bool: + """True if the relation is non-empty (free variables read as + existentially quantified).""" + return self.ctx.check(self) + + def is_empty(self) -> bool: + return self.evaluate().is_empty() + + def is_finite(self) -> bool: + return self.evaluate().is_finite() + + def contains(self, *args, **assignment) -> bool: + return self.evaluate().contains(*args, **assignment) + + def __contains__(self, item): + return item in self.evaluate() + + def __iter__(self): + return iter(self.evaluate()) + + def materialize(self, name: str = None): + """Evaluate now and return a formula standing for the resulting + automaton. Use it to share an expensive subformula across queries; + the compiled automaton is spliced in instead of being rebuilt.""" + return self.ctx.materialize(self, name) + + +class Atom(Formula): + """``R(t_1, ..., t_n)`` for a relation symbol of the signature.""" + + __slots__ = ('symbol', 'args') + + def __init__(self, ctx, symbol: str, args: Sequence[Term]): + args = tuple(args) + super().__init__(ctx, (symbol, args)) + object.__setattr__(self, 'symbol', symbol) + object.__setattr__(self, 'args', args) + + def variables(self): + return sorted({v for a in self.args for v in a.variables()}) + + def _substitute(self, mapping): + return Atom(self.ctx, self.symbol, + [a._substitute(mapping) for a in self.args]) + + def __str__(self): + return f"{self.symbol}({', '.join(str(a) for a in self.args)})" + + +class DfaAtom(Formula): + """An atom backed by an automaton supplied directly rather than by a + signature symbol -- the splice point for `Formula.materialize` and for + automata built outside the symbolic layer.""" + + __slots__ = ('dfa', 'args', 'label', 'prepared') + + def __init__(self, ctx, dfa, args: Sequence[Term], label: str = 'anon', + prepared: bool = False): + args = tuple(args) + super().__init__(ctx, (id(dfa), args, label)) + object.__setattr__(self, 'dfa', dfa) + object.__setattr__(self, 'args', args) + object.__setattr__(self, 'label', label) + object.__setattr__(self, 'prepared', prepared) + + def variables(self): + return sorted({v for a in self.args for v in a.variables()}) + + def _substitute(self, mapping): + return DfaAtom(self.ctx, self.dfa, + [a._substitute(mapping) for a in self.args], + self.label, self.prepared) + + def __str__(self): + return f"<{self.label}>({', '.join(str(a) for a in self.args)})" + + +class Not(Formula): + __slots__ = ('body',) + + def __init__(self, ctx, body: Formula): + super().__init__(ctx, (body,)) + object.__setattr__(self, 'body', body) + + def variables(self): + return self.body.variables() + + def _substitute(self, mapping): + return Not(self.ctx, self.body._substitute(mapping)) + + def __str__(self): + return f"not {self.body}" + + +class _Binary(Formula): + __slots__ = ('left', 'right') + connective = '?' + + def __init__(self, ctx, left: Formula, right: Formula): + super().__init__(ctx, (left, right)) + object.__setattr__(self, 'left', left) + object.__setattr__(self, 'right', right) + + def variables(self): + return sorted(set(self.left.variables()) | set(self.right.variables())) + + def _substitute(self, mapping): + return type(self)(self.ctx, self.left._substitute(mapping), + self.right._substitute(mapping)) + + def __str__(self): + return f"({self.left} {self.connective} {self.right})" + + +class And(_Binary): + connective = 'and' + __slots__ = () + + +class Or(_Binary): + connective = 'or' + __slots__ = () + + +class _Quantifier(Formula): + __slots__ = ('bound', 'body') + keyword = '?' + + def __init__(self, ctx, bound: Sequence[str], body: Formula): + bound = tuple(bound) + super().__init__(ctx, (bound, body)) + object.__setattr__(self, 'bound', bound) + object.__setattr__(self, 'body', body) + + def variables(self): + return sorted(set(self.body.variables()) - set(self.bound)) + + def _substitute(self, mapping): + # A replacement for a bound name does not reach into the body; a + # replacement whose *value* mentions a bound name would be captured, so + # the binder is renamed first. + inner = {k: v for k, v in mapping.items() if k not in self.bound} + if not inner: + return self + incoming = {v for t in inner.values() for v in t.variables()} + body, bound = self.body, list(self.bound) + for i, name in enumerate(bound): + if name in incoming: + # A fresh binder must avoid the body's *bound* names too, or + # renaming would just push the capture one level down. + fresh = self.ctx.fresh_name( + all_names(body) | incoming | set(bound)) + body = body._substitute({name: Var(self.ctx, fresh)}) + bound[i] = fresh + return type(self)(self.ctx, bound, body._substitute(inner)) + + def __str__(self): + return f"{self.keyword} {' '.join(self.bound)}. {self.body}" + + +class Exists(_Quantifier): + keyword = 'exists' + __slots__ = () + + +class Forall(_Quantifier): + keyword = 'forall' + __slots__ = () + + +class ExInf(Formula): + """:math:`\\exists^\\infty x. \\varphi`.""" + + __slots__ = ('variable', 'body') + + def __init__(self, ctx, variable: str, body: Formula): + super().__init__(ctx, (variable, body)) + object.__setattr__(self, 'variable', variable) + object.__setattr__(self, 'body', body) + + def variables(self): + return sorted(set(self.body.variables()) - {self.variable}) + + def _substitute(self, mapping): + inner = {k: v for k, v in mapping.items() if k != self.variable} + if not inner: + return self + return ExInf(self.ctx, self.variable, self.body._substitute(inner)) + + def __str__(self): + return f"exists-inf {self.variable}. {self.body}" + + +# ====================================================================== +# helpers +# ====================================================================== + +def all_names(node) -> set: + """Every variable name occurring in a formula, free or bound.""" + names = set(node.variables()) + if isinstance(node, _Quantifier): + names |= set(node.bound) | all_names(node.body) + elif isinstance(node, ExInf): + names |= {node.variable} | all_names(node.body) + elif isinstance(node, Not): + names |= all_names(node.body) + elif isinstance(node, _Binary): + names |= all_names(node.left) | all_names(node.right) + return names + + +def _formula(ctx, x) -> Formula: + if isinstance(x, Formula): + if x.ctx is not ctx: + raise ValueError("cannot combine formulas from different structures") + return x + raise TypeError(f"expected a formula, got {type(x).__name__}") + + +def _names(variables) -> List[str]: + """Accept a variable, a name, or an iterable of either.""" + if isinstance(variables, (str, Var)): + variables = [variables] + names = [] + for v in variables: + if isinstance(v, Var): + names.append(v.name) + elif isinstance(v, str): + names.append(v) + else: + raise TypeError(f"not a variable: {v!r}") + return names diff --git a/autstr/symbolic/signature.py b/autstr/symbolic/signature.py new file mode 100644 index 0000000..068238c --- /dev/null +++ b/autstr/symbolic/signature.py @@ -0,0 +1,103 @@ +"""Signatures: what a structure exposes to the symbolic layer. + +An automatic presentation is a bag of automata keyed by relation symbol. A +signature adds the information the symbolic layer needs on top of that: which +relations are graphs of functions, which Python operators those functions are +bound to, and how Python values translate to and from element encodings. + +Arities are never declared -- they are read off the automata themselves +(`dfa.symbol_arity`, minus the advice tape for a uniformly automatic class). +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional, Sequence + +# Python operators a signature may bind to a function symbol. The keys are the +# names used in `Signature.operators`; `expr` dispatches through them. +BINARY_OPERATORS = ('+', '-', '*', '@') +UNARY_OPERATORS = ('-',) + + +@dataclass(frozen=True) +class Function: + """A function symbol, presented by the automaton of its graph. + + :param graph: relation symbol whose automaton recognizes the graph. + :param out: position of the output tape in the graph relation. Defaults to + the last tape; negative values count from the end. + :param arity: number of inputs. Derived from the graph relation's arity + when the signature is bound to a structure. + """ + graph: str + out: int = -1 + + def positions(self, graph_arity: int) -> tuple: + """(input positions in order, output position) for a graph of the + given arity.""" + out = self.out % graph_arity + inputs = tuple(i for i in range(graph_arity) if i != out) + return inputs, out + + +class ElementCodec: + """Translation between Python values and element encodings. + + An encoding is a list of base-alphabet symbols, in the order the automata + read them. Supplying a codec is optional: without one the symbolic layer + still works, but constants cannot be written as Python values and solutions + are yielded as raw words. + """ + + def encode(self, value: Any) -> List: + raise NotImplementedError + + def decode(self, word: Sequence) -> Any: + raise NotImplementedError + + +@dataclass(frozen=True) +class FunctionCodec(ElementCodec): + """A codec built from two plain functions.""" + encoder: Callable[[Any], List] + decoder: Optional[Callable[[Sequence], Any]] = None + + def encode(self, value): + return list(self.encoder(value)) + + def decode(self, word): + if self.decoder is None: + raise NotImplementedError("this codec cannot decode") + return self.decoder(word) + + +@dataclass +class Signature: + """The symbolic-layer description of a structure's signature. + + :param functions: function symbol -> `Function`. The graph relation must + exist in the presentation. + :param operators: Python operator or method name -> symbol it dispatches + to. Keys may name a function symbol's operator (``'+'``, ``'-'``, + ``'*'``, ``'@'``) or a relation method (any identifier, e.g. ``'lt'``, + ``'eq'``), and values are function or relation symbols respectively. + :param codec: optional `ElementCodec` for constants and decoding. + :param relations: optional arity overrides. Normally arities come from the + automata; entries here are only consulted for symbols that are not + (yet) present in the presentation. + """ + functions: Dict[str, Function] = field(default_factory=dict) + operators: Dict[str, str] = field(default_factory=dict) + codec: Optional[ElementCodec] = None + relations: Dict[str, int] = field(default_factory=dict) + + def function(self, name: str, graph: str, out: int = -1) -> Signature: + """Declare a function symbol. Returns self, so declarations chain.""" + self.functions[name] = Function(graph=graph, out=out) + return self + + def operator(self, op: str, symbol: str) -> Signature: + """Bind a Python operator or method name to a function or relation + symbol. Returns self, so declarations chain.""" + self.operators[op] = symbol + return self diff --git a/autstr/uniform.py b/autstr/uniform.py index 332a1e1..fba4efe 100644 --- a/autstr/uniform.py +++ b/autstr/uniform.py @@ -130,6 +130,21 @@ def get_relation_symbols(self) -> List[str]: """All relation symbols of the class signature ('U' is the domain).""" return list(self.class_automata.keys()) + def symbolic(self, signature=None): + """A symbolic interface to this class. Expressions are written over + the class signature exactly as for a single structure; the advice tape + is added and quantifiers relativized to the member domain during + compilation, and results carry the advice under the tape name + ``'advice'``. + + :param signature: declared functions and operators. An element codec + has no advice-free meaning here and is not used. + :return: a `autstr.symbolic.SymbolicContext`. + """ + from autstr.symbolic.backends import ClassBackend + from autstr.symbolic.context import SymbolicContext + return SymbolicContext(ClassBackend(self), signature) + @staticmethod def _variable_names(phi: logic.Expression) -> set: """All variable names occurring in phi (free and bound).""" diff --git a/autstr/utils/automata_tools.py b/autstr/utils/automata_tools.py index 9e033a8..31571a6 100644 --- a/autstr/utils/automata_tools.py +++ b/autstr/utils/automata_tools.py @@ -109,6 +109,39 @@ def unpad(dfa: SparseDFA, padding_symbol: int = -1) -> SparseDFA: start_state=dfa.start_state, symbol_arity=arity, base_alphabet=base_alphabet, nodes=dfa.nodes).minimize() +def canonical(dfa: SparseDFA, padding_symbol: int = -1) -> SparseDFA: + """Keep only the canonical convolution of each tuple: words in which no + position is padding on *every* tape. + + `pad` and `unpad` deliberately leave the all-padding self-loops in place, + so a relation automaton accepts every tuple in infinitely many spellings -- + the convolution followed by any number of all-padding columns. That is + invisible to membership and to enumeration (`iterate_language` skips the + all-padding symbol), but it makes every non-empty relation look infinite to + a word-level cycle test. Restricting to canonical words first is what makes + finiteness and counting questions about *tuples* rather than about words. + """ + arity = dfa.symbol_arity + base_alphabet = dfa.base_alphabet + if padding_symbol == -1: + padding_symbol = sorted(base_alphabet)[0] + pad_enc = encode_symbol((padding_symbol,) * arity, base_alphabet) + + # State 0 accepts anything until the all-padding symbol drops it into the + # non-accepting sink 1. + no_padding = SparseDFA( + num_states=2, + default_states=np.array([0, 1], dtype=np.int64), + exception_symbols=np.array([[pad_enc], [-1]], dtype=np.int64), + exception_states=np.array([[1], [-1]], dtype=np.int64), + is_accepting=np.array([True, False], dtype=bool), + start_state=0, + symbol_arity=arity, + base_alphabet=base_alphabet, + ) + return dfa.intersection(no_padding).minimize() + + def product(dfa: SparseDFA, n: int) -> SparseDFA: """Create the n-fold Cartesian product of the automaton's language.""" if n == 0: diff --git a/tests/test_symbolic.py b/tests/test_symbolic.py new file mode 100644 index 0000000..77876b3 --- /dev/null +++ b/tests/test_symbolic.py @@ -0,0 +1,427 @@ +"""Tests for the generic symbolic interface.""" +from itertools import islice + +import pytest + +from autstr.buildin.presentations import BuechiArithmeticZ +from autstr.symbolic import ( + CompileError, FunctionCodec, Signature, SymbolicSymbolError, +) + + +def _encode(n: int): + """LSB-first encoding of an integer: sign symbol, then magnitude bits.""" + return [('0' if n >= 0 else '1')] + list(format(abs(n), 'b')[::-1]) + + +def _decode(word): + word = ''.join(word).replace('*', '') + magnitude = int(word[1:][::-1] or '0', 2) + return magnitude if word[0] == '0' else -magnitude + + +def arithmetic_signature() -> Signature: + signature = Signature(codec=FunctionCodec(_encode, _decode)) + signature.function('+', graph='A', out=2) + signature.function('neg', graph='Neg', out=1) + signature.operator('+', '+').operator('-', 'neg') + signature.operator('eq', 'Eq').operator('lt', 'Lt').operator('gt', 'Gt') + return signature + + +@pytest.fixture(scope='module') +def S(): + return BuechiArithmeticZ().symbolic(arithmetic_signature()) + + +# ---------------------------------------------------------------------- +# terms and operators +# ---------------------------------------------------------------------- + +def test_addition_term(S): + x, y, z = S.vars('x y z') + relation = (x + y).eq(z).evaluate() + assert relation.variables == ['x', 'y', 'z'] + assert relation.contains(x=3, y=4, z=7) + assert not relation.contains(x=3, y=4, z=8) + + +def test_positional_membership_follows_tape_order(S): + x, y, z = S.vars('x y z') + relation = (x + y).eq(z).evaluate() + assert (3, 4, 7) in relation + assert (3, 4, 8) not in relation + + +def test_nested_terms(S): + x, y, z = S.vars('x y z') + relation = ((x + y) + z).eq(S.const(10)).evaluate() + assert relation.contains(x=2, y=3, z=5) + assert not relation.contains(x=2, y=3, z=6) + + +def test_constants_from_python_values(S): + x, y = S.vars('x y') + relation = (x + 5).eq(y).evaluate() + assert relation.contains(x=10, y=15) + assert not relation.contains(x=10, y=16) + + +def test_negation_operator(S): + x, y = S.vars('x y') + relation = (-x).eq(y).evaluate() + assert relation.contains(x=5, y=-5) + assert not relation.contains(x=5, y=5) + + +def test_subtraction_via_declared_inverse(S): + x, y, z = S.vars('x y z') + relation = (x - y).eq(z).evaluate() + assert relation.contains(x=9, y=4, z=5) + assert not relation.contains(x=9, y=4, z=6) + + +def test_times_uses_base_two_decomposition(S): + x, y = S.vars('x y') + relation = x.times(5).eq(y).evaluate() + assert relation.contains(x=3, y=15) + assert not relation.contains(x=3, y=14) + + +def test_method_operators_from_signature(S): + x, y = S.vars('x y') + relation = x.lt(y).evaluate() + assert relation.contains(x=2, y=7) + assert not relation.contains(x=7, y=2) + + +def test_undeclared_operator_is_an_error(S): + x, y = S.vars('x y') + with pytest.raises(AttributeError, match='no operator'): + x.divides(y) + + +def test_undeclared_multiplication_is_an_error(S): + x, y = S.vars('x y') + with pytest.raises(TypeError, match="no operator '\\*'"): + x * y + + +# ---------------------------------------------------------------------- +# symbols +# ---------------------------------------------------------------------- + +def test_relation_symbol_arity_is_read_from_the_automaton(S): + R = S.get_symbolic_rel('A') + assert R.arity == 3 + + +def test_function_symbol_arity_is_read_from_its_graph(S): + f = S.get_symbolic_func('+') + assert f.arity == 2 + + +def test_function_application_matches_the_operator(S): + x, y, z = S.vars('x y z') + f = S.get_symbolic_func('+') + assert f(x, y).eq(z) == (x + y).eq(z) + + +def test_wrong_relation_arity_is_reported(S): + x, y = S.vars('x y') + with pytest.raises(CompileError, match='arity 3'): + S.atom('A', [x, y]).evaluate() + + +def test_wrong_function_arity_is_reported(S): + x, = S.vars('x') + f = S.get_symbolic_func('+') + with pytest.raises(SymbolicSymbolError, match='arity 2'): + f(x) + + +def test_unknown_relation_is_reported(S): + with pytest.raises(SymbolicSymbolError, match='no relation'): + S.get_symbolic_rel('NoSuchRelation') + + +def test_unknown_function_is_reported(S): + with pytest.raises(SymbolicSymbolError, match='no function'): + S.get_symbolic_func('nope') + + +# ---------------------------------------------------------------------- +# variable names +# ---------------------------------------------------------------------- + +def test_names_illegal_for_nltk_still_work(S): + """`nltk` only accepts [a-df-z][0-9]* as an individual variable and + silently drops anything else from the free-variable list. These names all + fall outside that pattern.""" + alpha, beta, e = S.vars(['alpha', 'beta', 'e']) + relation = (alpha + beta).eq(e).evaluate() + assert relation.variables == ['alpha', 'beta', 'e'] + assert relation.contains(alpha=3, beta=4, e=7) + assert not relation.contains(alpha=3, beta=4, e=8) + + +def test_tape_order_is_the_sorted_variable_names(S): + b, a = S.vars('b a') + assert (a + b).eq(S.const(3)).evaluate().variables == ['a', 'b'] + + +def test_reorder_permutes_tapes(S): + x, y, z = S.vars('x y z') + relation = (x + y).eq(z).evaluate().reorder(['z', 'y', 'x']) + assert relation.variables == ['z', 'y', 'x'] + assert relation.contains(7, 4, 3) + assert relation.contains(x=3, y=4, z=7) + + +# ---------------------------------------------------------------------- +# boolean algebra and quantification +# ---------------------------------------------------------------------- + +def test_conjunction(S): + x, y, z = S.vars('x y z') + relation = ((x + y).eq(z) & x.lt(y)).evaluate() + assert relation.contains(x=2, y=5, z=7) + assert not relation.contains(x=5, y=2, z=7) + + +def test_disjunction(S): + x, y = S.vars('x y') + relation = (x.lt(y) | x.eq(y)).evaluate() + assert relation.contains(x=2, y=5) + assert relation.contains(x=5, y=5) + assert not relation.contains(x=6, y=5) + + +def test_complement(S): + x, y = S.vars('x y') + relation = (~x.lt(y)).evaluate() + assert relation.contains(x=5, y=2) + assert not relation.contains(x=2, y=5) + + +def test_drop_projects(S): + x, y, z = S.vars('x y z') + relation = ((x + y).eq(z)).drop(y).evaluate() + assert relation.variables == ['x', 'z'] + assert relation.contains(x=3, z=9) + + +def test_universal_quantification(S): + x, y = S.vars('x y') + # every integer has a strictly larger one + assert x.lt(y).drop(x).all(y).check() + # but not every integer is below a fixed bound + assert not x.lt(S.const(5)).all(x).check() + + +def test_implication_and_iff(S): + x, y = S.vars('x y') + assert x.lt(y).implies(~y.lt(x)).all([x, y]).check() + assert x.eq(y).iff(~(x.lt(y) | y.lt(x))).all([x, y]).check() + + +def test_exinf(S): + x, y = S.vars('x y') + # infinitely many y exceed each x + assert x.lt(y).exinf(y).check() + # but only finitely many lie strictly between 0 and x + assert not (S.const(0).lt(y) & y.lt(x)).exinf(y).check() + + +# ---------------------------------------------------------------------- +# immutability and substitution +# ---------------------------------------------------------------------- + +def test_expressions_compare_structurally(S): + x, y = S.vars('x y') + assert (x + y).eq(S.const(3)) == (x + y).eq(S.const(3)) + assert {(x + y).eq(S.const(3)), (x + y).eq(S.const(3))}.__len__() == 1 + + +def test_substitution_leaves_the_original_alone(S): + x, y = S.vars('x y') + base = (x + y).eq(S.const(9)) + renamed = base.substitute(y='w') + assert base.variables() == ['x', 'y'] + assert renamed.variables() == ['w', 'x'] + assert renamed.evaluate().contains(w=4, x=5) + + +def test_substitution_avoids_capture(S): + x, y, z = S.vars('x y z') + # y is bound here, so substituting x by y must rename the binder rather + # than let the incoming y fall under the quantifier + formula = ((x + y).eq(z)).drop(y).substitute(x='y') + assert formula.variables() == ['y', 'z'] + relation = formula.evaluate() + assert relation.contains(y=3, z=9) + + +def test_nodes_are_immutable(S): + x, y = S.vars('x y') + with pytest.raises(AttributeError, match='immutable'): + (x + y).func = 'other' + + +# ---------------------------------------------------------------------- +# evaluation surface +# ---------------------------------------------------------------------- + +def test_check_and_emptiness(S): + x, y = S.vars('x y') + assert (x + y).eq(S.const(4)).check() + assert (x.lt(y) & y.lt(x)).is_empty() + + +def test_finiteness(S): + x, y = S.vars('x y') + assert ((x + y).eq(S.const(4)) & x.lt(y) & S.const(0).lt(x)).is_finite() + assert not x.lt(y).is_finite() + + +def test_finiteness_ignores_padding_spellings(S): + """A relation automaton accepts each tuple followed by any number of + all-padding columns, so a word-level cycle test calls every non-empty + relation infinite. Finiteness has to be asked of the tuples.""" + x, y = S.vars('x y') + single_point = x.eq(S.const(1)) & y.eq(S.const(3)) + assert single_point.is_finite() + assert not single_point.evaluate().dfa.is_finite() # the word language + + +def test_iteration_decodes_through_the_codec(S): + x, y = S.vars('x y') + formula = (x + y).eq(S.const(4)) & S.const(0).lt(x) & x.lt(y) + solutions = sorted(islice(iter(formula), 3)) + assert solutions == [(1, 3)] + + +def test_materialize_splices_a_compiled_automaton(S): + x, y, z = S.vars('x y z') + shared = ((x + y).eq(z)).materialize('sum') + combined = (shared & z.lt(S.const(100))).evaluate() + assert combined.variables == ['x', 'y', 'z'] + assert combined.contains(x=3, y=4, z=7) + assert not combined.contains(x=60, y=60, z=120) + + +def test_compile_reports_the_query_and_tape_order(S): + x, y, z = S.vars('x y z') + expression, variables = S.compile((x + y).eq(z)) + assert variables == ['x', 'y', 'z'] + assert 'A(' in str(expression) and 'Eq(' in str(expression) + + +# ---------------------------------------------------------------------- +# structures without a signature +# ---------------------------------------------------------------------- + +def test_bare_structure_needs_no_signature(): + S = BuechiArithmeticZ().symbolic() + x, y, z = S.vars('x y z') + relation = S.atom('A', [x, y, z]).evaluate() + assert relation.variables == ['x', 'y', 'z'] + + +def test_constants_need_a_codec(): + S = BuechiArithmeticZ().symbolic() + with pytest.raises(SymbolicSymbolError, match='no element codec'): + S.const(3) + + +# ---------------------------------------------------------------------- +# uniformly automatic classes +# ---------------------------------------------------------------------- + +@pytest.fixture(scope='module') +def depth2(): + from autstr.graphs import TreeDepthClass + return TreeDepthClass(2) + + +def test_class_arities_exclude_the_advice_tape(depth2): + K = depth2.symbolic() + assert K.rel('E').arity == 2 + assert K.rel('Sing').arity == 1 + + +def test_class_query_carries_the_advice_tape(depth2): + K = depth2.symbolic() + x, y = K.vars('x y') + relation = (~K.rel('Sing')(x) | K.rel('E')(x, y).drop(y)).all(x).evaluate() + assert relation.variables == ['advice'] + + +def test_class_query_agrees_with_the_string_api(depth2): + import networkx as nx + from autstr.graphs import TreeDepthGraph + + K = depth2.symbolic() + x, y = K.vars('x y') + symbolic = (~K.rel('Sing')(x) | K.rel('E')(x, y).drop(y)).all(x) + string = 'all x.((not Sing(x)) or exists y.(E(x,y)))' + + for graph in [nx.star_graph(1), nx.empty_graph(2), nx.path_graph(3)]: + g = TreeDepthGraph.from_networkx(graph) + assert K.check_member(symbolic, depth2.advice(g)) == depth2.check(string, g) + + +def test_class_member_becomes_an_ordinary_structure(depth2): + import networkx as nx + from autstr.graphs import TreeDepthGraph + + K = depth2.symbolic() + g = TreeDepthGraph.from_networkx(nx.star_graph(1)) + member = K.get_structure(depth2.advice(g)).symbolic() + a, b = member.vars('a b') + assert member.rel('E')(a, b).evaluate().variables == ['a', 'b'] + + +def test_class_constants_are_refused(depth2): + K = depth2.symbolic() + with pytest.raises(SymbolicSymbolError, match='no element codec'): + K.const(3) + + +def test_class_reserves_the_advice_name(depth2): + K = depth2.symbolic() + with pytest.raises(SymbolicSymbolError, match='reserved'): + K.var('advice') + + +def test_substitution_avoids_capture_by_inner_binders(S): + """The fresh binder has to dodge names bound deeper in the body, not just + the free ones.""" + x, y, z = S.vars('x y z') + inner = ((x + y).eq(z) & (x + z).eq(y).drop('_v0')) + formula = inner.drop(y).substitute(x='y') + assert formula.variables() == ['y', 'z'] + assert not formula.evaluate().is_empty() + + +def test_member_evaluation_is_refused_on_a_plain_structure(S): + x, = S.vars('x') + with pytest.raises(NotImplementedError, match='no members'): + S.check_member(x.lt(S.const(3)), advice=[]) + + +def test_class_implicit_and_explicit_checking_agree(depth2): + """Implicit evaluation never builds a query automaton; it must still give + the same answers as the compiled one.""" + import networkx as nx + from autstr.graphs import TreeDepthGraph + + K = depth2.symbolic() + depth2.cls.element_alphabet = ['0', '1'] + x, y = K.vars('x y') + formula = (~K.rel('Sing')(x) | K.rel('E')(x, y).drop(y)).all(x) + + for graph in [nx.star_graph(1), nx.empty_graph(2)]: + advice = depth2.advice(TreeDepthGraph.from_networkx(graph)) + assert (K.check_member(formula, advice, implicit=True) + == K.check_member(formula, advice)) From 05eba3092d712b4faab7757ee834c86383277587 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Tue, 21 Jul 2026 23:07:13 +0200 Subject: [PATCH 02/76] =?UTF-8?q?Rewrite=20B=C3=BCchi=20arithmetic=20on=20?= =?UTF-8?q?the=20symbolic=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `autstr/arithmetic.py` becomes a signature plus an encoder/decoder: 803 lines down to 108. The old `VariableETerm`/`Term` API is gone -- the symbolic layer subsumes it, and keeping a second term algebra alongside it would mean two compilers to keep in agreement. The deleted per-term deepcopy was ugly but not slow: `deepcopy` of a `BuechiArithmeticZ` measures 0.13 ms and never appeared in the profile. The cost it was blamed for was the re-preparation now handled by `prepared_updates`. Co-Authored-By: Claude Opus 4.8 --- autstr/arithmetic.py | 854 ++++----------------------------------- tests/test_arithmetic.py | 222 +++++----- 2 files changed, 185 insertions(+), 891 deletions(-) diff --git a/autstr/arithmetic.py b/autstr/arithmetic.py index 94646d7..d33a0f9 100644 --- a/autstr/arithmetic.py +++ b/autstr/arithmetic.py @@ -1,803 +1,85 @@ +"""Büchi arithmetic over the integers, as a symbolic structure. + +:math:`(\\mathbb{Z}, +, <, \\mid_2)` presented in base 2, with the generic +symbolic interface of `autstr.symbolic` on top: build terms and formulas with +Python operators, then evaluate, model check, test for emptiness or +finiteness, and enumerate solutions. + + >>> Z = integers() + >>> x, y, z = Z.vars("x y z") + >>> phi = (x + y).eq(z) & z.lt(100) + >>> phi.check() + True + >>> phi.evaluate().contains(x=3, y=4, z=7) + True + +Integers are written directly wherever a term is expected -- ``x + 5``, +``x.lt(100)`` -- and solutions come back as Python integers. + +The signature is that of the underlying presentation: ``A`` (addition graph), +``Lt``/``Gt``/``Eq``, ``Neg`` (the graph of negation), ``N0`` (non-negative), +``Z`` (zero), ``Pt`` (powers of two) and ``B``, where ``B(x, y)`` holds iff +``y`` is a power of two dividing ``x``. Anything the operators do not cover is +reachable through `SymbolicContext.rel`. +""" from __future__ import annotations -from abc import ABC, abstractmethod -from copy import deepcopy, copy -from typing import List, Union, Tuple, Dict -import math +from functools import lru_cache +from typing import List -from autstr.buildin.automata import k_longer_automaton -from autstr.sparse_automata import SparseDFA -from autstr.utils.automata_tools import iterate_language, lsbf_Z_automaton from autstr.buildin.presentations import BuechiArithmeticZ -from autstr.utils.misc import get_unique_id -from autstr.utils.misc import encode_symbol, decode_symbol +from autstr.symbolic import FunctionCodec, Signature, SymbolicContext +#: Base-2 encoding: a sign symbol followed by the magnitude, least significant +#: bit first. This is the convention of `lsbf_Z_automaton`. +SIGN_POSITIVE = '0' +SIGN_NEGATIVE = '1' +PADDING = '*' -class Term(ABC): - """ - Abstract class representing a term over the (base 2) Büchi arithmetic over the integers :math:`\\mathbb{Z}` - """ - arithmetic = BuechiArithmeticZ() - - def __init__(self): - self.presentation = None - - @abstractmethod - def update_presentation(self, recursive=True) -> None: - """ - Updates the internal presentation of the term. - - :param recursive: If True, recursively updates the presentation of all sub-relations - :return: - """ - raise NotImplementedError - - def evaluate(self) -> SparseDFA: - """ - Returns automatic presentation of the relation. - - :return: - """ - if self.presentation is None: - self.update_presentation() - - return self.presentation - - @abstractmethod - def get_variables(self) -> List[str]: - """ - Get all free variables of a term. - - :return: - """ - raise NotImplementedError - - def substitute(self, allow_collision: bool = False, inplace=False, **kwargs) -> Term: - if not inplace: - result = deepcopy(self) - result._substitute(allow_collision, inplace=True) - return result - else: - self._substitute_inplace(allow_collision) - return self - - @abstractmethod - def _substitute_inplace(self, allow_collision: bool = False, **kwargs) -> Term: - """ - Substitute variable names in the relation. - - :param allow_collision: if True, does not check collision with quantified - :param inplace: if True, performs the substitution inplace. Note that this will have side effects on all terms - in which self appears. - :param kwargs: dictionary of variable names and their substitution terms. - :return: - """ - raise NotImplementedError - - -class RelationalAlgebraTerm(Term, ABC): - def __and__(self, other: RelationalAlgebraTerm) -> IntersectionRATerm: - """ - Intersection - - :param other: - :return: A term that presents the intersection of self and other - """ - return IntersectionRATerm(self, other) - - def __or__(self, other: RelationalAlgebraTerm) -> UnionRATerm: - """ - Union - - :param other: - :return: A term that presents the union of self and other - """ - return UnionRATerm(self, other) - - def __invert__(self): - """ - Complement - - :return: A term that presents the complement of the current relation - """ - return ComplementRATerm(self) - - def __contains__(self, item): - """ - Check if a tuple is in the relation. - - :param item: - :return: - """ - signs = [str(int(x < 0)) for x in item] - words = list(format(abs(n), 'b')[::-1] for n in item) - words = [sign + b for sign, b in zip(signs, words)] - l_max = max([len(w) for w in words]) - for i, w in enumerate(words): - if len(w) < l_max: - difference = l_max - len(w) - words[i] = w + ('*' * difference) - - input_word = [tuple(w[i] for w in words) for i in range(l_max)] - return self.evaluate().accepts(input_word) - - def drop(self, variables: List[Union[str, VariableETerm]]) -> DropRATerm: - """ - Drop variables. - - :param variables: The variables to drop - :return: projection of the current relation onto the variables self.get_variables without variables - """ - return DropRATerm(self, variables) - - def ex(self, variables: List[Union[str, VariableETerm]]): - """ - Create Existential quantification term - :param variables: The variables that should be quantified - :return: projection of the current relation onto the variables self.get_variables without variables - """ - return DropRATerm(self, variables) - - def exinf(self, variable: Union[str, VariableETerm]): - """ - Represents the relation of the form :math:`\\{\\bar{x} | \\exists \\infty\\text{-many } y: R(\\bar{x}, y)\\}` - for some base relation :math:`R`. - - :param variable: Variable that should be :math:`\\exists^\\infty`-quantified - :return: - """ - return ExInfRATerm(self, variable) - - def isempty(self) -> bool: - """ - Checks if the current relation is empty. - - :return: True, if self presents an empty relation - """ - if self.presentation is None: - self.update_presentation() - - return self.presentation.is_empty() - - def isfinite(self) -> bool: - """ - checks if the number of solutions is finite. - - :return: True, if the relation contains only finitely many tuples - """ - if self.presentation is None: - self.update_presentation() - - return self.presentation.is_finite() - - def __iter__(self): - """ - Iterates all solutions by successively enumerating all solution tuples smaller than :math:`(2^n,...,2^n)` in - lexicographic order. The procedure guarantees that every solution tuple is enumerated exactly once. - - :return: - """ - if self.presentation is None: - self.update_presentation() - - for t in iterate_language(self.presentation, backward=True, padding_symbol='*'): - yield tuple( - int( - n.replace('*', '')[:-1], base=2 - ) if n.replace('*', '')[-1] == '0' else -int( - n.replace('*', '')[:-1], base=2 - ) for n in t - ) - - -class ExInfRATerm(RelationalAlgebraTerm): - def __init__(self, term: RelationalAlgebraTerm, variable: Union[str, VariableETerm]): - super().__init__() - self.subterm = term - self.variable = str(variable) - - def update_presentation(self, recursive=True) -> None: - if recursive: - self.subterm.update_presentation(recursive) - - sub_presentation = self.subterm.evaluate() - k_distance = sub_presentation.num_states + 1 - inf_witness = k_longer_automaton(k_distance, len(self.subterm.get_variables()) - 1, self.arithmetic.sigma, self.arithmetic.padding_symbol) - arithmetic = deepcopy(self.arithmetic) - T, L = get_unique_id(arithmetic.get_relation_symbols(), 2) - arithmetic.update(**{T: self.subterm.evaluate(), L: inf_witness}) - - psi_T = T + '(' + ','.join(self.subterm.get_variables()) + ')' - args_psi_L = ','.join([v for v in self.subterm.get_variables() if v != self.variable]) + ',' + self.variable - psi_L = L + '(' + args_psi_L + ')' - - phi = f'exists {self.variable}.({psi_L} and {psi_T})' - - self.presentation = arithmetic.evaluate(phi) - - def get_variables(self) -> List[str]: - variables = [v for v in self.subterm.get_variables() if v != self.variable] - variables.sort() - return variables - def _substitute_inplace(self, allow_collision: bool = False, **kwargs) -> None: - kw_rec = copy(kwargs) +def encode(n: int) -> List[str]: + """The word encoding an integer: sign symbol, then magnitude bits LSB + first.""" + sign = SIGN_POSITIVE if n >= 0 else SIGN_NEGATIVE + return [sign] + list(format(abs(n), 'b')[::-1]) - if self.variable in kwargs: - del kw_rec[self.variable] - if not allow_collision: - if self.variable in kwargs.values(): - v_new = get_unique_id(self.subterm.get_variables(), 1) - self.variable = v_new - self.subterm._substitute_inplace(**{str(self.variable): v_new}) +def decode(word) -> int: + """The integer encoded by a word, ignoring padding.""" + word = ''.join(word).replace(PADDING, '') + if not word: + raise ValueError("empty encoding") + magnitude = int(word[1:][::-1] or '0', base=2) + return magnitude if word[0] == SIGN_POSITIVE else -magnitude - self.subterm._substitute_inplace(**kw_rec) - self.presentation = None - return self +def signature() -> Signature: + """The signature of Büchi arithmetic: ``+`` and unary ``-`` as functions, + the order and equality as relational methods, and the integer codec.""" + sig = Signature(codec=FunctionCodec(encode, decode)) + sig.function('+', graph='A', out=2) + sig.function('neg', graph='Neg', out=1) + sig.operator('+', '+') + sig.operator('-', 'neg') + sig.operator('eq', 'Eq') + sig.operator('lt', 'Lt') + sig.operator('gt', 'Gt') + # B(x, y): y is a power of two dividing x. Spelled out as a method rather + # than bound to `|`, which on formulas already means union. + sig.operator('divided_by_power', 'B') + return sig -class BaseRATerm(RelationalAlgebraTerm): - """ - Represents a term of the form :math:`R(t_1,...,t_n)` for elementary terms :math:`t_1,...,t_n` - """ - - def _substitute_inplace(self, allow_collision: bool = False, **kwargs) -> BaseRATerm: - - kwargs = { - str(x): ElementaryTerm.to_term(kwargs[x]) for x in kwargs - } - for i, t in enumerate(self.terms): - if str(t) in kwargs.keys(): - self.terms[i] = kwargs[str(t)] - else: - t._substitute_inplace(allow_collision, **kwargs) - - self.presentation = None - - return self - - def __init__(self, relation_symbol, terms): - super(BaseRATerm, self).__init__() - self.R = relation_symbol - self.terms = [ConstantETerm(t) if isinstance(t, int) else t for t in terms] - - def get_variables(self) -> List[str]: - variables = [] - for t in self.terms: - variables = variables + t.get_variables() - variables = list(set(variables)) - variables.sort() - return variables - - def update_presentation(self, recursive=True, **kwargs) -> None: - if recursive: - for t in self.terms: - t.update_presentation(recursive) - - phi, update = self.to_fo() - - update = {R: update[R].evaluate() for R in update} - arithmetic = deepcopy(self.arithmetic) - arithmetic.update(**update) - self.presentation = arithmetic.evaluate(phi) - - def to_fo(self) -> Tuple[str, Dict[str, ElementaryTerm]]: - """ - Creates the a translation of the atomic formula :math:`R(t_1(\\bar{x}), ..., t_n(\\bar{x}))` into a relational first-order formula - with new - predicates for :math:`T_1,..., T_n` for the graphs of :math:`t_1,...,t_n`. The result will be of shape - :math:`\\exists y_1,...,y_n.(T_1(\\bar{x}, y_1) \\wedge ... \\wedge T_n(\\bar{x}, y_n) \\wedge R(y_1,...y_n))`. - The method guarantees that the newly created relation symbols :math:`T_1,...,T_n` - do not collide with already defined relation symbols. - :return: The relational formula and the mapping of new relation symbols to terms - """ - phi = self.R + '({})' +@lru_cache(maxsize=1) +def integers() -> SymbolicContext: + """The symbolic interface to Büchi arithmetic over :math:`\\mathbb{Z}`. - arithmetic = deepcopy(self.arithmetic) - - unique_vars = get_unique_id(self.get_variables(), len(self.terms)) - unique_rels = get_unique_id(arithmetic.get_relation_symbols(), len(self.terms)) - - final_vars = [] - - updates = {} - for R, t, x in zip(unique_rels, self.terms, unique_vars): - if isinstance(t, VariableETerm): - phi.format(t.get_name()) - final_vars.append(t.get_name()) - else: - final_vars.append(x) - guard = R + '(' + ','.join(t.get_variables() + [x]) + ')' - phi = f'exists {x}.({guard} and {phi})' - updates[R] = t - - phi = phi.format(','.join(final_vars)) - - return phi, updates - - -class BinaryRATerm(RelationalAlgebraTerm, ABC): - """ - Abstract class that represents binary relational algebra terms. + The presentation is built once and shared; expressions are immutable, so + sharing it is safe and avoids rebuilding the base automata per term. """ + return BuechiArithmeticZ().symbolic(signature()) - def _substitute_inplace(self, allow_collision: bool = False, **kwargs) -> BinaryRATerm: - self.left._substitute_inplace(allow_collision, **kwargs) - self.right._substitute_inplace(allow_collision, **kwargs) - self.presentation = None - - return self - - def get_variables(self) -> List[str]: - result = list(set(self.left.get_variables() + self.right.get_variables())) - result.sort() - return result - - def __init__(self, left: RelationalAlgebraTerm, right: RelationalAlgebraTerm): - super().__init__() - self._template = None - self.left = left - self.right = right - - def update_presentation(self, recursive=True) -> None: - """ - Builds presentation from the two sub-relations and combines then through a logical formula - - :param recursive: If True, call update_presentation for all sub-terms - :return: - """ - if recursive: - self.left.update_presentation(recursive=recursive) - self.right.update_presentation(recursive=recursive) - - arithmetic = deepcopy(self.arithmetic) - R0, R1 = get_unique_id(arithmetic.get_relation_symbols(), 2) - psi_R0 = R0 + '(' + ','.join(self.left.get_variables()) + ')' - psi_R1 = R1 + '(' + ','.join(self.right.get_variables()) + ')' - phi = self._template.format(psi_R0, psi_R1) - arithmetic.update(**{R0: self.left.evaluate(), R1: self.right.evaluate()}) - self.presentation = arithmetic.evaluate(phi) - - -class IntersectionRATerm(BinaryRATerm): - """ - Intersection of two relations. - """ - - def __init__(self, left: RelationalAlgebraTerm, right: RelationalAlgebraTerm): - super(IntersectionRATerm, self).__init__(left, right) - self._template = "(({} and {}))" - - -class UnionRATerm(BinaryRATerm): - """ - Union of two relations. - """ - - def __init__(self, left: RelationalAlgebraTerm, right: RelationalAlgebraTerm): - super().__init__(left, right) - self._template = "(({} or {}))" - - -class ComplementRATerm(RelationalAlgebraTerm): - """ - The complement of a relation - """ - - def _substitute_inplace(self, allow_collision: bool = False, **kwargs) -> ComplementRATerm: - self.relation._substitute_inplace(allow_collision, **kwargs) - self.presentation = None - - return self - - def __init__(self, relation: RelationalAlgebraTerm): - super().__init__() - self.relation = relation - - def update_presentation(self, recursive=True) -> None: - if recursive: - self.relation.update_presentation(recursive) - - arithmetic = deepcopy(self.arithmetic) - R0 = get_unique_id(arithmetic.get_relation_symbols(), 1) - psi_R0 = R0 + '(' + ','.join(self.relation.get_variables()) + ')' - phi = f'not ({psi_R0})' - arithmetic.update(**{R0: self.relation.evaluate()}) - self.presentation = arithmetic.evaluate(phi) - - def get_variables(self) -> List[str]: - return self.relation.get_variables() - - -class DropRATerm(RelationalAlgebraTerm): - """ - Relation of the shape :math:`\\{(x_1,...,x_n) | (x_1,...,x_n,y_1,...,y_m) \\in R\\}` where :math:`y_1,\\ldots,y_m` - are the dropped variables. - """ - - def _substitute_inplace(self, allow_collision: bool = False, **kwargs) -> None: - kwrec = copy(kwargs) - for x in self.variables: - if x in kwargs: - del kwrec[x] - - if not allow_collision: - for i, v in enumerate(self.variables): - if str(v) in kwargs.values(): - v_new = get_unique_id(self.relation.get_variables(), 1) - self.variables[i] = v_new - self.relation._substitute_inplace(**{str(v): v_new}) - - self.relation._substitute_inplace(**kwrec) - self.presentation = None - - return self - - def update_presentation(self, recursive: bool = True) -> None: - if recursive: - self.relation.update_presentation() - - ex_args = ' '.join(self.variables) - R_args = ','.join(self.relation.get_variables()) - arithmetic = deepcopy(self.arithmetic) - R0 = get_unique_id(arithmetic.get_relation_symbols(), 1) - - phi = f'exists {ex_args}.({R0}({R_args}))' - arithmetic.update(**{R0: self.relation.evaluate()}) - self.presentation = arithmetic.evaluate(phi) - - def get_variables(self) -> List[str]: - result = [v for v in self.relation.get_variables() if v not in self.variables] - result.sort() - return result - - def __init__(self, relation, variables): - super().__init__() - self.relation = relation - self.variables = [ - str(x) for x in variables - ] - - -class ElementaryTerm(Term, ABC): - """ - Elementary term. These terms are evaluated in the base structure, i.e. the yield integers. - """ - @classmethod - def to_term(self, x: Union[str, int, ElementaryTerm]) -> ElementaryTerm: - """ - Classmethod for converting str and int into variables and constants, respectively. - - :param x: The input parameter - :return: the term tht presents x - """ - return VariableETerm(x) if isinstance(x, str) else ConstantETerm(x) if isinstance(x, int) else x - - def __init__(self): - super().__init__() - self.presentation = None - - def eq(self, other: ElementaryTerm) -> BaseRATerm: - """ - Creates the relation :math:`\\textrm{self} == \\textrm{other}`. - - :param other: the rhs of the equality - :return: - """ - return BaseRATerm('Eq', [self, other]) - - def lt(self, other) -> BaseRATerm: - """ - Creates the relation :math:`\\textrm{self} < \\textrm{other}`. - - :param other: The term on the rhs - :return: - """ - return BaseRATerm('Lt', [self, other]) - - def gt(self, other) -> BaseRATerm: - """ - Creates the relation :math:`\\textrm{other} < \\textrm{self}`. - - :param other: The term on the lhs - :return: - """ - return BaseRATerm('Gt', [self, other]) - - def evaluate(self) -> SparseDFA: - if self.presentation is None: - self.update_presentation() - - return self.presentation - - def __add__(self, other) -> AdditionETerm: - """ - Creates the term :math:`\textrm{self} + \\textrm{other}`. - - :param other: - :return: - """ - if isinstance(other, int): - other = ConstantETerm(other) - - return AdditionETerm(self, other) - - def __radd__(self, other): - """ - Creates a term that is equivalent to :math:`\\textrm{other} + \textrm{self}`. Uses commutativity. - - :param other: - :return: - """ - return self.__add__(other) - - def __neg__(self): - """ - Creates the term :math:`-\\textrm{self}`. - - :return: - """ - return NegatedETerm(self) - - def __sub__(self, other): - """ - Creates the term :math:`\\textrm{self} + (-\\textrm{other})`. - - :param other: - :return: - """ - return self + (-other) - - def __rsub__(self, other): - """ - creates the term :math:`\\textrm{other} + (-\\textrm{self})`. - - :param other: - :return: - """ - return other + (-self) - - def __mul__(self, other) -> AdditionETerm: - """ - Creates a term that is equivalent to :math:`\\textrm{self}\\cdot \\textrm{other}` in linear arithmetic. - Note that other needs to be a constant. - The method creates a nested addition and guarantees to create only :math:`O(\\log_2(\\textrm{other}))` - many distinct terms on object level. - - :param other: The constant to multiply with - :return: term that expresses the other-fold summation of self - """ - if isinstance(other, int): - # Reduce number of unique terms by base 2 decomposition - positive = (other >= 0) - other = abs(other) - if other == 0: - n_bits = 1 - else: - n_bits = math.floor(math.log(other, 2)) + 1 - power_multiples = None - term = None - for _ in range(n_bits): - if power_multiples is None: - power_multiples = [self] - else: - power_multiples.append(power_multiples[-1] + power_multiples[-1]) - if other % 2 == 1: - if term is None: - term = power_multiples[-1] - else: - term = term + power_multiples[-1] - other = int((other - 1) / 2) - else: - other = int(other / 2) - - if positive: - return term - else: - return -term - else: - raise ValueError('Can multiply only with natural numbers') - - def __rmul__(self, other): - """ - Creates a term equivalent to :math:`other \\cdot self`. Uses commutativity. - - :param other: The Constant to multiply - :return: - """ - return self.__mul__(other) - - def __or__(self, other): - """ - creates a relational algebra term that represents self | other. The semantics of | is given as :math:`x | y` iff - :math:`y = 2^n` for some :math:`n` and :math:`y` divides :math:`x`. - - :param other: - :return: - """ - if isinstance(other, int): - other = ConstantETerm(other) - return BaseRATerm(relation_symbol="B", terms=[self, other]) - - @abstractmethod - def update_presentation(self, recursive: bool = True, **kwargs) -> None: - raise NotImplementedError - - -class ConstantETerm(ElementaryTerm): - def _substitute_inplace(self, allow_collision: bool = False, **kwargs): - return self - - def get_variables(self) -> List[str]: - return [] - - def update_presentation(self, recursive=True, **kwargs) -> None: - self.presentation = lsbf_Z_automaton(self.n) - - def __init__(self, n: int): - super().__init__() - self.n = n - - def __hash__(self): - return self.n - - -class VariableETerm(ElementaryTerm): - def _substitute_inplace(self, allow_collision: bool = False, **kwargs) -> VariableETerm: - return self - - def update_presentation(self, recursive=True, **kwargs) -> None: - arithmetic = deepcopy(self.arithmetic) - self.presentation = arithmetic.automata['Eq'] - - def get_variables(self) -> List[str]: - return [self.get_name()] - - def get_name(self) -> str: - return self.name - - def __eq__(self, other) -> bool: - """ - equality is based on the name of the variable. - - :param other: The other Variable - :return: - """ - if isinstance(other, VariableETerm): - return self.name == other.name - elif isinstance(str): - return self.name == other - else: - return False - - def __init__(self, name: str): - """ - Initialization. - - :param name: The name of the variable - """ - super().__init__() - self.name = name - - def __hash__(self): - return int.from_bytes(self.name.encode(), 'little') - - def __str__(self): - return self.name - -class NegatedETerm(ElementaryTerm): - def __init__(self, term: ElementaryTerm): - super().__init__() - self.subterm = term - - def update_presentation(self, recursive: bool = True, **kwargs) -> None: - if recursive: - self.subterm.update_presentation(recursive) - - arithmetic = deepcopy(self.arithmetic) - T = get_unique_id(arithmetic.get_relation_symbols()) - input_args = self.subterm.get_variables() - y, t = get_unique_id(input_args, 2) - - if not isinstance(self.subterm, VariableETerm): - tau = T + '(' + ','.join(input_args + [t]) + ')' - phi = f'exists {t}.({tau} and Neg({t}, {y}))' - else: - phi = 'Neg(x, y)' - - arithmetic.update(**{T: self.subterm.evaluate()}) - - self.presentation = arithmetic.evaluate(phi) - - def get_variables(self) -> List[str]: - return self.subterm.get_variables() - - def _substitute_inplace(self, allow_collision: bool = False, **kwargs) -> Term: - return self.subterm._substitute_inplace(self, allow_collision, **kwargs) - - -class AdditionETerm(ElementaryTerm): - def _substitute_inplace(self, allow_collision: bool = False, **kwargs) -> AdditionETerm: - kwargs = { - str(x): ElementaryTerm.to_term(kwargs[x]) for x in kwargs - } - if str(self.left) in kwargs: - self.left = kwargs[str(self.left)] - else: - self.left._substitute_inplace(allow_collision, **kwargs) - - if str(self.right) in kwargs: - self.right = kwargs[str(self.right)] - else: - self.right._substitute_inplace(allow_collision, **kwargs) - - self.presentation = None - - return self - - def update_presentation(self, recursive=True, **kwargs) -> None: - if recursive: - self.left.update_presentation() - self.right.update_presentation() - - left_is_var = isinstance(self.left, VariableETerm) - right_is_var = isinstance(self.right, VariableETerm) - - phi = 'A({}, {}, {})' - - x0, y0, z = get_unique_id(self.get_variables(), 3) - arithmetic = deepcopy(self.arithmetic) - R0, R1 = get_unique_id(arithmetic.get_relation_symbols(), 2) - - if left_is_var: - x = self.left.get_name() - else: - x = x0 - left_vars = self.left.get_variables() - left_vars.sort() - args = ','.join(left_vars + [x0]) - psi = f'{R0}({args})' - - phi = f'exists {x0}.({psi} and {phi})' - - if right_is_var: - y = self.right.get_name() - else: - y = y0 - right_vars = self.right.get_variables() - right_vars.sort() - args = ','.join(right_vars + [y0]) - psi = f"{R1}({args})" - - phi = f'exists {y0}.({psi} and {phi})' - - phi = phi.format(x, y, z) - arithmetic = deepcopy(self.arithmetic) - updates = {R0: self.left.evaluate(), R1: self.right.evaluate()} - arithmetic.update(**updates) - self.presentation = arithmetic.evaluate( - phi - ) - - def get_variables(self) -> List[str]: - """ - Get ordered list of all free variables in the term. - - :return: - """ - result = list(set(self.left.get_variables() + self.right.get_variables())) - result.sort() - return result - - def __eq__(self, other) -> bool: - if isinstance(other, AdditionETerm): - return self.left == other.left and self.right == other.right - else: - return False - def __init__(self, left: ElementaryTerm, right: ElementaryTerm): - super().__init__() - self.left = left - self.right = right +def variables(names) -> tuple: + """Shorthand for ``integers().vars(names)``.""" + return integers().vars(names) diff --git a/tests/test_arithmetic.py b/tests/test_arithmetic.py index 71faef1..ce308b7 100644 --- a/tests/test_arithmetic.py +++ b/tests/test_arithmetic.py @@ -1,149 +1,161 @@ -from autstr.arithmetic import VariableETerm as Var +"""Büchi arithmetic through the symbolic interface.""" +from itertools import islice -class TestArithmetic: - def test_neg(self): - x = Var('x') - y = Var('y') +import pytest - expr = (-x).eq(y) +from autstr.arithmetic import decode, encode, integers - assert not (expr.isfinite()) - assert not (expr.isempty()) - for a, b in expr: - if a > 10: - break +@pytest.fixture(scope='module') +def Z(): + return integers() - b0 = -a - assert b0 == b - def test_add(self): - x = Var('x') - y = Var('y') - z = Var('z') +def bounded(formula, limit=6): + """Solutions whose entries all stay within +/- limit, taken from a prefix + of the length-lexicographic enumeration.""" + return [t for t in islice(iter(formula), 400) + if all(abs(v) <= limit for v in t)] - expr = (x + y).eq(z) - assert not (expr.isfinite()) - assert not (expr.isempty()) +# ---------------------------------------------------------------------- +# encoding +# ---------------------------------------------------------------------- - for a, b, c in expr: - if c > 10: - break +@pytest.mark.parametrize('n', [0, 1, 2, 7, 8, 255, -1, -2, -9, -256]) +def test_encode_decode_roundtrip(n): + assert decode(encode(n)) == n - cp = a + b - assert (cp == c) - def test_mul(self): - x = Var('x') - y = Var('y') +def test_decode_ignores_padding(): + assert decode(encode(13) + ['*', '*']) == 13 - expr = (2 * x).eq(y) - assert not (expr.isfinite()) - assert not (expr.isempty()) +# ---------------------------------------------------------------------- +# terms +# ---------------------------------------------------------------------- - for a, b in expr: - if a > 5: - break +def test_addition(Z): + x, y, z = Z.vars('x y z') + formula = (x + y).eq(z) + assert not formula.is_empty() + assert not formula.is_finite() + for a, b, c in bounded(formula): + assert a + b == c - bp = 2 * a - assert (bp == b) - def test_sub(self): - x = Var('x') - y = Var('y') - z = Var('z') +def test_negation(Z): + x, y = Z.vars('x y') + formula = (-x).eq(y) + for a, b in bounded(formula): + assert b == -a - expr = (x - y).eq(z) - assert not (expr.isfinite()) - assert not (expr.isempty()) +def test_subtraction(Z): + x, y, z = Z.vars('x y z') + for a, b, c in bounded((x - y).eq(z)): + assert a - b == c - for a, b, c in expr: - if a > 10: - break - c0 = a - b - assert (c0 == c) +def test_integer_multiple(Z): + x, y = Z.vars('x y') + for a, b in bounded((2 * x).eq(y)): + assert b == 2 * a - def test_lt(self): - x = Var('x') - expr = x.lt(10) +def test_larger_integer_multiple(Z): + x, y = Z.vars('x y') + relation = x.times(11).eq(y).evaluate() + assert relation.contains(x=3, y=33) + assert not relation.contains(x=3, y=32) - assert not (expr.isfinite()) - assert not (expr.isempty()) - for a, in expr: - condition = a < 10 - assert (condition) - if abs(a) > 5: - break +def test_python_integers_are_constants(Z): + x, = Z.vars('x') + formula = x.lt(10) + assert not formula.is_empty() + assert not formula.is_finite() + for (a,) in bounded(formula): + assert a < 10 - def test_composite_relations(self): - x = Var('x') - y = Var('y') - z = Var('z') - constrains = (x + y).eq(z) +# ---------------------------------------------------------------------- +# relations +# ---------------------------------------------------------------------- - assert not (constrains.isfinite()) - assert not (constrains.isempty()) +def test_order(Z): + x, y = Z.vars('x y') + relation = x.lt(y).evaluate() + assert relation.contains(x=-3, y=2) + assert not relation.contains(x=2, y=-3) + assert not relation.contains(x=2, y=2) - for a, b, c in constrains: - condition = a + b == c - assert (condition) - if max([a, b, c]) > 3: - break +def test_power_of_two_divisibility(Z): + x, y = Z.vars('x y') + relation = x.divided_by_power(y).evaluate() + assert relation.contains(x=12, y=4) # 4 = 2^2 divides 12 + assert not relation.contains(x=12, y=8) # 8 does not divide 12 + assert not relation.contains(x=12, y=3) # 3 is not a power of two - constrains = constrains & z.gt(0) & z.lt(3) - assert not (constrains.isfinite()) - assert not (constrains.isempty()) - for a, b, c in constrains: - condition = (a + b == c) and (c > 0) and (c < 3) - assert (condition) +def test_relations_reachable_by_name(Z): + x, = Z.vars('x') + N0 = Z.rel('N0') + relation = N0(x).evaluate() + assert relation.contains(x=5) + assert not relation.contains(x=-5) - if abs(max([a, b])) > 3: - break - constrains = constrains.drop(['z']) +# ---------------------------------------------------------------------- +# relational algebra +# ---------------------------------------------------------------------- - for a, b in constrains: - condition = 0 < a + b < 3 - assert (condition) +def test_conjunction_and_projection(Z): + x, y, z = Z.vars('x y z') + constraints = (x + y).eq(z) & z.gt(0) & z.lt(3) + for a, b, c in bounded(constraints): + assert a + b == c and 0 < c < 3 - if abs(max([a, b])) > 3: - break + projected = constraints.drop(['z']) + assert projected.variables() == ['x', 'y'] + for a, b in bounded(projected): + assert 0 < a + b < 3 - constrains = ~constrains + for a, b in bounded(~projected): + assert not 0 < a + b < 3 - for a, b in constrains: - condition = 0 >= a + b or a + b >= 3 - assert (condition) - if abs(max([a, b])) > 3: - break +def test_membership(Z): + x, y, z = Z.vars('x y z') + formula = (x + y).eq(z) + assert (1, 1, 2) in formula + assert (10, 2, 13) not in formula + assert formula.contains(x=10, y=2, z=12) - def test_exinf(self): - x = Var('x') - y = Var('y') - expr = x.eq(y).exinf('y') - assert (expr.isempty()) +def test_exinf(Z): + x, y = Z.vars('x y') + # exactly one y equals each x + assert x.eq(y).exinf('y').is_empty() + # unboundedly many exceed it + assert not x.lt(y).exinf('y').is_empty() - expr = x.lt(y).exinf('y') - assert not (expr.isempty()) - assert not (expr.isfinite()) - def test_contains(self): - x = Var('x') - y = Var('y') - z = Var('z') +def test_universal_quantification(Z): + x, y = Z.vars('x y') + assert (x + y).eq(0).drop(y).all(x).check() # every integer has an inverse + assert x.lt(y).implies(~y.lt(x)).all([x, y]).check() - expr = (x + y).eq(z) - assert ((1, 1, 2) in expr) - assert not ((10, 2, 13) in expr) +def test_finiteness_is_about_tuples(Z): + x, y = Z.vars('x y') + assert (x.eq(3) & y.eq(4)).is_finite() + assert (x.gt(0) & x.lt(5)).is_finite() + assert not x.lt(y).is_finite() + + +def test_enumeration_yields_integers(Z): + x, y = Z.vars('x y') + formula = (x + y).eq(4) & x.gt(0) & x.lt(y) + assert sorted(islice(iter(formula), 5)) == [(1, 3)] From 88c87e31e22e2c6979db629f426818b10c07f66e Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Tue, 21 Jul 2026 23:07:23 +0200 Subject: [PATCH 03/76] Docs: the symbolic interface, and fix the NP-completeness claim Adds an overview section on symbolic expressions, and migrates the README snippet and the media script off the deleted `VariableETerm` API. The arithmetic notebook claimed integer linear systems are "NP-hard in general". They are not: whether Ax = b has an integer solution is decidable in polynomial time via the Hermite or Smith normal form. Hardness needs *bounded* variables -- that is integer linear programming. Reframed accordingly, with subset sum as the smallest NP-complete case. Each item is encoded as a variable constrained to {0, a_i} rather than as a_i * e_i with e_i in {0,1}. The two-way choice is the bound, and it avoids scalar multiplication entirely, which lowers to repeated addition over the coefficient's bit length and dominates everything else: n=2 with 4-bit coefficients goes from 16.6s/1.29GB to 0.09s/63MB, and the feasible size moves from n=2 to n=4. The instance is small on purpose and the text says so -- three items have eight subsets. The point is that the automaton is built rather than searched, so the unsolvable target is certified, not merely unsolved; the construction's blow-up in the number of items is where the NP-completeness shows up. Co-Authored-By: Claude Opus 4.8 --- docs/source/overview.md | 56 ++++++++++++++ notebooks/arithmetic_and_algebra.ipynb | 100 ++++++++++++++++++++++--- scripts/gen_readme_media.py | 9 ++- 3 files changed, 149 insertions(+), 16 deletions(-) diff --git a/docs/source/overview.md b/docs/source/overview.md index 62736ea..522893d 100644 --- a/docs/source/overview.md +++ b/docs/source/overview.md @@ -32,6 +32,62 @@ automaton of *all* satisfying assignments, which you can enumerate or reuse. The {doc}`arithmetic & algebra notebook ` walks through Presburger and Büchi arithmetic in full. +## Symbolic expressions instead of formula strings + +Formula strings are fine for a one-off question, but they are awkward to build +programmatically and give no help with arities or variable names. Every +structure and every class also hands out a **symbolic interface**: variables, +relation symbols and function symbols that compose with ordinary Python +operators. + +```python +from autstr.arithmetic import integers + +Z = integers() +x, y, z = Z.vars('x y z') + +phi = (x + y).eq(z) & z.lt(100) # a relation over x, y, z +phi.check() # satisfiable? — True +phi.evaluate().contains(x=3, y=4, z=7) # — True +(x + y).eq(4) & x.gt(0) & x.lt(y) # ... and enumerate its solutions +``` + +Terms nest freely: `f(t)` for a declared function symbol compiles to an +existential over the graph relation, so `((x + y) + z).eq(10)` means what it +reads as. Arities come from the automata, so applying a relation to the wrong +number of arguments is an error rather than a silently wrong query, and +variables may be named anything — the compiler renames them internally and +restores the names in results. + +What a structure offers is declared in a `Signature`: which relations are +function graphs, which operators they bind to, and how Python values encode as +elements. + +```python +from autstr.symbolic import Signature, FunctionCodec + +signature = (Signature(codec=FunctionCodec(encode, decode)) + .function('+', graph='A', out=2) # A(x, y, z) is the graph of + + .operator('+', '+') + .operator('lt', 'Lt')) + +S = my_presentation.symbolic(signature) +``` + +Results carry their tape order as variable *names*, so membership and +enumeration are keyed by name rather than position: + +```python +relation = phi.evaluate() +relation.variables # ['x', 'y', 'z'] +relation.contains(z=7, x=3, y=4) # order does not matter +relation.is_finite() # finitely many satisfying tuples? +``` + +The same expressions compile against a uniformly automatic class, where they +define a relation across every member at once; `check_member` then evaluates +one member, explicitly or implicitly. + ## Computer algebra over infinite domains Structures need not be finitely generated. The localizations **ℤ[1/p]** — the diff --git a/notebooks/arithmetic_and_algebra.ipynb b/notebooks/arithmetic_and_algebra.ipynb index fa22d23..341fa6f 100644 --- a/notebooks/arithmetic_and_algebra.ipynb +++ b/notebooks/arithmetic_and_algebra.ipynb @@ -47,10 +47,11 @@ "metadata": {}, "outputs": [], "source": [ - "from autstr.arithmetic import VariableETerm as Var\n", + "from autstr.arithmetic import integers\n", "from time import time\n", "\n", - "x = Var('x')" + "Z = integers()\n", + "x = Z.var('x')" ] }, { @@ -74,8 +75,8 @@ "outputs": [], "source": [ "def infinite_sieve(steps):\n", - " x = Var('x')\n", - " candidates = (x.gt(1)) # {2, 3, 4, ...}\n", + " x = Z.var('x')\n", + " candidates = x.gt(1) # {2, 3, 4, ...}\n", " primes = []\n", " for _ in range(steps):\n", " start = time()\n", @@ -83,14 +84,14 @@ " primes.append(p[0])\n", " break\n", " print(f\"next prime {primes[-1]} ({time() - start:.3f}s)\")\n", - " y = Var('y')\n", - " multiples = (x.eq(primes[-1] * y)).drop('y')\n", + " y = Z.var('y')\n", + " multiples = x.eq(primes[-1] * y).drop('y')\n", " candidates = candidates & ~multiples\n", " return primes, candidates\n", "\n", "primes, remaining = infinite_sieve(steps=3)\n", "print('primes found:', primes)\n", - "remaining.evaluate().show_diagram()" + "remaining.evaluate().dfa.show_diagram()" ] }, { @@ -100,8 +101,11 @@ "source": [ "### Integer linear systems\n", "\n", - "Integer linear systems are NP-hard in general, but they are first-order definable\n", - "over $(\\mathbb{Z}, +)$, so we can build them and read off (or rule out) solutions." + "Over the integers, a system of linear *equations* is easy: whether $Ax = b$ has an\n", + "integer solution can be decided in polynomial time, via the Hermite or Smith normal\n", + "form. Hardness enters only when the variables are **bounded** -- that is integer\n", + "linear *programming*, and it is NP-complete. Both sit inside first-order logic over\n", + "$(\\mathbb{Z}, +)$, so the same machinery expresses either one; only the cost differs.\n" ] }, { @@ -111,7 +115,7 @@ "metadata": {}, "outputs": [], "source": [ - "x, y, z = Var('x'), Var('y'), Var('z')\n", + "x, y, z = Z.vars('x y z')\n", "\n", "eqn_sys = (x + y).eq(6) & (y + z).eq(12) & (x + z).eq(10)\n", "for s in eqn_sys:\n", @@ -126,7 +130,67 @@ "outputs": [], "source": [ "unsolvable = (3 * y + 5 * z).eq(1) & (x + y).eq(5) & (10 * z + x).eq(17)\n", - "print('has a solution:', not unsolvable.isempty())" + "print('has a solution:', not unsolvable.is_empty())" + ] + }, + { + "cell_type": "markdown", + "id": "a1b2c3d4", + "metadata": {}, + "source": [ + "#### Where the hardness starts: subset sum\n", + "\n", + "Subset sum is the smallest NP-complete instance of the bounded case: given\n", + "$a_1, \\dots, a_n$ and a target $T$, is there a subset summing to $T$? Each item\n", + "becomes a variable $s_i$ constrained to $\\{0, a_i\\}$ -- taking it or leaving it.\n", + "That two-way choice *is* the bound, and it needs no multiplication.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a1b2c3d5", + "metadata": {}, + "outputs": [], + "source": [ + "def subset_sum(values, target):\n", + " \"\"\"Is some sub-collection of `values` equal to `target`?\"\"\"\n", + " svars = Z.vars(' '.join(f's{i}' for i in range(len(values))))\n", + "\n", + " phi = None\n", + " for a, s in zip(values, svars):\n", + " take_or_leave = s.eq(0) | s.eq(a)\n", + " phi = take_or_leave if phi is None else phi & take_or_leave\n", + "\n", + " total = None\n", + " for s in svars:\n", + " total = s if total is None else total + s\n", + "\n", + " return phi & total.eq(target)\n", + "\n", + "\n", + "values = [11, 13, 9]\n", + "\n", + "solvable = subset_sum(values, 20)\n", + "print('target 20 -- solvable:', not solvable.is_empty())\n", + "for picks in solvable:\n", + " print(' picks:', [v for v in picks if v])\n", + " break\n", + "\n", + "print('target 7 -- solvable:', not subset_sum(values, 7).is_empty())" + ] + }, + { + "cell_type": "markdown", + "id": "a1b2c3d6", + "metadata": {}, + "source": [ + "The automaton is *built*, not searched, so the unsolvable target is decided the\n", + "same way as the solvable one: the empty automaton certifies that **no** subset\n", + "works, rather than reporting a failure to find one.\n", + "\n", + "That generality is what costs. The construction grows steeply in the number of\n", + "items. The blow-up is where the NP-completeness shows up.\n" ] }, { @@ -400,9 +464,21 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.14" } }, "nbformat": 4, diff --git a/scripts/gen_readme_media.py b/scripts/gen_readme_media.py index d1964f5..1cb6b52 100755 --- a/scripts/gen_readme_media.py +++ b/scripts/gen_readme_media.py @@ -12,19 +12,20 @@ """ from pathlib import Path -from autstr.arithmetic import VariableETerm as Var +from autstr.arithmetic import integers MEDIA = Path(__file__).resolve().parent.parent / "docs" / "media" def coprime_automaton(primes): """The DFA for {x > 1 : no p in `primes` divides x}.""" - x = Var("x") + Z = integers() + x = Z.var("x") s = x.gt(1) for p in primes: - y = Var("y") + y = Z.var("y") s = s & ~((x.eq(p * y)).drop("y")) # remove the multiples of p - return s.evaluate() + return s.evaluate().dfa def main(): From 3cb5ae416118240fe0a719b7dcd0e6a08919b697 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Tue, 21 Jul 2026 23:07:30 +0200 Subject: [PATCH 04/76] Enforce output-free notebooks with nbstripout The repository keeps notebooks output-free and the docs build executes them (`nb_execution_mode = 'force'`), but nothing enforced it. `pre-commit` was already a dev dependency with no configuration; this adds one. `--keep-id` matters: without it nbstripout renumbers every cell id to 0, 1, 2, ... and rewrites all five notebooks. Co-Authored-By: Claude Opus 4.8 --- .pre-commit-config.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..3ef8718 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,9 @@ +# The repository keeps notebooks output-free; the docs build executes them +# (docs/source/conf.py sets nb_execution_mode = 'force'). +repos: + - repo: https://github.com/kynan/nbstripout + rev: 0.7.1 + hooks: + - id: nbstripout + # Keep the notebooks' cell ids; renumbering them churns every cell. + args: [--keep-id] From bd8ecb9ea0cc54893c34ff3a7b54a90f848e72fb Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Tue, 21 Jul 2026 23:23:33 +0200 Subject: [PATCH 05/76] Codec: the encoding's shape belongs to the backend `FunctionCodec.encode` coerced its encoder's output with `list(...)`, which bakes the word-shaped engines into the generic layer: a tree encoding dies with "'Tree' object is not iterable" before it ever reaches a backend. The codec's output is only ever handed back to the backend that asked for it, so this layer has no reason to interpret it. `ElementCodec` now says so, and `FunctionCodec` returns the encoder's output unchanged. Behaviour-preserving for the string engines: both existing encoders already return lists. Co-Authored-By: Claude Opus 4.8 --- autstr/symbolic/signature.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/autstr/symbolic/signature.py b/autstr/symbolic/signature.py index 068238c..f5e4d43 100644 --- a/autstr/symbolic/signature.py +++ b/autstr/symbolic/signature.py @@ -43,27 +43,33 @@ def positions(self, graph_arity: int) -> tuple: class ElementCodec: """Translation between Python values and element encodings. - An encoding is a list of base-alphabet symbols, in the order the automata - read them. Supplying a codec is optional: without one the symbolic layer - still works, but constants cannot be written as Python values and solutions - are yielded as raw words. + What an encoding *is* belongs to the backend: a list of base-alphabet + symbols in the order the automata read them for the string engines, a + `Tree` for the tree engine. The codec's output is only ever handed back to + the backend that asked for it, so this layer does not interpret it. + + Supplying a codec is optional: without one the symbolic layer still works, + but constants cannot be written as Python values and solutions are yielded + in their raw encoded form. """ - def encode(self, value: Any) -> List: + def encode(self, value: Any) -> Any: raise NotImplementedError - def decode(self, word: Sequence) -> Any: + def decode(self, encoded: Any) -> Any: raise NotImplementedError @dataclass(frozen=True) class FunctionCodec(ElementCodec): """A codec built from two plain functions.""" - encoder: Callable[[Any], List] - decoder: Optional[Callable[[Sequence], Any]] = None + encoder: Callable[[Any], Any] + decoder: Optional[Callable[[Any], Any]] = None def encode(self, value): - return list(self.encoder(value)) + # Returned as the encoder produced it: coercing to a list here would + # bake in the word-shaped engines and break tree encodings. + return self.encoder(value) def decode(self, word): if self.decoder is None: From dd5d5b5831011b0a070bca270bb3d8747d72aff9 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Tue, 21 Jul 2026 23:23:42 +0200 Subject: [PATCH 06/76] Symbolic interfaces over the tree engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TreeAutomaticPresentation.symbolic()` gives the tree engine the same expression language as the string engines. `Backend` was the right seam: the compiler, the expression AST and `SymbolicContext` are unchanged, and a signature's codec now encodes Python values to `Tree`s. Three operations have no tree counterpart yet and raise with the reason rather than quietly answering a different question: * enumeration -- `iterate_language` walks word positions, and enumerating a tree language in a well-defined order is a separate construction; * finiteness -- needs the tree analogue of `automata_tools.canonical`, since padding-saturated automata accept every tuple in infinitely many spellings; * constants -- spliced in as a temporary relation, which the tree presentation's `evaluate` does not yet accept. Each message is asserted by a test, so none of them can start answering a different question unnoticed. Skolem arithmetic (N_{>0}, ·) is the oracle: products against Python multiplication, nested terms, commutativity as a sentence, a non-square witness, and divisibility against `b % a == 0`. Co-Authored-By: Claude Opus 4.8 --- autstr/symbolic/backends.py | 72 +++++++++++++++++++++++++ autstr/tree_presentations.py | 16 ++++++ tests/test_symbolic_tree.py | 102 +++++++++++++++++++++++++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 tests/test_symbolic_tree.py diff --git a/autstr/symbolic/backends.py b/autstr/symbolic/backends.py index 90950c0..8aad490 100644 --- a/autstr/symbolic/backends.py +++ b/autstr/symbolic/backends.py @@ -10,6 +10,7 @@ from typing import Dict, List, Optional, Sequence from autstr.buildin.automata import k_longer_automaton +from autstr.sparse_tree_automata import convolve_trees, tree_to_arrays from autstr.utils.automata_tools import ( canonical, iterate_language, word_automaton, ) @@ -141,6 +142,77 @@ def describe(self): return f"structure with relations {sorted(self.relation_symbols())}" +class TreeStructureBackend(Backend): + """Queries answered by a `TreeAutomaticPresentation`. + + Elements are trees rather than words, so a signature's codec encodes + Python values to `Tree`s; everything above this module is unchanged, since + the codec's output is only ever handed back to the backend. + + The tree engine has no counterpart yet for enumeration, finiteness, or + splicing an automaton into a query. Those operations raise with the reason + rather than quietly answering a different question. + """ + + def __init__(self, presentation): + self.presentation = presentation + + def relation_symbols(self): + return [s for s in self.presentation.get_relation_symbols() if s != 'U'] + + def arity(self, symbol): + sta = self.presentation.automata.get(symbol) + return None if sta is None else sta.symbol_arity + + def arity_of(self, sta): + return sta.symbol_arity + + def evaluate(self, expression, updates, prepared): + if updates or prepared: + raise NotImplementedError( + "TreeAutomaticPresentation.evaluate accepts no relation " + "updates, so constants and spliced automata cannot be injected " + "into a tree query yet") + sta = self.presentation.evaluate(expression) + return sta, get_free_elementary_vars(expression) + + def constant_automaton(self, tree): + raise NotImplementedError( + "a constant is spliced into the query as a temporary relation, " + "which the tree presentation does not yet accept; pass the value " + "as an assignment to a free variable instead") + + def longer_witness_automaton(self, k, references): + raise NotImplementedError( + "exinf counts word positions and has no tree analogue yet") + + def accepts(self, sta, values, codec): + trees = values if codec is None else [codec.encode(v) for v in values] + # `SparseTreeAutomaton.accepts` convolves with sorted(alphabet)[0] as + # the pad, which is the padding symbol only by luck of the ordering. + conv = convolve_trees(trees, sta.base_alphabet_frozen, + self.presentation.padding_symbol) + return sta.accepts(tree_to_arrays(conv, sta.base_alphabet_frozen, + sta.symbol_arity)) + + def iterate(self, sta, codec, arity): + raise NotImplementedError( + "the tree engine has no language enumeration: `iterate_language` " + "walks word positions, and enumerating a tree language in a " + "well-defined order is a separate construction") + + def is_finite(self, sta): + raise NotImplementedError( + "finiteness needs the tree analogue of `automata_tools.canonical`: " + "padding-saturated automata accept every tuple in infinitely many " + "spellings, so a cycle test would answer a question about trees " + "rather than about tuples") + + def describe(self): + return (f"tree structure with relations " + f"{sorted(self.relation_symbols())}") + + class ClassBackend(Backend): """Queries answered by a `UniformlyAutomaticClass`. diff --git a/autstr/tree_presentations.py b/autstr/tree_presentations.py index 09add51..763eb62 100644 --- a/autstr/tree_presentations.py +++ b/autstr/tree_presentations.py @@ -84,6 +84,22 @@ def _domain_product(self, arity: int) -> SparseTreeAutomaton: def get_relation_symbols(self): return list(self.automata.keys()) + def symbolic(self, signature=None): + """A symbolic interface to this structure: variables, relation and + function symbols that build first-order expressions with Python + operators instead of formula strings. + + Elements are trees, so a signature's codec encodes Python values to + `Tree`s. Enumeration, finiteness and constants are not available over + the tree engine yet and raise with the reason. + + :param signature: declared functions, operators and element codec. + :return: a `autstr.symbolic.SymbolicContext`. + """ + from autstr.symbolic.backends import TreeStructureBackend + from autstr.symbolic.context import SymbolicContext + return SymbolicContext(TreeStructureBackend(self), signature) + def update(self, **automata) -> None: """Install or replace relations. Values may be automata (saturated and domain-restricted like at construction time) or formula strings diff --git a/tests/test_symbolic_tree.py b/tests/test_symbolic_tree.py new file mode 100644 index 0000000..25f7009 --- /dev/null +++ b/tests/test_symbolic_tree.py @@ -0,0 +1,102 @@ +"""The symbolic layer over the tree engine. + +Skolem arithmetic (N_{>0}, ·) is the oracle: every query has an obvious +reference answer in Python, so the compiled tree automaton can be checked +against it directly. +""" +import pytest + +from autstr.buildin.tree_presentations import SkolemArithmetic +from autstr.symbolic import ( + CompileError, FunctionCodec, Signature, SymbolicSymbolError, +) + + +@pytest.fixture(scope="module") +def skolem(): + return SkolemArithmetic(max_states=500_000) + + +@pytest.fixture(scope="module") +def S(skolem): + signature = Signature(codec=FunctionCodec(SkolemArithmetic.encode, + SkolemArithmetic.decode)) + signature.function('*', graph='M', out=2) + signature.operator('*', '*') + signature.operator('eq', 'E') + return skolem.symbolic(signature) + + +class TestSignature: + def test_relation_symbols_exclude_the_domain(self, S): + assert 'U' not in S.backend.relation_symbols() + assert set(S.backend.relation_symbols()) == {'M', 'E'} + + def test_arities_come_from_the_automata(self, S): + assert S.backend.arity('M') == 3 + assert S.backend.arity('E') == 2 + + def test_wrong_relation_arity_is_reported(self, S): + x, y = S.vars('x y') + with pytest.raises(CompileError, match='arity 3'): + S.atom('M', [x, y]).evaluate() + + def test_wrong_function_arity_is_reported(self, S): + x, = S.vars('x') + with pytest.raises(SymbolicSymbolError, match='arity 2'): + S.get_symbolic_func('*')(x) + + +class TestProduct: + def test_membership_matches_multiplication(self, S): + x, y, z = S.vars('x y z') + relation = (x * y).eq(z).evaluate() + for a, b in [(1, 1), (2, 3), (6, 2), (4, 5), (7, 3), (12, 1)]: + assert relation.contains(x=a, y=b, z=a * b), (a, b) + assert not relation.contains(x=a, y=b, z=a * b + 1), (a, b) + + def test_nested_terms(self, S): + x, y, z, w = S.vars('x y z w') + relation = ((x * y) * z).eq(w).evaluate() + assert relation.contains(x=2, y=3, z=5, w=30) + assert not relation.contains(x=2, y=3, z=5, w=31) + + +class TestSentences: + def test_multiplication_is_commutative(self, S): + x, y, z = S.vars('x y z') + assert (x * y).eq(z).implies((y * x).eq(z)).all('x y z').check() + + def test_not_every_element_is_a_square(self, S): + x, y = S.vars('x y') + # exists x. not exists y. y * y = x -- 2 is a witness. + assert (~((y * y).eq(x).drop('y'))).drop('x').check() + + def test_divisibility_agrees_with_python(self, S): + x, y, z = S.vars('x y z') + divides = (x * z).eq(y).drop('z') + relation = divides.evaluate() + for a, b in [(2, 6), (3, 12), (4, 6), (5, 5), (7, 30), (6, 24)]: + assert relation.contains(x=a, y=b) == (b % a == 0), (a, b) + + +class TestUnsupportedOperations: + """The tree engine has no counterpart for these yet; each must say so + rather than answer a different question.""" + + def test_enumeration_explains_itself(self, S): + x, y, z = S.vars('x y z') + relation = (x * y).eq(z).evaluate() + with pytest.raises(NotImplementedError, match="enumeration"): + next(iter(relation)) + + def test_finiteness_explains_itself(self, S): + x, y, z = S.vars('x y z') + relation = (x * y).eq(z).evaluate() + with pytest.raises(NotImplementedError, match="canonical"): + relation.is_finite() + + def test_constants_explain_themselves(self, S): + x, y = S.vars('x y') + with pytest.raises(NotImplementedError, match="assignment"): + (x * y).eq(S.const(12)).evaluate() From 4a76408988fd061f93ade8a9fa8517c53be35c77 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Tue, 21 Jul 2026 23:40:35 +0200 Subject: [PATCH 07/76] Constants and finiteness over the tree engine Two of the three gaps the tree symbolic layer shipped with. They land together because the backend and the test file each span both. **Constants.** `TreeAutomaticPresentation.evaluate` gains `updates`, mirroring the string version: relations installed for one evaluation only, prepared exactly as at construction time, so a spliced automaton is padding-saturated and domain-restricted before any projection sees it. Two deliberate differences: the restore sits in a `finally` (the string version restores on the success path only, so a query that raises leaves the temporaries installed -- a latent bug there, not copied here), and there is no `prepared_updates` fast path, which re-pads without re-restricting to the domain and is sound only because string `unpad` yields already-restricted automata. **Finiteness.** Three pieces: * `tree_automata_tools.canonical` -- the tree analog of the string version: keep only trees with no all-padding node. `attach_padding` accepts each tuple with arbitrary padding regions hanging below, so a saturated automaton's *tree* language is infinite whenever the relation is non-empty. Built from nine diagram entries rather than enumerating m^k symbols. * `SparseTreeAutomaton.co_reachable_states` -- the top-down companion that `reachable_states` lacked. * `SparseTreeAutomaton.is_finite` -- a state that occurs strictly below itself pumps, so the language is infinite exactly when the "child of" graph over reachable and co-reachable states has a cycle. Unlisted child pairs fall to the global default, so the analyses enumerate pairs over available states: quadratic, and documented as such, because a conservative over-approximation would invent cycles and wrongly report "infinite". Verified against an exhaustive oracle (60 random automata, splitting 28 finite / 32 infinite, so neither branch passes vacuously) using an exact bound: an accepted tree taller than the state count repeats a state and pumps, and an infinite language forces unbounded height since a binary tree's size is bounded by its height. Plus the Skolem oracle -- divisor pairs finite, multiples infinite -- and a test pinning both halves of why `canonical` is needed. Co-Authored-By: Claude Opus 4.8 --- autstr/sparse_tree_automata.py | 80 +++++++++++++++++++++++++++++ autstr/symbolic/backends.py | 35 ++++++------- autstr/tree_presentations.py | 43 +++++++++++++--- autstr/utils/tree_automata_tools.py | 38 ++++++++++++++ tests/test_symbolic_tree.py | 62 +++++++++++++++++++--- tests/test_tree_automata.py | 63 +++++++++++++++++++++++ 6 files changed, 289 insertions(+), 32 deletions(-) diff --git a/autstr/sparse_tree_automata.py b/autstr/sparse_tree_automata.py index 74cfe6d..3a1c4a9 100644 --- a/autstr/sparse_tree_automata.py +++ b/autstr/sparse_tree_automata.py @@ -20,6 +20,7 @@ acceptance and touches no diagram at all, and hash-consing makes two states with the same transition function share one node. """ +from collections import defaultdict, deque from typing import Dict, List, Optional, Sequence, Set, Tuple import numpy as np @@ -531,6 +532,85 @@ def is_empty(self) -> bool: reach = self.reachable_states() return not bool((reach & self.is_accepting).any()) + def _transitions(self, available: np.ndarray): + """Yield ``(left, right, targets)`` for every child pair both of whose + children are available, ``BOT`` included. Unlisted pairs fall to the + global default, so they are enumerated too -- which is why this is + quadratic in the number of available states and reserved for the + analyses below rather than the hot pipeline.""" + base = self.num_states + 1 + listed = {int(k): int(n) for k, n in + zip(self.pair_keys, self.pair_nodes)} + usable = [int(s) for s in np.flatnonzero(available)] + [self.BOT] + for left in usable: + for right in usable: + node = listed.get(left * base + right) + if node is None: + yield left, right, (self.default_state,) + else: + yield left, right, self.store.terminals(node) + + def co_reachable_states(self, available: Optional[np.ndarray] = None + ) -> np.ndarray: + """Boolean mask of states that can occur in an accepting run: a state + is co-reachable if it is accepting (as the root) or it is a child in + some transition whose target is co-reachable and whose sibling subtree + exists. The top-down companion to `reachable_states`.""" + if available is None: + available = self.reachable_states() + co = self.is_accepting.copy() + transitions = list(self._transitions(available)) + while True: + new = False + for left, right, targets in transitions: + if not any(t < self.num_states and co[t] for t in targets): + continue + for child in (left, right): + if child < self.num_states and not co[child]: + co[child] = True + new = True + if not new: + return co + + def is_finite(self) -> bool: + """Whether the automaton accepts finitely many trees. + + A state that can occur strictly below itself pumps: the context + between the two occurrences can be repeated without bound. So the + language is infinite exactly when the "child of" graph, restricted to + states that are both reachable and co-reachable, has a cycle. + """ + available = self.reachable_states() + usable = available & self.co_reachable_states(available) + if not usable.any(): + return True # empty language + + successors = defaultdict(set) + indegree = defaultdict(int) + nodes = set(int(s) for s in np.flatnonzero(usable)) + for left, right, targets in self._transitions(available): + for target in targets: + if target >= self.num_states or not usable[target]: + continue + for child in (left, right): + if child >= self.num_states or not usable[child]: + continue + if target not in successors[child]: + successors[child].add(target) + indegree[target] += 1 + + # Kahn's algorithm: anything left over sits on a cycle. + queue = deque(n for n in nodes if indegree[n] == 0) + removed = 0 + while queue: + node = queue.popleft() + removed += 1 + for target in successors[node]: + indegree[target] -= 1 + if indegree[target] == 0: + queue.append(target) + return removed == len(nodes) + def __repr__(self): return (f"SparseTreeAutomaton({self.num_states} states, " f"{len(self.pair_keys)} pairs, {self.num_nodes} nodes, " diff --git a/autstr/symbolic/backends.py b/autstr/symbolic/backends.py index 8aad490..697740a 100644 --- a/autstr/symbolic/backends.py +++ b/autstr/symbolic/backends.py @@ -11,6 +11,9 @@ from autstr.buildin.automata import k_longer_automaton from autstr.sparse_tree_automata import convolve_trees, tree_to_arrays +from autstr.utils.tree_automata_tools import ( + canonical as tree_canonical, tree_automaton, +) from autstr.utils.automata_tools import ( canonical, iterate_language, word_automaton, ) @@ -149,9 +152,8 @@ class TreeStructureBackend(Backend): Python values to `Tree`s; everything above this module is unchanged, since the codec's output is only ever handed back to the backend. - The tree engine has no counterpart yet for enumeration, finiteness, or - splicing an automaton into a query. Those operations raise with the reason - rather than quietly answering a different question. + The tree engine has no counterpart yet for enumeration, which raises with + the reason rather than quietly answering a different question. """ def __init__(self, presentation): @@ -168,19 +170,17 @@ def arity_of(self, sta): return sta.symbol_arity def evaluate(self, expression, updates, prepared): - if updates or prepared: - raise NotImplementedError( - "TreeAutomaticPresentation.evaluate accepts no relation " - "updates, so constants and spliced automata cannot be injected " - "into a tree query yet") - sta = self.presentation.evaluate(expression) + # The tree presentation has no `prepared_updates` fast path: it would + # skip the domain restriction, and there is no tree `unpad` producing + # the already-restricted automata that shortcut relies on. + combined = dict(updates or {}) + combined.update(prepared or {}) + sta = self.presentation.evaluate( + expression, updates=combined or None) return sta, get_free_elementary_vars(expression) def constant_automaton(self, tree): - raise NotImplementedError( - "a constant is spliced into the query as a temporary relation, " - "which the tree presentation does not yet accept; pass the value " - "as an assignment to a free variable instead") + return tree_automaton(tree, self.presentation.base_alphabet) def longer_witness_automaton(self, k, references): raise NotImplementedError( @@ -202,11 +202,10 @@ def iterate(self, sta, codec, arity): "well-defined order is a separate construction") def is_finite(self, sta): - raise NotImplementedError( - "finiteness needs the tree analogue of `automata_tools.canonical`: " - "padding-saturated automata accept every tuple in infinitely many " - "spellings, so a cycle test would answer a question about trees " - "rather than about tuples") + # As on the string side, the tuple set is finite iff the canonical + # convolutions are; the saturated automaton's own tree language never + # is, thanks to the attached all-padding regions. + return tree_canonical(sta, self.presentation.padding_symbol).is_finite() def describe(self): return (f"tree structure with relations " diff --git a/autstr/tree_presentations.py b/autstr/tree_presentations.py index 763eb62..f0e90b9 100644 --- a/autstr/tree_presentations.py +++ b/autstr/tree_presentations.py @@ -11,7 +11,7 @@ `project` — which produces the canonical (trimmed) language — is followed by re-saturation, the tree analog of the string pipeline's pad/unpad dance. """ -from typing import Dict, Optional +from typing import Dict, Optional, Union from nltk.sem import logic @@ -90,8 +90,8 @@ def symbolic(self, signature=None): operators instead of formula strings. Elements are trees, so a signature's codec encodes Python values to - `Tree`s. Enumeration, finiteness and constants are not available over - the tree engine yet and raise with the reason. + `Tree`s. Enumeration and finiteness are not available over the tree + engine yet and raise with the reason. :param signature: declared functions, operators and element codec. :return: a `autstr.symbolic.SymbolicContext`. @@ -121,12 +121,43 @@ def check(self, phi) -> bool: phi = phi.simplify() return not self._build_automaton(phi).is_empty() - def evaluate(self, phi) -> SparseTreeAutomaton: + def evaluate(self, phi, updates: Optional[Dict[str, Union[ + SparseTreeAutomaton, str]]] = None) -> SparseTreeAutomaton: """Automaton of all satisfying assignments (tapes = sorted free - variables; padding-saturated form).""" + variables; padding-saturated form). + + :param phi: the first-order formula. + :param updates: relations to install for this evaluation only. Values + may be automata or formula strings over the current signature, and + are prepared exactly as at construction time -- so a spliced + automaton is padding-saturated and domain-restricted before any + projection sees it. + """ if isinstance(phi, str): phi = logic.Expression.fromstring(phi) - return self._build_automaton(optimize_query(phi)) + phi = optimize_query(phi) + if not updates: + return self._build_automaton(phi) + + prepared = {} + for name, value in updates.items(): + if name == 'U': + raise ValueError("cannot replace the domain automaton") + if isinstance(value, SparseTreeAutomaton): + prepared[name] = self._prepare_automaton(value) + else: + query = optimize_query(logic.Expression.fromstring(value)) + prepared[name] = self._prepare_automaton( + self._build_automaton(query)) + + backup = self.automata + self.automata = dict(self.automata, **prepared) + try: + return self._build_automaton(phi) + finally: + # Restore even if the build raises; otherwise a failed query would + # leave the temporary relations installed on the presentation. + self.automata = backup def _build_automaton(self, phi) -> SparseTreeAutomaton: if isinstance(phi, str): diff --git a/autstr/utils/tree_automata_tools.py b/autstr/utils/tree_automata_tools.py index 38c3e5e..7797094 100644 --- a/autstr/utils/tree_automata_tools.py +++ b/autstr/utils/tree_automata_tools.py @@ -342,6 +342,44 @@ def attach_padding(sta: SparseTreeAutomaton, padding_symbol, # Single-tree and string-language automata # ==================================================================== +def canonical(sta: SparseTreeAutomaton, padding_symbol) -> SparseTreeAutomaton: + """Keep only the canonical convolution of each tuple: trees in which no + node is padding on *every* tape. + + `attach_padding` deliberately accepts each tuple with arbitrary + all-padding regions hanging below it, so the *tree* language of a + saturated relation automaton is infinite as soon as the relation is + non-empty. Restricting to canonical trees first is what makes finiteness + and counting questions be about tuples rather than about trees -- the tree + analog of `automata_tools.canonical`. + """ + store = sta.store + k, m, bits = sta.symbol_arity, sta.m, sta.bits + CLEAN, DEAD, BOT = 0, 1, 2 + base = 3 + + pad_assignment = _symbol_assignment( + encode_symbol((padding_symbol,) * k, sta.base_alphabet_frozen), + k, m, bits) + + # Stay clean on every symbol but the all-padding one; once dead, dead. + clean = store.set_path(store.const(CLEAN, k, m, bits), + pad_assignment, DEAD) + dead = store.const(DEAD, k, m, bits) + + keys, nodes = [], [] + for left in (CLEAN, DEAD, BOT): + for right in (CLEAN, DEAD, BOT): + keys.append(left * base + right) + nodes.append(dead if DEAD in (left, right) else clean) + + no_padding = SparseTreeAutomaton( + num_states=2, default_state=DEAD, is_accepting=[True, False], + symbol_arity=k, base_alphabet=sta.base_alphabet, + pair_keys=keys, pair_nodes=nodes) + return minimize(sta.intersection(no_padding)) + + def tree_automaton(tree, base_alphabet, symbol_arity: int = 1 ) -> SparseTreeAutomaton: """Automaton accepting exactly the given tree. Subtrees are hash-consed diff --git a/tests/test_symbolic_tree.py b/tests/test_symbolic_tree.py index 25f7009..a9ca333 100644 --- a/tests/test_symbolic_tree.py +++ b/tests/test_symbolic_tree.py @@ -7,6 +7,7 @@ import pytest from autstr.buildin.tree_presentations import SkolemArithmetic +from autstr.utils.tree_automata_tools import canonical from autstr.symbolic import ( CompileError, FunctionCodec, Signature, SymbolicSymbolError, ) @@ -80,6 +81,37 @@ def test_divisibility_agrees_with_python(self, S): assert relation.contains(x=a, y=b) == (b % a == 0), (a, b) +class TestFiniteness: + """Finiteness is a question about tuples, so the canonical convolutions + have to be taken first.""" + + def test_divisor_pairs_are_finite(self, S): + x, y = S.vars('x y') + assert (x * y).eq(S.const(12)).evaluate().is_finite() + + def test_multiples_are_infinite(self, S): + y, z = S.vars('y z') + assert not (S.const(3) * z).eq(y).drop('z').evaluate().is_finite() + + def test_the_whole_product_relation_is_infinite(self, S): + x, y, z = S.vars('x y z') + assert not (x * y).eq(z).evaluate().is_finite() + + def test_a_singleton_is_finite(self, S): + x = S.vars('x')[0] + assert (x * S.const(1)).eq(S.const(7)).evaluate().is_finite() + + def test_canonical_is_what_makes_it_a_question_about_tuples(self, S): + """Without trimming the attached all-padding regions, a saturated + automaton accepts each tuple in infinitely many spellings -- so the + finite relation would look infinite. This is the tree counterpart of + the string bug that `automata_tools.canonical` fixed.""" + x, y = S.vars('x y') + sta = (x * y).eq(S.const(12)).evaluate().dfa + assert not sta.is_finite() # infinitely many trees + assert canonical(sta, '*').is_finite() # finitely many tuples + + class TestUnsupportedOperations: """The tree engine has no counterpart for these yet; each must say so rather than answer a different question.""" @@ -90,13 +122,27 @@ def test_enumeration_explains_itself(self, S): with pytest.raises(NotImplementedError, match="enumeration"): next(iter(relation)) - def test_finiteness_explains_itself(self, S): - x, y, z = S.vars('x y z') - relation = (x * y).eq(z).evaluate() - with pytest.raises(NotImplementedError, match="canonical"): - relation.is_finite() - def test_constants_explain_themselves(self, S): +class TestConstants: + """A constant is spliced into the query as a temporary relation, prepared + exactly as at construction time.""" + + def test_divisor_pairs_of_a_constant(self, S): + x, y = S.vars('x y') + relation = (x * y).eq(S.const(12)).evaluate() + for a in range(1, 14): + for b in range(1, 14): + assert relation.contains(x=a, y=b) == (a * b == 12), (a, b) + + def test_constant_on_both_sides(self, S): + x = S.vars('x')[0] + relation = (x * S.const(3)).eq(S.const(12)).evaluate() + assert relation.contains(x=4) + assert not relation.contains(x=3) + assert not relation.contains(x=12) + + def test_splicing_leaves_the_presentation_unchanged(self, S, skolem): + before = dict(skolem.automata) x, y = S.vars('x y') - with pytest.raises(NotImplementedError, match="assignment"): - (x * y).eq(S.const(12)).evaluate() + (x * y).eq(S.const(12)).evaluate() + assert dict(skolem.automata) == before diff --git a/tests/test_tree_automata.py b/tests/test_tree_automata.py index 7eaa50a..58385c9 100644 --- a/tests/test_tree_automata.py +++ b/tests/test_tree_automata.py @@ -305,3 +305,66 @@ def test_arrays_roundtrip_postorder(self): # post-order: children indices precede parents for i, (l, r) in enumerate(zip(lefts, rights)): assert l < i and r < i + + +# ==================================================================== +# Finiteness against an exhaustive oracle +# ==================================================================== + +def _trees_up_to_height(m: int, height: int): + """Every tree of height <= `height` over `m` labels, where the height is + the number of nodes on the longest root-to-leaf path. `None` (the absent + tree) is included so it can serve as a child.""" + level = [None] + for _ in range(height): + level = [None] + [Tree(label, left, right) + for label in range(m) + for left in level + for right in level] + return level + + +def _height(tree) -> int: + if tree is None: + return 0 + best, stack = 0, [(tree, 1)] + while stack: + node, depth = stack.pop() + best = max(best, depth) + for child in (node.left, node.right): + if child is not None: + stack.append((child, depth + 1)) + return best + + +def _accepts_a_tall_tree(sta: SparseTreeAutomaton, m: int) -> bool: + """Whether some accepted tree is taller than the state count. + + Such a tree repeats a state along one root-to-leaf path, so the context + between the two occurrences pumps and the language is infinite. + Conversely an infinite language has trees of unbounded size, and a binary + tree's size is bounded by its height -- so this is exact, not a heuristic. + """ + n = sta.num_states + return any(tree is not None and _height(tree) > n and sta.accepts(tree) + for tree in _trees_up_to_height(m, n + 1)) + + +class TestFiniteness: + def test_matches_the_exhaustive_oracle(self): + rng = random.Random(20260721) + for _ in range(60): + sta = random_sta(rng, max_states=2, max_symbols=2, + max_exc=8, max_pd=4) + m = len(sta.base_alphabet) + assert sta.is_finite() is not _accepts_a_tall_tree(sta, m), sta + + def test_empty_language_is_finite(self): + sta = SparseTreeAutomaton(1, 0, is_accepting=[False], symbol_arity=1, + base_alphabet={0, 1}) + assert sta.is_finite() + + def test_every_tree_is_infinite(self): + sta = SparseTreeAutomaton(1, 0, is_accepting=[True], symbol_arity=1, + base_alphabet={0, 1}) + assert not sta.is_finite() From 0f356fcef6919dbeb15965fb1724eee39e266502 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 22 Jul 2026 00:12:27 +0200 Subject: [PATCH 08/76] Enumeration and exists-infinity over the tree engine The last two gaps. As with the previous pair, they land together because the backend and the symbolic test file each span both. **Enumeration.** `iterate_trees` yields accepted trees in shortlex order: by node count, then by labels in pre-order. Two things make it work at all, and both come from the finiteness step: * it builds trees only at states that are reachable *and* co-reachable -- trees at dead states can never become witnesses and otherwise dominate the cost; * a finite language has an acyclic "child of" graph, so the largest accepted tree has a computable size and the generator stops. Without that it climbed sizes forever after having yielded everything, which is how the first version ran into a timeout on the six divisors of 12. `canonical` comes first, or the saturated automaton would spell one tuple infinitely many ways and never reach the second. Shortlex orders by *encoding size*, which is what the string engine's length-lexicographic order does too -- there it coincides with increasing |n| only because a Buechi word is ceil(log2|n|) letters. A Skolem tree is the prime index plus the exponents' bit lengths, so 128 = 2^7 is enumerated before the prime 7. Value order belongs to the codec: for Skolem the magnitude order is not even recognizable, since every tree-automatic structure has a decidable theory while (N, *, <) does not. **Exists-infinity.** "k letters longer than every reference" becomes "k nodes deeper on one root-to-leaf path", with the same pigeonhole: pumping in a bottom-up tree automaton happens along a path via a context, and a tree's domain is closed under parents, so the reference-free nodes form a suffix of each path. The converse holds because a binary tree's size is bounded by its height, so infinitely many witnesses force one with a long enough path. `k_deeper_automaton` counts only nodes where the witness tape is present. `attach_padding` hangs all-padding regions below every tree, and those nodes have the references padded too -- counting them would invent depth carrying no witness and make exists-infinity true for finite fibres. That guard is load-bearing: removing it makes the automaton accept padding-only depth. It is pinned by a unit test, because the end-to-end tests could not tell the two implementations apart. Verified: enumeration against exhaustive filtering on 25 random automata; divisor pairs exactly, without repeats, terminating; infinite relations streaming lazily with every triple a true product; and for exists-infinity the discriminating pair on one relation -- every element has infinitely many multiples, no element has infinitely many divisors -- plus agreement with `is_finite` on the same fibre. Co-Authored-By: Claude Opus 4.8 --- autstr/symbolic/backends.py | 52 +++++++-- autstr/tree_presentations.py | 4 +- autstr/utils/tree_automata_tools.py | 175 +++++++++++++++++++++++++++- tests/test_symbolic_tree.py | 74 ++++++++++-- tests/test_tree_automata.py | 63 ++++++++++ tests/test_tree_tools.py | 39 +++++++ 6 files changed, 386 insertions(+), 21 deletions(-) diff --git a/autstr/symbolic/backends.py b/autstr/symbolic/backends.py index 697740a..77de327 100644 --- a/autstr/symbolic/backends.py +++ b/autstr/symbolic/backends.py @@ -10,14 +10,16 @@ from typing import Dict, List, Optional, Sequence from autstr.buildin.automata import k_longer_automaton -from autstr.sparse_tree_automata import convolve_trees, tree_to_arrays +from autstr.sparse_tree_automata import Tree, convolve_trees, tree_to_arrays from autstr.utils.tree_automata_tools import ( - canonical as tree_canonical, tree_automaton, + canonical as tree_canonical, iterate_trees, k_deeper_automaton, + tree_automaton, ) from autstr.utils.automata_tools import ( canonical, iterate_language, word_automaton, ) from autstr.utils.logic import get_free_elementary_vars +from autstr.utils.misc import decode_symbol class Backend: @@ -145,6 +147,23 @@ def describe(self): return f"structure with relations {sorted(self.relation_symbols())}" +def _project_tape(tree, tape: int, arity: int, alphabet, padding_symbol): + """The single-tape tree carried by tape `tape` of a convolution. + + A tree's domain is closed under parents, so the positions a tape occupies + are exactly those where its component is not padding -- dropping a padded + node drops its whole subtree on that tape. + """ + if tree is None: + return None + label = decode_symbol(tree.label, arity, alphabet)[tape] + if label == padding_symbol: + return None + return Tree(label, + _project_tape(tree.left, tape, arity, alphabet, padding_symbol), + _project_tape(tree.right, tape, arity, alphabet, padding_symbol)) + + class TreeStructureBackend(Backend): """Queries answered by a `TreeAutomaticPresentation`. @@ -152,8 +171,9 @@ class TreeStructureBackend(Backend): Python values to `Tree`s; everything above this module is unchanged, since the codec's output is only ever handed back to the backend. - The tree engine has no counterpart yet for enumeration, which raises with - the reason rather than quietly answering a different question. + Every operation of the string backends is available; the ones whose string + formulation counts word positions -- enumeration order and `exinf` -- are + restated in terms of node count and path depth. """ def __init__(self, presentation): @@ -183,8 +203,12 @@ def constant_automaton(self, tree): return tree_automaton(tree, self.presentation.base_alphabet) def longer_witness_automaton(self, k, references): - raise NotImplementedError( - "exinf counts word positions and has no tree analogue yet") + # "k letters longer" becomes "k nodes deeper": pumping in a tree + # automaton happens along a root-to-leaf path, so that is where the + # pigeonhole has to bite. + return k_deeper_automaton(k, references, + self.presentation.base_alphabet, + self.presentation.padding_symbol) def accepts(self, sta, values, codec): trees = values if codec is None else [codec.encode(v) for v in values] @@ -196,10 +220,18 @@ def accepts(self, sta, values, codec): sta.symbol_arity)) def iterate(self, sta, codec, arity): - raise NotImplementedError( - "the tree engine has no language enumeration: `iterate_language` " - "walks word positions, and enumerating a tree language in a " - "well-defined order is a separate construction") + padding = self.presentation.padding_symbol + alphabet = sta.base_alphabet_frozen + # Canonical first: the saturated automaton spells each tuple in + # infinitely many ways, so enumerating it directly would repeat one + # tuple forever and never reach the second. + for tree in iterate_trees(tree_canonical(sta, padding)): + tapes = tuple(_project_tape(tree, i, arity, alphabet, padding) + for i in range(arity)) + if codec is None: + yield tapes + else: + yield tuple(codec.decode(t) for t in tapes) def is_finite(self, sta): # As on the string side, the tuple set is finite iff the canonical diff --git a/autstr/tree_presentations.py b/autstr/tree_presentations.py index f0e90b9..d663e32 100644 --- a/autstr/tree_presentations.py +++ b/autstr/tree_presentations.py @@ -90,8 +90,8 @@ def symbolic(self, signature=None): operators instead of formula strings. Elements are trees, so a signature's codec encodes Python values to - `Tree`s. Enumeration and finiteness are not available over the tree - engine yet and raise with the reason. + `Tree`s. Enumeration is shortlex by node count, which orders by + encoding size rather than by any notion of value. :param signature: declared functions, operators and element codec. :return: a `autstr.symbolic.SymbolicContext`. diff --git a/autstr/utils/tree_automata_tools.py b/autstr/utils/tree_automata_tools.py index 7797094..918015f 100644 --- a/autstr/utils/tree_automata_tools.py +++ b/autstr/utils/tree_automata_tools.py @@ -49,7 +49,7 @@ import numpy as np from autstr.mtbdd import NONE, ComputedTable, bits_of, var_tables -from autstr.sparse_tree_automata import SparseTreeAutomaton +from autstr.sparse_tree_automata import SparseTreeAutomaton, Tree from autstr.utils.misc import encode_symbol @@ -380,6 +380,179 @@ def canonical(sta: SparseTreeAutomaton, padding_symbol) -> SparseTreeAutomaton: return minimize(sta.intersection(no_padding)) +def k_deeper_automaton(k: int, references: int, base_alphabet, + padding_symbol) -> SparseTreeAutomaton: + """Convolutions whose last tape runs at least `k` nodes below every + reference tape: some root-to-leaf path carries `k` nodes at which all + `references` reference tapes are padding and the last tape is not. + + The tree counterpart of `buildin.automata.k_longer_automaton`, and it + supports exists-infinity for the same pumping reason. A tree's domain is + closed under parents, so the nodes outside every reference's domain form a + suffix of each root-to-leaf path; `k` of them make a body automaton with + fewer than `k` states repeat along that path, and the context between the + two occurrences pumps without ever touching the references. + + Requiring the last tape to be present is what keeps this sound under + `attach_padding`, which hangs all-padding regions below a tree: those + nodes have every reference padded too, so counting them would manufacture + depth that carries no witness. + """ + alphabet = frozenset(base_alphabet) + arity = references + 1 + num_states = k + 1 # state s = run length so far, capped + BOT = num_states + + # symbols with every reference padded and a real letter on the witness + deepening = [ + encode_symbol((padding_symbol,) * references + (letter,), alphabet) + for letter in sorted(alphabet) if letter != padding_symbol + ] + + exc_left, exc_right, exc_symbol, exc_target = [], [], [], [] + pd_left, pd_right, pd_target = [], [], [] + for left in list(range(num_states)) + [BOT]: + for right in list(range(num_states)) + [BOT]: + lv = 0 if left == BOT else left + rv = 0 if right == BOT else right + if lv == k or rv == k: # already achieved below: absorbing + pd_left.append(left) + pd_right.append(right) + pd_target.append(k) + continue + target = min(1 + max(lv, rv), k) + for symbol in deepening: + exc_left.append(left) + exc_right.append(right) + exc_symbol.append(symbol) + exc_target.append(target) + + return SparseTreeAutomaton( + num_states=num_states, default_state=0, + exc_left=exc_left, exc_right=exc_right, + exc_symbol=exc_symbol, exc_target=exc_target, + is_accepting=[s == k for s in range(num_states)], + symbol_arity=arity, base_alphabet=alphabet, + pd_left=pd_left, pd_right=pd_right, pd_target=pd_target) + + +def _shortlex_key(tree) -> tuple: + """Canonical key ordering trees of equal size: labels in pre-order, with + the absent child ordering before any present one.""" + if tree is None: + return () + return (tree.label, _shortlex_key(tree.left), _shortlex_key(tree.right)) + + +def _max_tree_size(sta: SparseTreeAutomaton, delta, live) -> int: + """The node count of the largest accepted tree, for a finite language. + + Finite means the "child of" graph is acyclic, so the longest tree reaching + each state is well defined: one plus the longest trees of the best child + pair. Relaxing to a fixpoint settles in at most one round per state. + """ + bot = sta.BOT + longest = {bot: 0} # the absent child has no nodes + for _ in range(sta.num_states + 1): + changed = False + for left in list(longest): + for right in list(longest): + row = delta[left, right] + for target in live: + if not (row == target).any(): + continue + candidate = 1 + longest[left] + longest[right] + if candidate > longest.get(target, -1): + longest[target] = candidate + changed = True + if not changed: + break + sizes = [longest[q] for q in live + if q in longest and sta.is_accepting[q]] + return max(sizes, default=0) + + +def iterate_trees(sta: SparseTreeAutomaton, max_entries: int = 10 ** 7): + """Generate the accepted trees in shortlex order: by node count, then by + `_shortlex_key`. + + Size is the tree's own node count, which is the faithful analogue of the + string engine's length-lexicographic order -- and, like it, says nothing + about what the encoded *values* are. For Büchi arithmetic word length is + ceil(log2|n|), so shortlex happens to enumerate integers by increasing + absolute value; for Skolem arithmetic the tree size is instead the prime + index plus the exponents' bit lengths, so 128 arrives before 7. Any + value-ordering belongs to the codec, not here: for Skolem the magnitude + order is not even recognizable, since `(N, *, <)` is undecidable while + every tree-automatic structure has a decidable theory. + + Trees of every size below the one being yielded are retained, since they + are the subtrees of the larger ones -- enumeration of an infinite language + grows without bound by nature. + """ + delta = sta.dense_delta(max_entries) + bot = sta.BOT + accepting = sta.is_accepting + + # Only states that can occur in an accepting run are worth building trees + # for: a tree at a dead state can never become a witness, and enumerating + # them dominates the cost. + reachable = sta.reachable_states() + usable = reachable & sta.co_reachable_states(reachable) + live = {int(q) for q in np.flatnonzero(usable)} + + # A finite language has an acyclic "child of" graph, so the largest + # accepted tree has a computable size; without this the generator would + # climb sizes forever after having yielded everything. + bound = _max_tree_size(sta, delta, live) if sta.is_finite() else None + + # by_size[s][q] = the trees of exactly s nodes whose root state is q + by_size: List[Dict[int, List]] = [{}] # index 0 unused: no 0-node tree + + size = 0 + while bound is None or size < bound: + size += 1 + level: Dict[int, List] = {} + + if size == 1: + for symbol in range(sta.num_symbols): + target = int(delta[bot, bot, symbol]) + if target in live: + level.setdefault(target, []).append(Tree(symbol)) + else: + for left_size in range(0, size): + right_size = size - 1 - left_size + lefts = ({bot: [None]} if left_size == 0 + else by_size[left_size]) + rights = ({bot: [None]} if right_size == 0 + else by_size[right_size]) + for left_state, left_trees in lefts.items(): + for right_state, right_trees in rights.items(): + row = delta[left_state, right_state] + for target in live: + symbols = np.flatnonzero(row == target) + if not len(symbols): + continue + bucket = level.setdefault(target, []) + for symbol in symbols.tolist(): + for left in left_trees: + for right in right_trees: + bucket.append( + Tree(symbol, left, right)) + + for trees in level.values(): + trees.sort(key=_shortlex_key) + by_size.append(level) + + # One sorted run per size: grouping by state first would order the + # trees by their root state rather than shortlex. + accepted = [tree for state, trees in level.items() + if state < sta.num_states and accepting[state] + for tree in trees] + accepted.sort(key=_shortlex_key) + yield from accepted + + def tree_automaton(tree, base_alphabet, symbol_arity: int = 1 ) -> SparseTreeAutomaton: """Automaton accepting exactly the given tree. Subtrees are hash-consed diff --git a/tests/test_symbolic_tree.py b/tests/test_symbolic_tree.py index a9ca333..b0c8a98 100644 --- a/tests/test_symbolic_tree.py +++ b/tests/test_symbolic_tree.py @@ -4,13 +4,18 @@ reference answer in Python, so the compiled tree automaton can be checked against it directly. """ +from itertools import islice + import pytest from autstr.buildin.tree_presentations import SkolemArithmetic -from autstr.utils.tree_automata_tools import canonical +from autstr.sparse_tree_automata import convolve_trees from autstr.symbolic import ( CompileError, FunctionCodec, Signature, SymbolicSymbolError, ) +from autstr.utils.tree_automata_tools import canonical + +enc = SkolemArithmetic.encode @pytest.fixture(scope="module") @@ -112,15 +117,68 @@ def test_canonical_is_what_makes_it_a_question_about_tuples(self, S): assert canonical(sta, '*').is_finite() # finitely many tuples -class TestUnsupportedOperations: - """The tree engine has no counterpart for these yet; each must say so - rather than answer a different question.""" +class TestEnumeration: + """Shortlex on the convolution: by node count, then by labels. That is the + faithful analogue of the string engine's length-lexicographic order, but + it orders by *encoding size*, which for Skolem is factorization complexity + rather than magnitude.""" + + def test_enumerates_exactly_the_divisor_pairs(self, S): + x, y = S.vars('x y') + got = list((x * y).eq(S.const(12)).evaluate()) + assert sorted(got) == sorted((a, 12 // a) + for a in range(1, 13) if 12 % a == 0) + assert len(got) == len(set(got)) # no tuple twice - def test_enumeration_explains_itself(self, S): + def test_sizes_are_non_decreasing(self, S): + x, y = S.vars('x y') + sizes = [convolve_trees([enc(a), enc(b)], S.backend.presentation + .base_alphabet, '*').size() + for a, b in (x * y).eq(S.const(12)).evaluate()] + assert sizes == sorted(sizes) + + def test_an_infinite_relation_streams(self, S): x, y, z = S.vars('x y z') - relation = (x * y).eq(z).evaluate() - with pytest.raises(NotImplementedError, match="enumeration"): - next(iter(relation)) + got = list(islice(iter((x * y).eq(z).evaluate()), 8)) + assert len(got) == 8 + for a, b, c in got: + assert a * b == c, (a, b, c) + + def test_complexity_order_is_not_magnitude_order(self, S): + """128 = 2^7 encodes smaller than the prime 7, because exponents are + stored in binary while primes cost spine length. So the enumeration is + by factorization complexity, not by magnitude -- which is why + `iterate` stays structural and value order is left to the codec.""" + x, y = S.vars('x y') + elements = [a for a, _ in islice( + iter((x * S.const(1)).eq(y).evaluate()), 60)] + assert 128 in elements and 7 in elements + assert elements.index(128) < elements.index(7) + + +class TestExistsInfinitely: + """`exinf` rewrites to "some witness runs k nodes deeper than every + reference" -- the tree form of the string engine's "k letters longer", + with k the body's state count, so the pigeonhole bites along a path.""" + + def test_every_element_has_infinitely_many_multiples(self, S): + x, y, z = S.vars('x y z') + divides = (x * z).eq(y).drop('z') + relation = divides.exinf('y').evaluate() + for n in (1, 2, 5, 12): + assert relation.contains(x=n), n + + def test_no_element_has_infinitely_many_divisors(self, S): + x, y, z = S.vars('x y z') + divides = (x * z).eq(y).drop('z') + assert not divides.exinf('x').check() + + def test_agrees_with_finiteness_of_the_fibre(self, S): + """The two answers must line up: the divisors of 12 are a finite + fibre, so no y has infinitely many x with x * y = 12.""" + x, y = S.vars('x y') + assert (x * y).eq(S.const(12)).evaluate().is_finite() + assert not (x * y).eq(S.const(12)).exinf('x').check() class TestConstants: diff --git a/tests/test_tree_automata.py b/tests/test_tree_automata.py index 58385c9..64b2982 100644 --- a/tests/test_tree_automata.py +++ b/tests/test_tree_automata.py @@ -4,6 +4,7 @@ import numpy as np import pytest +from autstr.utils.tree_automata_tools import _shortlex_key, iterate_trees from autstr.sparse_tree_automata import ( SparseTreeAutomaton, Tree, convolve_trees, tree_to_arrays, ) @@ -368,3 +369,65 @@ def test_every_tree_is_infinite(self): sta = SparseTreeAutomaton(1, 0, is_accepting=[True], symbol_arity=1, base_alphabet={0, 1}) assert not sta.is_finite() + + +# ==================================================================== +# Enumeration against an exhaustive oracle +# ==================================================================== + +def _trees_up_to_size(m: int, max_size: int): + """All trees of each size up to `max_size` over `m` labels.""" + by_size = {0: [None]} + for size in range(1, max_size + 1): + by_size[size] = [ + Tree(label, left, right) + for left_size in range(size) + for label in range(m) + for left in by_size[left_size] + for right in by_size[size - 1 - left_size] + ] + return by_size + + +class TestEnumeration: + def test_matches_brute_force(self): + rng = random.Random(4242) + limit = 4 + for _ in range(25): + sta = random_sta(rng, max_states=3, max_symbols=2, + max_exc=8, max_pd=4) + m = len(sta.base_alphabet) + by_size = _trees_up_to_size(m, limit) + expected = [t for size in range(1, limit + 1) + for t in by_size[size] if sta.accepts(t)] + + got = [] + for tree in iterate_trees(sta): + if tree.size() > limit: + break + got.append(tree) + + assert (sorted(map(_shortlex_key, got)) + == sorted(map(_shortlex_key, expected))), sta + + def test_yields_sizes_in_order(self): + rng = random.Random(99) + for _ in range(10): + sta = random_sta(rng, max_states=3, max_symbols=2, + max_exc=8, max_pd=4) + sizes = [] + for tree in iterate_trees(sta): + sizes.append(tree.size()) + if len(sizes) > 30: + break + assert sizes == sorted(sizes), sta + + def test_finite_language_terminates(self): + """Only the single leaf labelled 0 is accepted, so the generator must + stop rather than climb sizes forever looking for more.""" + BOT = 2 # = num_states + sta = SparseTreeAutomaton( + 2, 1, + exc_left=[BOT], exc_right=[BOT], exc_symbol=[0], exc_target=[0], + is_accepting=[True, False], symbol_arity=1, base_alphabet={0, 1}) + assert [t.label for t in iterate_trees(sta)] == [0] diff --git a/tests/test_tree_tools.py b/tests/test_tree_tools.py index 7dd5772..6c9e982 100644 --- a/tests/test_tree_tools.py +++ b/tests/test_tree_tools.py @@ -3,6 +3,7 @@ import numpy as np import pytest +from autstr.utils.tree_automata_tools import k_deeper_automaton from autstr.sparse_tree_automata import SparseTreeAutomaton, Tree, tree_to_arrays from autstr.utils.misc import encode_symbol from autstr.utils.tree_automata_tools import ( @@ -354,3 +355,41 @@ def test_language_is_preserved_on_dense_random_automata(self): sta = random_sta_arity(rng, 1, 2, max_states=n, max_exc=2 * (n + 1) ** 2, max_pd=6) assert equivalent(sta, minimize(sta)), trial + + +class TestKDeeperAutomaton: + """`k_deeper_automaton` underwrites exists-infinity: it must certify that + the witness itself runs k nodes below every reference.""" + + ALPHA = {'*', 'a'} + + def _automaton(self, k=3, references=1): + return k_deeper_automaton(k, references, self.ALPHA, '*') + + def test_accepts_a_genuinely_deeper_witness(self): + deep = Tree(('a', 'a'), + Tree(('*', 'a'), Tree(('*', 'a'), Tree(('*', 'a'))))) + assert self._automaton().accepts(deep) + + def test_rejects_a_shallow_witness(self): + assert not self._automaton().accepts(Tree(('a', 'a'))) + + def test_padding_alone_does_not_make_depth(self): + """`attach_padding` hangs all-padding regions below every tree. Those + nodes have the reference padded too, so counting them would invent + depth that carries no witness and make exists-infinity true for finite + fibres.""" + padded = Tree(('a', 'a'), + Tree(('*', '*'), Tree(('*', '*'), Tree(('*', '*'))))) + assert not self._automaton().accepts(padded) + + def test_depth_must_be_on_one_path(self): + """Two short branches do not add up: pumping needs the repeated state + on a single root-to-leaf path. The root carries the reference, so each + branch contributes a run of 2 -- four qualifying nodes in total, but + no path with more than two.""" + forked = Tree(('a', 'a'), + Tree(('*', 'a'), Tree(('*', 'a'))), + Tree(('*', 'a'), Tree(('*', 'a')))) + assert self._automaton(k=2).accepts(forked) + assert not self._automaton(k=3).accepts(forked) From dc4df1d33c7eb1af2dbbf687031a07a4e09e83f8 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 22 Jul 2026 00:32:31 +0200 Subject: [PATCH 09/76] Pin the symbolic interface over uniformly tree-automatic classes `UniformlyTreeAutomaticClass.symbolic()` needs no tree-specific backend, and this commit is the evidence rather than the implementation. `ClassBackend` reads `class_automata` for the arities and otherwise delegates to check / check_implicit / evaluate_implicit / get_structure, all of which the tree class already provides -- so the string class backend serves both engines unchanged. Verified side by side: identical tape order (['x', 'y', 'advice']), identical arities, identical refusals. That works because everything which would have to know about trees -- constants, enumeration, finiteness -- has no advice-free meaning over a class and is refused there for both engines. `get_structure` then hands back a TreeAutomaticPresentation whose own `symbolic()` supplies exactly those, via the tree structure backend. Six tests pin it against the string class, plus a comment on the class saying why no TreeClassBackend exists -- without it, adding one is the obvious next move for a reader. The symbolic layer now covers all four combinations: string structure, string class, tree structure, tree class. Co-Authored-By: Claude Opus 4.8 --- autstr/tree_uniform.py | 10 +++++ tests/test_tree_uniform.py | 87 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/autstr/tree_uniform.py b/autstr/tree_uniform.py index ffaa2a4..69d1490 100644 --- a/autstr/tree_uniform.py +++ b/autstr/tree_uniform.py @@ -156,6 +156,16 @@ def __init__(self, automata: Dict[str, SparseTreeAutomaton], # evaluate(), _relativize(), _variable_names() and get_relation_symbols() # are inherited: they operate on formulas and on self.presentation only. + # + # symbolic() is inherited for the same reason, and needs no tree-specific + # backend: `symbolic.backends.ClassBackend` reads `class_automata` for the + # arities and otherwise delegates to check/check_implicit/evaluate_implicit + # /get_structure, all of which this class provides. Everything that would + # have to know about trees -- constants, enumeration, finiteness -- has no + # advice-free meaning over a class and is refused there for both engines, + # and `get_structure` hands back a TreeAutomaticPresentation whose own + # `symbolic()` supplies them. See tests/test_tree_uniform.py + # ::TestSymbolicInterface, which pins this against the string class. def check(self, phi: Union[str, logic.Expression], advice: Tree, **assignments) -> bool: diff --git a/tests/test_tree_uniform.py b/tests/test_tree_uniform.py index 1af0f43..e494233 100644 --- a/tests/test_tree_uniform.py +++ b/tests/test_tree_uniform.py @@ -3,6 +3,7 @@ import pytest from autstr.sparse_tree_automata import SparseTreeAutomaton, Tree +from autstr.symbolic import FunctionCodec, Signature from autstr.tree_uniform import UniformlyTreeAutomaticClass from autstr.uniform import UniformlyAutomaticClass, dfa_from_delta from autstr.utils.misc import encode_symbol @@ -312,3 +313,89 @@ def test_get_structure_agrees(self, engines): want = truth(n) assert Ss.check(phi) == want, (phi, n) assert St.check(phi) == want, (phi, n) + + +# ==================================================================== +# The symbolic interface over a uniformly tree-automatic class +# ==================================================================== + +class TestSymbolicInterface: + """`UniformlyTreeAutomaticClass.symbolic()` needs no tree-specific + backend: `ClassBackend` only touches members that the tree class inherits + or provides itself, so the string class backend serves both engines. These + tests are what says so -- they pin the behaviour against the string class + rather than against an implementation.""" + + def test_signature_is_read_from_the_class(self, engines): + _, tc = engines + K = tc.symbolic() + assert 'U' not in K.backend.relation_symbols() + # the advice tape is not part of the relation's arity + assert K.backend.arity('Lt') == 2 + + def test_agrees_with_the_string_class_backend(self, engines): + sc, tc = engines + SK, TK = sc.symbolic(), tc.symbolic() + for K, cls in ((SK, sc), (TK, tc)): + x, y = K.vars('x y') + assert K.backend.arity('Lt') == 2 + assert K.atom('Lt', [x, y]).evaluate().variables == \ + ['x', 'y', 'advice'] + + def test_member_checking_matches_the_formula_api(self, engines): + sc, tc = engines + K = tc.symbolic() + x, y = K.vars('x y') + minimum = (~K.atom('Lt', [y, x])).all('y').drop('x') + for n in range(1, 6): + advice = string_chain('1' * n) + assert K.check_member(minimum, advice) == \ + tc.check('exists x.(all y.(not Lt(y,x)))', advice), n + + def test_pointwise_order_agrees_with_ground_truth(self, engines): + _, tc = engines + K = tc.symbolic() + x, y = K.vars('x y') + lt = K.atom('Lt', [x, y]) + n = 4 + for i in range(1, n + 1): + for j in range(1, n + 1): + got = K.check_member(lt, string_chain('1' * n), + x=string_chain('1' * i), + y=string_chain('1' * j)) + assert got == (i < j), (i, j) + + def test_get_structure_hands_back_a_tree_structure_context(self, engines): + _, tc = engines + K = tc.symbolic() + member = K.get_structure(string_chain('1' * 3)) + S = member.symbolic() + assert S.backend.arity('Lt') == 2 # no advice tape any more + a, b = S.vars('a b') + relation = S.atom('Lt', [a, b]).evaluate() + assert relation.variables == ['a', 'b'] + + def test_class_level_operations_need_a_member(self, engines): + """Enumeration, finiteness and constants have no advice-free meaning; + each must say so rather than answer for some arbitrary member.""" + _, tc = engines + K = tc.symbolic() + x, y = K.vars('x y') + relation = K.atom('Lt', [x, y]).evaluate() + with pytest.raises(NotImplementedError, match="advice"): + next(iter(relation)) + with pytest.raises(NotImplementedError, match="member"): + relation.is_finite() + + def test_constants_have_no_advice_free_meaning(self, engines): + """A class element's encoding depends on the advice, so even with a + codec supplied there is nothing for a constant to denote.""" + _, tc = engines + signature = Signature( + codec=FunctionCodec(lambda n: string_chain('1' * n))) + K = tc.symbolic(signature) + x, = K.vars('x') + # the term is built lazily; the guard fires when it is compiled + with pytest.raises(NotImplementedError, match="advice"): + K.check_member(K.atom('Lt', [x, K.const(3)]), + string_chain('111')) From 83d8cc5799061080f4ac94f2265d32243c6b4a84 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 22 Jul 2026 01:01:11 +0200 Subject: [PATCH 10/76] Fix a universal quantifier that silently became an existential `SymbolicContext.vars` splits a whitespace-separated string; `_names`, which backs `.all()`, `.drop()` and `.exinf()`, did not. So `.all('x y z')` bound a single variable literally named 'x y z', which occurs nowhere -- x, y and z stayed free, and `check` existentially closes free variables. The query answered a different question, with no error anywhere. Found by cross-checking group commutativity against the formula-string API after adding operator signatures: the symbolic layer called extraspecial Z/3 abelian and the string engine did not. The string engine was right. The layer's own test was complicit: `test_multiplication_is_commutative` used `.all('x y z')` and passed, because Skolem multiplication really is commutative -- the right answer for the wrong reason. The new regression tests use `x + y = y`, where the universal is false and the existential true, so a mis-bound quantifier cannot pass. `_names` now splits like `vars`, and quantifying a variable that is not free in the body raises instead of silently doing nothing -- the check `exinf` already had. That guard makes vacuous binders illegal, which `test_substitution_avoids_capture_by_inner_binders` relied on to plant an inner binder named '_v0'. It now conjoins a trivially true `v0.eq(v0)` so the name genuinely occurs: same semantics, same intent, and the binder is still there for substitution to dodge. Co-Authored-By: Claude Opus 4.8 --- autstr/symbolic/expr.py | 35 +++++++++++++++++++++++++++++++---- tests/test_symbolic.py | 27 ++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/autstr/symbolic/expr.py b/autstr/symbolic/expr.py index b692689..f82f6d2 100644 --- a/autstr/symbolic/expr.py +++ b/autstr/symbolic/expr.py @@ -239,16 +239,33 @@ def iff(self, other) -> 'Formula': return (~self | other) & (~other | self) # -- quantification ----------------------------------------------- + def _quantified(self, variables, what: str) -> List[str]: + """The names to bind, refusing any that is not free in the body. + + Binding a variable that does not occur is logically harmless but is + almost always a typo, and it fails silently: the intended variables + stay free and are existentially closed by `check`. + """ + from autstr.symbolic.context import SymbolicSymbolError + names = _names(variables) + free = set(self.variables()) + missing = [n for n in names if n not in free] + if missing: + raise SymbolicSymbolError( + f"cannot {what} {missing}: not free in the body, whose free " + f"variables are {sorted(free)}") + return names + def drop(self, variables) -> 'Formula': """Project the relation away from ``variables`` -- equivalently, existentially quantify them.""" - return Exists(self.ctx, _names(variables), self) + return Exists(self.ctx, self._quantified(variables, 'drop'), self) ex = drop def all(self, variables) -> 'Formula': """Universally quantify ``variables``.""" - return Forall(self.ctx, _names(variables), self) + return Forall(self.ctx, self._quantified(variables, 'quantify'), self) def exinf(self, variable) -> 'Formula': """:math:`\\exists^\\infty` -- the tuples extended by infinitely many @@ -492,9 +509,19 @@ def _formula(ctx, x) -> Formula: def _names(variables) -> List[str]: - """Accept a variable, a name, or an iterable of either.""" - if isinstance(variables, (str, Var)): + """Accept a variable, a name, a whitespace-separated string of names, or + an iterable of either. + + Splitting a string matches `SymbolicContext.vars`, which is where these + names come from. Without it ``.all('x y z')`` bound one variable literally + called ``'x y z'``, which occurs nowhere -- so x, y and z stayed free and + were existentially closed, turning a universal into an existential with no + error anywhere. + """ + if isinstance(variables, Var): variables = [variables] + elif isinstance(variables, str): + variables = variables.split() names = [] for v in variables: if isinstance(v, Var): diff --git a/tests/test_symbolic.py b/tests/test_symbolic.py index 77876b3..047547b 100644 --- a/tests/test_symbolic.py +++ b/tests/test_symbolic.py @@ -398,7 +398,10 @@ def test_substitution_avoids_capture_by_inner_binders(S): """The fresh binder has to dodge names bound deeper in the body, not just the free ones.""" x, y, z = S.vars('x y z') - inner = ((x + y).eq(z) & (x + z).eq(y).drop('_v0')) + v0 = S.var('_v0') + # `v0.eq(v0)` is trivially true, so the conjunct means exactly what it did + # before; it is there so that '_v0' occurs and can be bound. + inner = ((x + y).eq(z) & ((x + z).eq(y) & v0.eq(v0)).drop('_v0')) formula = inner.drop(y).substitute(x='y') assert formula.variables() == ['y', 'z'] assert not formula.evaluate().is_empty() @@ -425,3 +428,25 @@ def test_class_implicit_and_explicit_checking_agree(depth2): advice = depth2.advice(TreeDepthGraph.from_networkx(graph)) assert (K.check_member(formula, advice, implicit=True) == K.check_member(formula, advice)) + + +def test_whitespace_variable_names_are_split(S): + """`.all('x y')` must bind both, the way `vars('x y')` does. + + Binding one variable literally named 'x y' left x and y free, and free + variables are existentially closed by `check` -- so a universal silently + became an existential and the query answered a different question. + """ + x, y = S.vars('x y') + # x + y = y holds exactly when x = 0, so the universal is false and the + # existential is true; a mis-bound quantifier makes both come out true. + assert not (x + y).eq(y).all('x y').check() + assert (x + y).eq(y).drop('x y').check() + + +def test_quantifying_a_variable_that_is_not_free_is_an_error(S): + x, y = S.vars('x y') + with pytest.raises(SymbolicSymbolError, match='not free'): + (x + y).eq(y).all('w') + with pytest.raises(SymbolicSymbolError, match='not free'): + (x + y).eq(y).drop(['x', 'w']) From b5b0a8b0b6a0208df9f16cf190449fc7180b8295 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 22 Jul 2026 01:01:11 +0200 Subject: [PATCH 11/76] Default signatures: groups get * , abelian groups get + Only Buechi Z shipped a signature, so every other structure gave relations but no operators -- `(x * y).eq(z)` was unavailable everywhere. Worse, the family wrappers did not forward `symbolic()` at all: reaching the interface meant going through `.cls`. `symbolic()` now falls back to `default_signature()` on the presentation or class, and `SymbolicClassWrapper` gives the group and algebra families both the forwarding and their operator vocabulary. The families name their operation consistently, so the mapping is mechanical: M -> `*` for the non-abelian families, A -> `+` for FiniteAbelianGroups. Skolem arithmetic also gets its codec, so `(x * y).eq(12)` enumerates the divisor pairs. The operator follows the signature symbol, not member-by-member commutativity: IndexTwoCyclicGroups mixes abelian and non-abelian families in one class, so it gets `*` throughout. Not bound: `&`, `|` and `~` already mean the formula connectives, so lattice operations must stay methods -- the reason arithmetic spells `B` as `divided_by_power`. Inverse is not a relation of these classes, so no operator is wired to it. Known gap: FiniteAbelianGroups declares no equality relation, so `+` builds terms that cannot become formulas. Equality is missing from five families altogether; that is the next change. Co-Authored-By: Claude Opus 4.8 --- autstr/buildin/tree_presentations.py | 8 ++++++ autstr/cocycle_groups.py | 3 +- autstr/groups.py | 17 +++++++---- autstr/presentations.py | 9 ++++++ autstr/symbolic/__init__.py | 8 ++++-- autstr/symbolic/signature.py | 31 ++++++++++++++++++++ autstr/tree_groups.py | 5 ++-- autstr/tree_presentations.py | 8 ++++++ autstr/uniform.py | 42 ++++++++++++++++++++++++++++ 9 files changed, 120 insertions(+), 11 deletions(-) diff --git a/autstr/buildin/tree_presentations.py b/autstr/buildin/tree_presentations.py index d97ee82..9787694 100644 --- a/autstr/buildin/tree_presentations.py +++ b/autstr/buildin/tree_presentations.py @@ -57,6 +57,14 @@ def __init__(self, max_states: Optional[int] = None): 'E': self._equality()}, padding_symbol=self.PAD, max_states=max_states) + def default_signature(self): + """Multiplication as ``*``, equality as ``.eq``, and positive integers + as elements -- so ``(x * y).eq(12)`` says what it reads as.""" + from autstr.symbolic import FunctionCodec, operation_signature + return operation_signature( + self.get_relation_symbols(), graph='M', operator='*', + codec=FunctionCodec(self.encode, self.decode)) + # ---------------- encoding elements ---------------- @classmethod diff --git a/autstr/cocycle_groups.py b/autstr/cocycle_groups.py index ab768df..d4242f5 100644 --- a/autstr/cocycle_groups.py +++ b/autstr/cocycle_groups.py @@ -37,6 +37,7 @@ from autstr.sparse_tree_automata import SparseTreeAutomaton, Tree from autstr.tree_presentations import TreeAutomaticPresentation from autstr.tree_uniform import UniformlyTreeAutomaticClass, sta_from_delta +from autstr.uniform import SymbolicClassWrapper PAD = '*' @@ -315,7 +316,7 @@ def scattered_sites(p: int, m: int, d: int = 1) -> Tuple[CocycleSites, Dict]: # The claim-and-verify automaton: the six-register protocol # ==================================================================== -class CocycleRankWidthGroups: +class CocycleRankWidthGroups(SymbolicClassWrapper): """The uniformly tree-automatic class of distributed-center class-2 groups of tensor cut-rank <= r over R = Z/p^d. The bottom-up automaton's state is six R^r registers (plus three scratch slots):: diff --git a/autstr/groups.py b/autstr/groups.py index 477fbe8..66cb010 100644 --- a/autstr/groups.py +++ b/autstr/groups.py @@ -59,7 +59,9 @@ from autstr import chain_ring as cr from autstr.presentations import AutomaticPresentation from autstr.sparse_automata import SparseDFA -from autstr.uniform import UniformlyAutomaticClass, dfa_from_delta +from autstr.uniform import ( + SymbolicClassWrapper, UniformlyAutomaticClass, dfa_from_delta, +) PAD = '*' SEP = '#' @@ -74,7 +76,7 @@ def _lsbf_bits(n: int) -> List[str]: # Groups with a cyclic subgroup of index <= 2 # ==================================================================== -class IndexTwoCyclicGroups: +class IndexTwoCyclicGroups(SymbolicClassWrapper): """The uniformly automatic class of finite groups with a cyclic subgroup of index <= 2, in one advice format. Signature: M(x,y,z) [z = x*y], T(x) [x is a twisted element r^a s], Eq(x,y), plus the primitives the @@ -439,7 +441,7 @@ def get_structure(self, advice: Sequence[str]) -> AutomaticPresentation: # Extraspecial p-groups (fixed p, growing rank) # ==================================================================== -class ExtraspecialGroups: +class ExtraspecialGroups(SymbolicClassWrapper): """For a fixed prime p, the uniformly automatic class of Heisenberg-type groups of order p^(1+2n): elements (c, a, b) with c in Z_p and a, b in Z_p^n, multiplied as (c,a,b)(c',a',b') = @@ -579,11 +581,16 @@ def get_structure(self, n: int) -> AutomaticPresentation: # Finite abelian groups # ==================================================================== -class FiniteAbelianGroups: +class FiniteAbelianGroups(SymbolicClassWrapper): """The uniformly automatic class of all finite abelian groups, presented by their cyclic decompositions: the group Z_{n_1} ⊕ ... ⊕ Z_{n_k} has advice bin(n_1)# ... bin(n_k)# (LSB-first binary per block).""" + #: addition, not multiplication -- these members are abelian by + #: construction, and the class presents the operation as A(x, y, z). + GRAPH = 'A' + OPERATOR = '+' + def __init__(self): self.sigma = {PAD, '0', '1', SEP} self.cls = UniformlyAutomaticClass({ @@ -749,7 +756,7 @@ def _solve_xa_eq_b(A: np.ndarray, B: np.ndarray, p: int) -> np.ndarray: return X -class CutRankGroups: +class CutRankGroups(SymbolicClassWrapper): """For a fixed prime p, center dimension k, width r and ring depth d, the uniformly automatic class of class-2 groups over R = Z/p^d whose commutation form admits a linear layout of module cut-rank <= r (bounded *linear diff --git a/autstr/presentations.py b/autstr/presentations.py index 8b99ab4..fde3847 100644 --- a/autstr/presentations.py +++ b/autstr/presentations.py @@ -163,8 +163,17 @@ def symbolic(self, signature=None): """ from autstr.symbolic.backends import StructureBackend from autstr.symbolic.context import SymbolicContext + if signature is None: + signature = self.default_signature() return SymbolicContext(StructureBackend(self), signature) + def default_signature(self): + """The signature `symbolic()` uses when none is given, or None for a + structure that declares no operators and is addressed through its + relation symbols. Structures that know their own vocabulary override + this; see `autstr.symbolic.operation_signature`.""" + return None + def update(self, **kwargs) -> None: for key in kwargs: if isinstance(kwargs[key], SparseDFA): diff --git a/autstr/symbolic/__init__.py b/autstr/symbolic/__init__.py index 3769fb2..bdde33a 100644 --- a/autstr/symbolic/__init__.py +++ b/autstr/symbolic/__init__.py @@ -28,12 +28,14 @@ from autstr.symbolic.compiler import CompileError from autstr.symbolic.expr import Formula, Term, Var from autstr.symbolic.signature import ( - ElementCodec, Function, FunctionCodec, Signature, + ElementCodec, EQUALITY_SYMBOLS, Function, FunctionCodec, Signature, + operation_signature, ) __all__ = [ - 'Backend', 'ClassBackend', 'CompileError', 'ElementCodec', 'Formula', + 'Backend', 'ClassBackend', 'CompileError', 'ElementCodec', + 'EQUALITY_SYMBOLS', 'Formula', 'Function', 'FunctionCodec', 'FunctionSymbol', 'Relation', 'RelationSymbol', 'Signature', 'StructureBackend', 'SymbolicContext', - 'SymbolicSymbolError', 'Term', 'Var', + 'SymbolicSymbolError', 'Term', 'Var', 'operation_signature', ] diff --git a/autstr/symbolic/signature.py b/autstr/symbolic/signature.py index f5e4d43..86668c8 100644 --- a/autstr/symbolic/signature.py +++ b/autstr/symbolic/signature.py @@ -40,6 +40,37 @@ def positions(self, graph_arity: int) -> tuple: return inputs, out +#: Names structures in this library use for their equality relation. +EQUALITY_SYMBOLS = ('Eq', 'E') + + +def operation_signature(relations, graph: str, operator: str, + codec=None) -> 'Signature': + """The signature of a structure whose binary operation is presented by the + ternary graph relation `graph`, bound to `operator`. + + Equality is bound to ``.eq`` when the structure declares it, under either + name in `EQUALITY_SYMBOLS`. A structure without an equality relation gets + the operator anyway, but terms cannot then be turned into formulas -- + ``(x + y).eq(z)`` is the only way to state what a term denotes -- so such + a structure is better served through its relation symbols directly. + + :param relations: the structure's relation symbols. + :param graph: the ternary relation R(x, y, z) meaning ``x op y = z``. + :param operator: the Python operator to bind, ``'*'`` or ``'+'``. + :param codec: optional element codec; unused over a uniformly automatic + class, where an element's encoding depends on the advice. + """ + signature = Signature(codec=codec) + signature.function(operator, graph=graph, out=2) + signature.operator(operator, operator) + for name in EQUALITY_SYMBOLS: + if name in relations: + signature.operator('eq', name) + break + return signature + + class ElementCodec: """Translation between Python values and element encodings. diff --git a/autstr/tree_groups.py b/autstr/tree_groups.py index 73d5045..73dc8e0 100644 --- a/autstr/tree_groups.py +++ b/autstr/tree_groups.py @@ -42,13 +42,14 @@ from autstr.sparse_tree_automata import SparseTreeAutomaton, Tree from autstr.tree_presentations import TreeAutomaticPresentation from autstr.tree_uniform import UniformlyTreeAutomaticClass, sta_from_delta +from autstr.uniform import SymbolicClassWrapper PAD = '*' SHAPE = 's' CMARK = 'c' -class TreeExtraspecialGroups: +class TreeExtraspecialGroups(SymbolicClassWrapper): """The uniformly tree-automatic class of tree-indexed extraspecial p-groups (advice = shape tree, elements = coordinate labellings).""" @@ -199,7 +200,7 @@ def get_structure(self, shape: Tree) -> TreeAutomaticPresentation: # Class-2 groups of bounded rank-width (tree layouts of bounded cut-rank) # ==================================================================== -class CutRankTreeGroups: +class CutRankTreeGroups(SymbolicClassWrapper): """For a fixed prime p, center dimension k, width r and ring depth d, the uniformly tree-automatic class of class-2 groups over R = Z/p^d whose commutation form admits a *tree* layout of module cut-rank <= r (bounded diff --git a/autstr/tree_presentations.py b/autstr/tree_presentations.py index d663e32..f021848 100644 --- a/autstr/tree_presentations.py +++ b/autstr/tree_presentations.py @@ -98,8 +98,16 @@ def symbolic(self, signature=None): """ from autstr.symbolic.backends import TreeStructureBackend from autstr.symbolic.context import SymbolicContext + if signature is None: + signature = self.default_signature() return SymbolicContext(TreeStructureBackend(self), signature) + def default_signature(self): + """The signature `symbolic()` uses when none is given, or None for a + structure addressed through its relation symbols. See + `autstr.symbolic.operation_signature`.""" + return None + def update(self, **automata) -> None: """Install or replace relations. Values may be automata (saturated and domain-restricted like at construction time) or formula strings diff --git a/autstr/uniform.py b/autstr/uniform.py index fba4efe..2dff7a7 100644 --- a/autstr/uniform.py +++ b/autstr/uniform.py @@ -91,6 +91,40 @@ def dfa_from_delta(sigma, states, arity, delta, initial, finals, ).minimize() +class SymbolicClassWrapper: + """Mixin for the family wrappers that present a class through ``.cls``. + + The wrappers (the group and algebra families) hold their uniformly + automatic class in ``.cls`` and otherwise offer a domain-specific API. This + forwards the symbolic interface to that class and supplies the family's + operator vocabulary, so `ExtraspecialGroups(3).symbolic()` works directly + instead of reaching into ``.cls``. + + Subclasses set `GRAPH` and `OPERATOR`; the default is a multiplicative + group, since that is what most of these families are. + """ + + #: the ternary relation R(x, y, z) meaning ``x op y = z`` + GRAPH = 'M' + #: the Python operator it binds to + OPERATOR = '*' + + def default_signature(self): + from autstr.symbolic import operation_signature + return operation_signature(self.cls.get_relation_symbols(), + graph=self.GRAPH, operator=self.OPERATOR) + + def symbolic(self, signature=None): + """A symbolic interface to this family; see + `UniformlyAutomaticClass.symbolic`. Element codecs are not used over a + class -- an element's encoding depends on the advice -- so constants + and decoded solutions come from ``get_structure(advice).symbolic()``. + """ + if signature is None: + signature = self.default_signature() + return self.cls.symbolic(signature) + + class UniformlyAutomaticClass: """A uniformly automatic presentation of a class of structures. @@ -143,8 +177,16 @@ def symbolic(self, signature=None): """ from autstr.symbolic.backends import ClassBackend from autstr.symbolic.context import SymbolicContext + if signature is None: + signature = self.default_signature() return SymbolicContext(ClassBackend(self), signature) + def default_signature(self): + """The signature `symbolic()` uses when none is given, or None for a + class addressed through its relation symbols. See + `autstr.symbolic.operation_signature`.""" + return None + @staticmethod def _variable_names(phi: logic.Expression) -> set: """All variable names occurring in phi (free and bound).""" From 0d5522774134b0426c4868eea933a190d1174dfc Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 22 Jul 2026 01:35:12 +0200 Subject: [PATCH 12/76] Relations that are declared now and built on first use Equality is definable in most presentations here, but defining it costs an automaton construction that most queries never need, and for the wider graph classes that construction is expensive. `DeferredRelations` lets a structure register such a relation instead of building it: `get_relation_symbols` reports it immediately, and it materializes when something first asks. `materialize()` forces the construction -- what you want before pickling or reusing a structure -- and the constructors that register one take an eager flag for the same purpose. The query paths materialize whatever a formula mentions, so the formula-string API sees the relation transparently, and the symbolic backends now read arities through `relation()` rather than the automata dict, which is what triggers the build. Co-Authored-By: Claude Opus 4.8 --- autstr/presentations.py | 82 ++++++++++++++++++++++++++++++++---- autstr/symbolic/backends.py | 8 ++-- autstr/tree_presentations.py | 10 +++-- autstr/uniform.py | 39 +++++++++++++---- 4 files changed, 117 insertions(+), 22 deletions(-) diff --git a/autstr/presentations.py b/autstr/presentations.py index fde3847..fb3885c 100644 --- a/autstr/presentations.py +++ b/autstr/presentations.py @@ -7,6 +7,7 @@ from autstr.utils.logic import get_free_elementary_vars, optimize_query import json +import re import struct import zlib from typing import Dict @@ -112,7 +113,72 @@ def _parse_payload(cls, payload: bytes): enforce_consistency=False ) -class AutomaticPresentation: +class DeferredRelations: + """Relations declared up front and built on first use. + + Equality is definable in most presentations here -- from ``Leq`` in a + lattice, from ``Subset`` on set-valued elements, from the operation in a + group -- but defining it costs an automaton construction that most queries + never need, and for the wider graph classes that construction is + expensive. So such a relation is *registered* rather than built, and + materializes when something asks for it. + + `materialize()` forces the construction, which is what you want before + pickling or otherwise reusing a structure, and every constructor that + registers deferred relations takes an ``eager`` flag for the same purpose. + + Subclasses say where their relations live (`_relations`) and how to install + one (`_install_relation`). A definition is either a formula string over the + existing signature or a callable returning an automaton. + """ + + def _declare_deferred(self, definitions: Dict[str, object], + eager: bool = False) -> None: + self._deferred = dict(definitions) + if eager: + self.materialize() + + @property + def _relations(self) -> dict: + return self.automata + + def _install_relation(self, name: str, definition) -> None: + raise NotImplementedError + + def get_relation_symbols(self) -> List[str]: + """All relation symbols, including any not yet built.""" + names = list(self._relations.keys()) + names += [n for n in getattr(self, '_deferred', ()) + if n not in self._relations] + return names + + def relation(self, name: str): + """The automaton for `name`, building it if it was deferred.""" + deferred = getattr(self, '_deferred', {}) + if name not in self._relations and name in deferred: + self._install_relation(name, deferred[name]) + return self._relations.get(name) + + def materialize(self, *names: str): + """Build the named deferred relations now, or all of them. Returns + self, so it chains onto a constructor.""" + for name in (names or tuple(getattr(self, '_deferred', ()))): + self.relation(name) + return self + + def _materialize_for(self, phi) -> None: + """Build any deferred relation the formula mentions.""" + deferred = getattr(self, '_deferred', None) + if not deferred: + return + text = str(phi) + for name in list(deferred): + if name not in self._relations and re.search( + rf'\b{re.escape(name)}\b', text): + self.relation(name) + + +class AutomaticPresentation(DeferredRelations): """ A presentation of a possibly infinite structure by finite state machines. """ @@ -143,13 +209,11 @@ def automatic_presentation_to_file(self, filename: str) -> None: def automatic_presentation_from_file(cls, filename: str): return AutomaticPresentationSerializer.deserialize(filename) - def get_relation_symbols(self) -> List[str]: - """ - Returns list of all defined relation symbols. The symbol 'U' must always be defined and denotes the Universe. - - :return: list of all defined relation symbols. - """ - return list(self.automata.keys()) + def _install_relation(self, name, definition): + """Build a deferred relation: a formula over the current signature, or + a callable returning an automaton.""" + self.update(**{name: definition() if callable(definition) + else definition}) def symbolic(self, signature=None): """A symbolic interface to this structure: variables, relation and @@ -213,6 +277,7 @@ def check(self, phi: logic.Expression | str) -> bool: if isinstance(phi, str): phi = logic.Expression.fromstring(phi) phi = phi.simplify() + self._materialize_for(phi) return not self._build_automaton(phi).is_empty() def evaluate(self, phi: Union[str, logic.Expression], @@ -232,6 +297,7 @@ def evaluate(self, phi: Union[str, logic.Expression], if isinstance(phi, str): phi = logic.Expression.fromstring(phi) phi = optimize_query(phi) + self._materialize_for(phi) if prepared_updates: updates = dict(updates or {}) for key, dfa in prepared_updates.items(): diff --git a/autstr/symbolic/backends.py b/autstr/symbolic/backends.py index 77de327..b670cfc 100644 --- a/autstr/symbolic/backends.py +++ b/autstr/symbolic/backends.py @@ -99,7 +99,9 @@ def relation_symbols(self): return [s for s in self.presentation.get_relation_symbols() if s != 'U'] def arity(self, symbol): - dfa = self.presentation.automata.get(symbol) + # `relation` rather than the dict: equality and other definable + # relations are registered and built on first use. + dfa = self.presentation.relation(symbol) return None if dfa is None else dfa.symbol_arity def arity_of(self, dfa): @@ -183,7 +185,7 @@ def relation_symbols(self): return [s for s in self.presentation.get_relation_symbols() if s != 'U'] def arity(self, symbol): - sta = self.presentation.automata.get(symbol) + sta = self.presentation.relation(symbol) return None if sta is None else sta.symbol_arity def arity_of(self, sta): @@ -262,7 +264,7 @@ def relation_symbols(self): return [s for s in self.klass.get_relation_symbols() if s != 'U'] def arity(self, symbol): - dfa = self.klass.class_automata.get(symbol) + dfa = self.klass.relation(symbol) # Tape 0 carries the advice, so the relation arity is one less. return None if dfa is None else dfa.symbol_arity - 1 diff --git a/autstr/tree_presentations.py b/autstr/tree_presentations.py index f021848..748f6f3 100644 --- a/autstr/tree_presentations.py +++ b/autstr/tree_presentations.py @@ -15,6 +15,7 @@ from nltk.sem import logic +from autstr.presentations import DeferredRelations from autstr.sparse_tree_automata import SparseTreeAutomaton from autstr.utils.logic import get_free_elementary_vars, optimize_query from autstr.utils.tree_automata_tools import ( @@ -34,7 +35,7 @@ def tree_zero(symbol_arity: int = 1, base_alphabet=None) -> SparseTreeAutomaton: symbol_arity, base_alphabet or {0}) -class TreeAutomaticPresentation: +class TreeAutomaticPresentation(DeferredRelations): """A presentation of a structure by tree automata. :param automata: 'U' is the domain automaton (symbol arity 1); every other @@ -81,8 +82,9 @@ def _domain_product(self, arity: int) -> SparseTreeAutomaton: minimize(expand(self.automata['U'], arity, [i])))) return domain - def get_relation_symbols(self): - return list(self.automata.keys()) + def _install_relation(self, name, definition): + self.update(**{name: definition() if callable(definition) + else definition}) def symbolic(self, signature=None): """A symbolic interface to this structure: variables, relation and @@ -127,6 +129,7 @@ def check(self, phi) -> bool: if isinstance(phi, str): phi = logic.Expression.fromstring(phi) phi = phi.simplify() + self._materialize_for(phi) return not self._build_automaton(phi).is_empty() def evaluate(self, phi, updates: Optional[Dict[str, Union[ @@ -144,6 +147,7 @@ def evaluate(self, phi, updates: Optional[Dict[str, Union[ if isinstance(phi, str): phi = logic.Expression.fromstring(phi) phi = optimize_query(phi) + self._materialize_for(phi) if not updates: return self._build_automaton(phi) diff --git a/autstr/uniform.py b/autstr/uniform.py index 2dff7a7..8eb2e22 100644 --- a/autstr/uniform.py +++ b/autstr/uniform.py @@ -17,7 +17,7 @@ from nltk.sem import logic -from autstr.presentations import AutomaticPresentation +from autstr.presentations import AutomaticPresentation, DeferredRelations from autstr.sparse_automata import SparseDFA from autstr.buildin.automata import one from autstr.buildin.presentations import create_sparse_dfa, encode_symbol @@ -109,10 +109,27 @@ class SymbolicClassWrapper: #: the Python operator it binds to OPERATOR = '*' + #: how equality is defined when the class does not ship it, as a formula + #: over the existing signature; None if the class already has one + EQUALITY = None + + def _declare_equality(self, eager: bool = False) -> None: + """Register the family's equality relation, built on first use.""" + if self.EQUALITY is not None: + self.cls._declare_deferred({'Eq': self.EQUALITY}, eager=eager) + def default_signature(self): - from autstr.symbolic import operation_signature - return operation_signature(self.cls.get_relation_symbols(), - graph=self.GRAPH, operator=self.OPERATOR) + from autstr.symbolic import Signature, operation_signature + relations = self.cls.get_relation_symbols() + if self.GRAPH is None: + # lattices and graph classes have no single binary operation; their + # vocabulary is methods, but equality is still worth binding + signature = Signature() + if 'Eq' in relations: + signature.operator('eq', 'Eq') + return signature + return operation_signature(relations, graph=self.GRAPH, + operator=self.OPERATOR) def symbolic(self, signature=None): """A symbolic interface to this family; see @@ -125,7 +142,7 @@ class -- an element's encoding depends on the advice -- so constants return self.cls.symbolic(signature) -class UniformlyAutomaticClass: +class UniformlyAutomaticClass(DeferredRelations): """A uniformly automatic presentation of a class of structures. :param automata: dictionary of SparseDFAs. 'U' is reserved for the domain @@ -160,9 +177,14 @@ def __init__(self, automata: Dict[str, SparseDFA], padding_symbol="*") -> None: wrapped, padding_symbol=padding_symbol, enforce_consistency=False ) - def get_relation_symbols(self) -> List[str]: - """All relation symbols of the class signature ('U' is the domain).""" - return list(self.class_automata.keys()) + @property + def _relations(self) -> dict: + """Deferred relations live alongside the class signature ('U' is the + domain); see `DeferredRelations`.""" + return self.class_automata + + def _install_relation(self, name, definition) -> None: + self.define(name, definition() if callable(definition) else definition) def symbolic(self, signature=None): """A symbolic interface to this class. Expressions are written over @@ -255,6 +277,7 @@ def evaluate(self, phi: Union[str, logic.Expression]) -> Tuple[SparseDFA, List[s if isinstance(phi, str): phi = logic.Expression.fromstring(phi) phi = phi.simplify() + self._materialize_for(phi) free_vars = get_free_elementary_vars(phi) all_vars = sorted(self._variable_names(phi) | set(free_vars)) or ['p'] From b1c57bdc29987cc0dbaea9e027e50e94cbb9e939 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 22 Jul 2026 01:35:12 +0200 Subject: [PATCH 13/76] Every presentation has an equality relation, named Eq Five families declared none at all: FiniteAbelianGroups, FiniteBooleanAlgebras, and the six set-valued graph classes. Without one a term cannot become a formula -- `(x + y).eq(z)` is the only way to say what a term denotes -- so the operators added in the previous commit were unusable there. Each is defined from what the family already has, and registered rather than built: * FiniteAbelianGroups -- the identity is the unique idempotent, so `exists z0.(A(z0,z0,z0) and A(x,z0,y))`. The witness may not be called e0: nltk reads e-names as event variables and drops them silently. * FiniteBooleanAlgebras -- antisymmetry of the order, `Leq(x,y) and Leq(y,x)`. * the graph classes and MSO0-style set classes -- mutual inclusion, `Subset(x,y) and Subset(y,x)`, which is extensional equality of the vertex sets they take as elements. Skolem arithmetic and TreeExtraspecialGroups called equality 'E'; they now install it as 'Eq' and keep 'E' as an alias, so formulas written against the old signature still parse. That standardisation removes a live hazard. `operation_signature` had been guessing equality from the relation names, trying 'Eq' then 'E' -- but 'E' is the *edge* relation in every graph class, so the guess would have bound `.eq` to adjacency and silently answered "are these adjacent?" for "are these equal?". The name is now passed in explicitly and never inferred. `test_equality_is_not_adjacency` pins it on a graph where the two vertices are adjacent and not equal, so the two relations must disagree. Co-Authored-By: Claude Opus 4.8 --- autstr/algebra.py | 13 ++- autstr/buildin/tree_presentations.py | 5 +- autstr/graphs.py | 13 ++- autstr/groups.py | 6 +- autstr/symbolic/__init__.py | 4 +- autstr/symbolic/signature.py | 26 +++--- autstr/tree_graphs.py | 24 +++++- autstr/tree_groups.py | 4 +- tests/test_equality.py | 124 +++++++++++++++++++++++++++ tests/test_symbolic_tree.py | 4 +- 10 files changed, 197 insertions(+), 26 deletions(-) create mode 100644 tests/test_equality.py diff --git a/autstr/algebra.py b/autstr/algebra.py index b79d047..77e1fcb 100644 --- a/autstr/algebra.py +++ b/autstr/algebra.py @@ -26,7 +26,7 @@ from autstr.presentations import AutomaticPresentation from autstr.sparse_automata import SparseDFA -from autstr.uniform import UniformlyAutomaticClass, dfa_from_delta +from autstr.uniform import SymbolicClassWrapper, UniformlyAutomaticClass, dfa_from_delta from autstr.utils.logic import get_free_elementary_vars PAD = '*' @@ -338,12 +338,18 @@ def z1p_localization(p: int) -> Z1pLocalization: # Finite boolean algebras # ==================================================================== -class FiniteBooleanAlgebras: +class FiniteBooleanAlgebras(SymbolicClassWrapper): """The uniformly automatic class of all finite boolean algebras. The member with n atoms has advice 1^n; its elements are the subsets of {0, ..., n-1}, encoded as bitvectors of length exactly n.""" - def __init__(self): + #: no single binary operation: meet and join are both there, so the + #: vocabulary stays methods and only equality is bound + GRAPH = None + #: antisymmetry of the order is equality + EQUALITY = 'Leq(x,y) and Leq(y,x)' + + def __init__(self, eager_equality: bool = False): self.sigma = {PAD, '0', '1'} self.cls = UniformlyAutomaticClass({ 'U': self._universe_automaton(), @@ -353,6 +359,7 @@ def __init__(self): 'Compl': self._pointwise_automaton(2, lambda x, y: y == 1 - x), 'Atom': self._atom_automaton(), }, padding_symbol=PAD) + self._declare_equality(eager_equality) def _universe_automaton(self) -> SparseDFA: """U(p, x): p in 1* and x a {0,1}-word of exactly the same length diff --git a/autstr/buildin/tree_presentations.py b/autstr/buildin/tree_presentations.py index 9787694..ca264a9 100644 --- a/autstr/buildin/tree_presentations.py +++ b/autstr/buildin/tree_presentations.py @@ -52,9 +52,12 @@ class SkolemArithmetic(TreeAutomaticPresentation): PAD = '*' def __init__(self, max_states: Optional[int] = None): + equality = self._equality() + # 'Eq' is the standard name across the library; 'E' is kept as an + # alias so formulas written against the old signature keep working. super().__init__( {'U': self._universe(), 'M': self._multiplication(), - 'E': self._equality()}, + 'Eq': equality, 'E': equality}, padding_symbol=self.PAD, max_states=max_states) def default_signature(self): diff --git a/autstr/graphs.py b/autstr/graphs.py index d5f854d..1deaf8c 100644 --- a/autstr/graphs.py +++ b/autstr/graphs.py @@ -29,7 +29,7 @@ from autstr.presentations import AutomaticPresentation from autstr.sparse_automata import SparseDFA -from autstr.uniform import UniformlyAutomaticClass, dfa_from_delta +from autstr.uniform import SymbolicClassWrapper, UniformlyAutomaticClass, dfa_from_delta PAD = '*' @@ -480,11 +480,18 @@ def delta(q, sym): return dfa_from_delta(sigma, states, 3, delta, 'ok', {'ok', 'pad'}) -class _SetGraphClass: +class _SetGraphClass(SymbolicClassWrapper): """Shared class-level operations of the set-signature graph classes.""" - def __init__(self, automata: Dict[str, SparseDFA]): + #: elements are vertex *sets* and E is the edge relation, not equality + GRAPH = None + #: extensional equality of sets + EQUALITY = 'Subset(x,y) and Subset(y,x)' + + def __init__(self, automata: Dict[str, SparseDFA], + eager_equality: bool = False): self.cls = UniformlyAutomaticClass(automata, padding_symbol=PAD) + self._declare_equality(eager_equality) def advice(self, graph) -> List[str]: raise NotImplementedError diff --git a/autstr/groups.py b/autstr/groups.py index 66cb010..5036b9c 100644 --- a/autstr/groups.py +++ b/autstr/groups.py @@ -590,13 +590,17 @@ class FiniteAbelianGroups(SymbolicClassWrapper): #: construction, and the class presents the operation as A(x, y, z). GRAPH = 'A' OPERATOR = '+' + #: the identity is the unique idempotent, and x + 0 = y exactly when x = y. + #: The witness may not be called e0: nltk reads e-names as event variables. + EQUALITY = 'exists z0.(A(z0,z0,z0) and A(x,z0,y))' - def __init__(self): + def __init__(self, eager_equality: bool = False): self.sigma = {PAD, '0', '1', SEP} self.cls = UniformlyAutomaticClass({ 'U': self._universe_automaton(), 'A': self._addition_automaton(), }, padding_symbol=PAD) + self._declare_equality(eager_equality) self.cls.element_alphabet = ['0', '1', SEP] def _universe_automaton(self) -> SparseDFA: diff --git a/autstr/symbolic/__init__.py b/autstr/symbolic/__init__.py index bdde33a..79dd1b0 100644 --- a/autstr/symbolic/__init__.py +++ b/autstr/symbolic/__init__.py @@ -28,13 +28,13 @@ from autstr.symbolic.compiler import CompileError from autstr.symbolic.expr import Formula, Term, Var from autstr.symbolic.signature import ( - ElementCodec, EQUALITY_SYMBOLS, Function, FunctionCodec, Signature, + ElementCodec, EQUALITY_SYMBOL, Function, FunctionCodec, Signature, operation_signature, ) __all__ = [ 'Backend', 'ClassBackend', 'CompileError', 'ElementCodec', - 'EQUALITY_SYMBOLS', 'Formula', + 'EQUALITY_SYMBOL', 'Formula', 'Function', 'FunctionCodec', 'FunctionSymbol', 'Relation', 'RelationSymbol', 'Signature', 'StructureBackend', 'SymbolicContext', 'SymbolicSymbolError', 'Term', 'Var', 'operation_signature', diff --git a/autstr/symbolic/signature.py b/autstr/symbolic/signature.py index 86668c8..7ea04e8 100644 --- a/autstr/symbolic/signature.py +++ b/autstr/symbolic/signature.py @@ -40,20 +40,26 @@ def positions(self, graph_arity: int) -> tuple: return inputs, out -#: Names structures in this library use for their equality relation. -EQUALITY_SYMBOLS = ('Eq', 'E') +#: The standard name for a structure's equality relation. Some structures +#: also answer to 'E', but 'E' is the *edge* relation in every graph class, so +#: equality is always named explicitly rather than guessed from the symbols. +EQUALITY_SYMBOL = 'Eq' def operation_signature(relations, graph: str, operator: str, + equality: str = EQUALITY_SYMBOL, codec=None) -> 'Signature': """The signature of a structure whose binary operation is presented by the ternary graph relation `graph`, bound to `operator`. - Equality is bound to ``.eq`` when the structure declares it, under either - name in `EQUALITY_SYMBOLS`. A structure without an equality relation gets - the operator anyway, but terms cannot then be turned into formulas -- - ``(x + y).eq(z)`` is the only way to state what a term denotes -- so such - a structure is better served through its relation symbols directly. + Equality is bound to ``.eq`` when the structure declares `equality`. The + name is passed in rather than guessed: 'E' means equality in Skolem + arithmetic but the *edge* relation in every graph class, so guessing would + silently answer "are these adjacent?" for "are these equal?". + + A structure without an equality relation still gets the operator, but its + terms cannot become formulas -- ``(x + y).eq(z)`` is the only way to say + what a term denotes. :param relations: the structure's relation symbols. :param graph: the ternary relation R(x, y, z) meaning ``x op y = z``. @@ -64,10 +70,8 @@ def operation_signature(relations, graph: str, operator: str, signature = Signature(codec=codec) signature.function(operator, graph=graph, out=2) signature.operator(operator, operator) - for name in EQUALITY_SYMBOLS: - if name in relations: - signature.operator('eq', name) - break + if equality in relations: + signature.operator('eq', equality) return signature diff --git a/autstr/tree_graphs.py b/autstr/tree_graphs.py index f73a8f0..3a126ba 100644 --- a/autstr/tree_graphs.py +++ b/autstr/tree_graphs.py @@ -70,6 +70,7 @@ from autstr.sparse_tree_automata import SparseTreeAutomaton, Tree from autstr.tree_presentations import TreeAutomaticPresentation from autstr.tree_uniform import UniformlyTreeAutomaticClass, sta_from_delta +from autstr.uniform import SymbolicClassWrapper PAD = '*' NOP = 'n' @@ -261,10 +262,15 @@ def __repr__(self) -> str: # The uniformly tree-automatic class # ==================================================================== -class TreeWidthClass: +class TreeWidthClass(SymbolicClassWrapper): """The uniformly tree-automatic class of graphs of tree-width <= w, presented over set-valued elements (MSO0 style).""" + #: elements are vertex sets and E is the edge relation, not equality + GRAPH = None + #: extensional equality of sets + EQUALITY = 'Subset(x,y) and Subset(y,x)' + def __init__(self, w: int, max_states: Optional[int] = None): if w < 0: raise ValueError("width bound must be >= 0") @@ -291,6 +297,7 @@ def __init__(self, w: int, max_states: Optional[int] = None): 'E': self._edge_automaton(), }, padding_symbol=PAD, max_states=max_states) + self._declare_equality(getattr(self, '_eager_equality', False)) # ---------------- the automata ---------------- def _advice_letter(self, a): @@ -677,7 +684,7 @@ def cycle(cls, n: int) -> "CliqueWidthGraph": return cls(Tree(_join_symbol(0, 3), tree), 4) # close the cycle -class CliqueWidthClass: +class CliqueWidthClass(SymbolicClassWrapper): """The uniformly tree-automatic class of graphs of clique-width <= k, presented over set-valued elements (MSO0 style). @@ -691,6 +698,11 @@ class CliqueWidthClass: current label of each marked vertex, and whether the join has happened. """ + #: elements are vertex sets and E is the edge relation, not equality + GRAPH = None + #: extensional equality of sets + EQUALITY = 'Subset(x,y) and Subset(y,x)' + def __init__(self, k: int, max_states: Optional[int] = None): if not 2 <= k <= 9: raise ValueError("clique-width bound must be in [2, 9]") @@ -712,6 +724,7 @@ def __init__(self, k: int, max_states: Optional[int] = None): 'E': self._edge_automaton(), }, padding_symbol=PAD, max_states=max_states) + self._declare_equality(getattr(self, '_eager_equality', False)) # ---------------- letters ---------------- def _kind(self, a): @@ -1110,7 +1123,7 @@ def complete_bipartite(cls, left: int, right: int) -> "RankWidthGraph": [(i, left + j) for i in range(left) for j in range(right)]) -class RankWidthClass: +class RankWidthClass(SymbolicClassWrapper): """The uniformly tree-automatic class of graphs of rank-width <= r, presented over set-valued elements (MSO0 style: Sing, Subset, E). @@ -1140,6 +1153,11 @@ class RankWidthClass: binary letters, unlike the factored letters used by the group classes. """ + #: elements are vertex sets and E is the edge relation, not equality + GRAPH = None + #: extensional equality of sets + EQUALITY = 'Subset(x,y) and Subset(y,x)' + def __init__(self, r: int, max_states: Optional[int] = None): if r < 1: raise ValueError("rank-width bound must be >= 1") diff --git a/autstr/tree_groups.py b/autstr/tree_groups.py index 73dc8e0..9d862b4 100644 --- a/autstr/tree_groups.py +++ b/autstr/tree_groups.py @@ -65,10 +65,12 @@ def __init__(self, p: int, max_states: Optional[int] = None): self.advice_letters = {SHAPE} self.element_letters = set(self.inner_letters) | set(self.leaf_letters) + equality = self._equality_automaton() + # 'Eq' is the standard name; 'E' stays as an alias. self.cls = UniformlyTreeAutomaticClass({ 'U': self._universe_automaton(), 'M': self._multiplication_automaton(), - 'E': self._equality_automaton(), + 'Eq': equality, 'E': equality, }, padding_symbol=PAD, max_states=max_states) self.cls.element_alphabet = list(self.element_letters) diff --git a/tests/test_equality.py b/tests/test_equality.py new file mode 100644 index 0000000..8ee3b26 --- /dev/null +++ b/tests/test_equality.py @@ -0,0 +1,124 @@ +"""Equality across the presentations. + +Every family now declares an equality relation. Most define it from something +they already have, and the definition costs an automaton construction that +many queries never need -- so it is registered and built on first use, with an +``eager_equality`` flag for precompiling. +""" +import pytest + +from autstr.algebra import FiniteBooleanAlgebras +from autstr.graphs import TreeDepthClass, TreeDepthGraph +from autstr.tree_graphs import CliqueWidthClass, CliqueWidthGraph +from autstr.groups import FiniteAbelianGroups + + +class TestDeferredConstruction: + def test_declared_before_it_is_built(self): + a = FiniteAbelianGroups() + assert 'Eq' in a.cls.get_relation_symbols() + assert 'Eq' not in a.cls.class_automata + + def test_built_on_first_use(self): + a = FiniteAbelianGroups() + a.cls.check('exists x.(Eq(x,x))', a.advice([2])) + assert 'Eq' in a.cls.class_automata + + def test_materialize_forces_construction(self): + a = FiniteAbelianGroups() + assert a.cls.materialize() is a.cls + assert 'Eq' in a.cls.class_automata + + def test_eager_flag_builds_at_construction(self): + assert 'Eq' in FiniteAbelianGroups(eager_equality=True).cls.class_automata + + +class TestAbelianGroupEquality: + """Defined from the operation: the identity is the unique idempotent, and + x + 0 = y exactly when x = y.""" + + @pytest.fixture(scope="class") + def group(self): + return FiniteAbelianGroups() + + def test_pointwise_against_python(self, group): + advice = group.advice([4]) + for i in range(4): + for j in range(4): + got = group.cls.check( + 'Eq(x,y)', advice, + x=group.encode([i], [4]), y=group.encode([j], [4])) + assert got == (i == j), (i, j) + + def test_the_operator_form_agrees(self, group): + K = group.symbolic() + x, y, z = K.vars('x y z') + advice = group.advice([4]) + sums = (x + y).eq(z) + for i in range(4): + for j in range(4): + got = K.check_member( + sums, advice, x=group.encode([i], [4]), + y=group.encode([j], [4]), + z=group.encode([(i + j) % 4], [4])) + assert got, (i, j) + + +class TestLatticeEquality: + """Antisymmetry of the order is equality.""" + + def test_pointwise_on_a_four_element_algebra(self): + algebra = FiniteBooleanAlgebras() + advice = ['1'] * 2 # the algebra with 2 atoms + elements = ['00', '01', '10', '11'] + for a in elements: + for b in elements: + got = algebra.cls.check('Eq(x,y)', advice, + x=list(a), y=list(b)) + assert got == (a == b), (a, b) + + +class TestSetEquality: + """Elements are vertex sets, so equality is mutual inclusion -- and 'E' is + the edge relation here, which is exactly why equality is never guessed + from the relation names.""" + + def test_edge_and_equality_are_different_relations(self): + cls = TreeDepthClass(2) + assert {'E', 'Eq'} <= set(cls.cls.get_relation_symbols()) + + def test_pointwise_on_a_small_graph(self): + cls = TreeDepthClass(2) + graph = TreeDepthGraph([(1, ()), (2, (1,))]) + sets = [{0}, {1}, {0, 1}] + for i, x in enumerate(sets): + for j, y in enumerate(sets): + assert cls.check('Eq(x,y)', graph, x=x, y=y) == (i == j) + + def test_equality_is_not_adjacency(self): + """Why equality is named explicitly rather than guessed: on this graph + the two vertices are adjacent, and adjacency is what 'E' means here.""" + cls = TreeDepthClass(2) + graph = TreeDepthGraph([(1, ()), (2, (1,))]) + u, v = graph.nodes[0], graph.nodes[1] + assert (u, v) in graph.edges() or (v, u) in graph.edges() + assert cls.check('E(x,y)', graph, x={u}, y={v}) + assert not cls.check('Eq(x,y)', graph, x={u}, y={v}) + + +class TestTreeGraphClassEquality: + """The tree-engine graph classes register the same set equality. They are + the expensive ones to build, so clique-width 2 stands in for the family.""" + + def test_declared_lazily(self): + cls = CliqueWidthClass(2) + assert 'Eq' in cls.cls.get_relation_symbols() + assert 'Eq' not in cls.cls.class_automata + + def test_equality_is_not_adjacency(self): + cls = CliqueWidthClass(2) + graph = CliqueWidthGraph.clique(2) + u, v = sorted(graph.vertices)[:2] + assert cls.check('E(x,y)', graph, x={u}, y={v}) + assert not cls.check('Eq(x,y)', graph, x={u}, y={v}) + assert cls.check('Eq(x,y)', graph, x={u}, y={u}) diff --git a/tests/test_symbolic_tree.py b/tests/test_symbolic_tree.py index b0c8a98..5c7fbf8 100644 --- a/tests/test_symbolic_tree.py +++ b/tests/test_symbolic_tree.py @@ -36,7 +36,9 @@ def S(skolem): class TestSignature: def test_relation_symbols_exclude_the_domain(self, S): assert 'U' not in S.backend.relation_symbols() - assert set(S.backend.relation_symbols()) == {'M', 'E'} + # 'Eq' is the standard name for equality across the library; Skolem + # keeps 'E' as an alias so older formulas still parse. + assert set(S.backend.relation_symbols()) == {'M', 'Eq', 'E'} def test_arities_come_from_the_automata(self, S): assert S.backend.arity('M') == 3 From 1d93514351c4f5c24312c09239ec10604d47a9fe Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 22 Jul 2026 02:07:40 +0200 Subject: [PATCH 14/76] Fix the three docs-build warnings The docs build runs with -W, so these fail it. Two are the same RST mistake in docstrings I added: `Tree`s. Inline markup cannot be followed directly by a letter, so docutils reads the backtick as an unterminated start-string. Reworded to "`Tree` objects". The third was a cross-reference ambiguity on `Function`, and my first attempt at it was wrong: I spelled the reference out in the docstring, which changed nothing, because the annotation `Dict[str, Function]` generates a reference too. Reproducing the build locally showed the real cause -- `Function` is in `autstr.symbolic.__all__`, which makes autodoc document it a second time as `autstr.symbolic.Function`, so every bare reference has two targets. Dropping it from `__all__` leaves one target and resolves both references. The name stays importable from `autstr.symbolic`; only `import *` and autodoc are affected, and a signature's functions are built with `Signature.function` rather than by constructing one directly. A comment on `__all__` records why, so it does not get re-added. Verified by running the build the way CI does (sphinx -W --keep-going, with notebook execution off): 0 warnings, exit 0. Co-Authored-By: Claude Opus 4.8 --- autstr/symbolic/__init__.py | 6 +++++- autstr/symbolic/backends.py | 5 +++-- autstr/tree_presentations.py | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/autstr/symbolic/__init__.py b/autstr/symbolic/__init__.py index 79dd1b0..c7b6cdd 100644 --- a/autstr/symbolic/__init__.py +++ b/autstr/symbolic/__init__.py @@ -32,10 +32,14 @@ operation_signature, ) +# `Function` is importable from here but deliberately absent from __all__: +# re-exporting it makes `autstr.symbolic.Function` a second autodoc target, and +# the annotation `Dict[str, Function]` then resolves ambiguously and fails the +# docs build, which runs with -W. Users build one via `Signature.function`. __all__ = [ 'Backend', 'ClassBackend', 'CompileError', 'ElementCodec', 'EQUALITY_SYMBOL', 'Formula', - 'Function', 'FunctionCodec', 'FunctionSymbol', 'Relation', + 'FunctionCodec', 'FunctionSymbol', 'Relation', 'RelationSymbol', 'Signature', 'StructureBackend', 'SymbolicContext', 'SymbolicSymbolError', 'Term', 'Var', 'operation_signature', ] diff --git a/autstr/symbolic/backends.py b/autstr/symbolic/backends.py index b670cfc..5ce4185 100644 --- a/autstr/symbolic/backends.py +++ b/autstr/symbolic/backends.py @@ -170,8 +170,9 @@ class TreeStructureBackend(Backend): """Queries answered by a `TreeAutomaticPresentation`. Elements are trees rather than words, so a signature's codec encodes - Python values to `Tree`s; everything above this module is unchanged, since - the codec's output is only ever handed back to the backend. + Python values to `Tree` objects; everything above this module is + unchanged, since the codec's output is only ever handed back to the + backend. Every operation of the string backends is available; the ones whose string formulation counts word positions -- enumeration order and `exinf` -- are diff --git a/autstr/tree_presentations.py b/autstr/tree_presentations.py index 748f6f3..88112d9 100644 --- a/autstr/tree_presentations.py +++ b/autstr/tree_presentations.py @@ -92,7 +92,7 @@ def symbolic(self, signature=None): operators instead of formula strings. Elements are trees, so a signature's codec encodes Python values to - `Tree`s. Enumeration is shortlex by node count, which orders by + `Tree` objects. Enumeration is shortlex by node count, which orders by encoding size rather than by any notion of value. :param signature: declared functions, operators and element codec. From 98aabba6903e51dff2ae60181729cb614df79a37 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 22 Jul 2026 13:06:24 +0200 Subject: [PATCH 15/76] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- autstr/symbolic/expr.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/autstr/symbolic/expr.py b/autstr/symbolic/expr.py index f82f6d2..969cccd 100644 --- a/autstr/symbolic/expr.py +++ b/autstr/symbolic/expr.py @@ -116,6 +116,8 @@ def __mul__(self, other): f"by an integer is available as .times(n)") def __rmul__(self, other): + if '*' in self.ctx.signature.operators: + return self._binop('*', other, swap=True) return self.__mul__(other) def times(self, n: int) -> 'Term': From 589f479f005875cd8a0d0a33c800b6eb89bcf0f0 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 22 Jul 2026 13:06:38 +0200 Subject: [PATCH 16/76] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- autstr/presentations.py | 1 - 1 file changed, 1 deletion(-) diff --git a/autstr/presentations.py b/autstr/presentations.py index fb3885c..d7a9a5d 100644 --- a/autstr/presentations.py +++ b/autstr/presentations.py @@ -10,7 +10,6 @@ import re import struct import zlib -from typing import Dict class AutomaticPresentationSerializer: MAGIC = b'APRS' From 1b5e39b3292d020bc2fb76a1522c9eb5950de85a Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 22 Jul 2026 13:29:51 +0200 Subject: [PATCH 17/76] Restore spliced relations even when a query fails `AutomaticPresentation.evaluate(updates=...)` installs relations for one evaluation and restored them only on the success path, so a query that raised mid-build left the temporaries installed and every later evaluation silently answered against them. I noticed this while writing the tree engine's `evaluate` and said so in that commit message -- "a latent bug there, not copied here" -- but left it in place. Flagging a known bug is not fixing it. The regression test runs a failing query with `updates=` and asserts the relations are unchanged afterwards, which is what catches a restore that happens on only one path. Co-Authored-By: Claude Opus 4.8 --- autstr/presentations.py | 19 ++++++++++++------- tests/test_symbolic.py | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/autstr/presentations.py b/autstr/presentations.py index d7a9a5d..e5e4189 100644 --- a/autstr/presentations.py +++ b/autstr/presentations.py @@ -316,13 +316,18 @@ def evaluate(self, phi: Union[str, logic.Expression], automata_backup = self.automata self.automata = dict(self.automata, **updates) - if len(get_free_elementary_vars(phi)) > 0: - dfa_phi = unpad(self._build_automaton(phi), self.padding_symbol).minimize() - else: - dfa_phi = self._build_automaton(phi) - - if updates is not None: - self.automata = automata_backup + try: + if len(get_free_elementary_vars(phi)) > 0: + dfa_phi = unpad(self._build_automaton(phi), + self.padding_symbol).minimize() + else: + dfa_phi = self._build_automaton(phi) + finally: + # Restore even if the build raises: otherwise a failed query would + # leave the spliced relations installed, and every later + # evaluation would silently answer against them. + if updates is not None: + self.automata = automata_backup return dfa_phi diff --git a/tests/test_symbolic.py b/tests/test_symbolic.py index 047547b..e04c252 100644 --- a/tests/test_symbolic.py +++ b/tests/test_symbolic.py @@ -450,3 +450,19 @@ def test_quantifying_a_variable_that_is_not_free_is_an_error(S): (x + y).eq(y).all('w') with pytest.raises(SymbolicSymbolError, match='not free'): (x + y).eq(y).drop(['x', 'w']) + + +def test_failed_evaluation_does_not_leave_spliced_relations_installed(S): + """`evaluate(updates=...)` installs relations for one query only. If the + build raises, the restore still has to happen -- otherwise every later + evaluation silently answers against the temporaries. + """ + presentation = S.backend.presentation + before = dict(presentation.automata) + + unit = presentation.automata['Eq'] + with pytest.raises(Exception): + presentation.evaluate('NoSuchRelation(x,y)', updates={'Spliced': unit}) + + assert dict(presentation.automata) == before + assert 'Spliced' not in presentation.automata From 19b4d19f4b5724d6c3ab152c8971347949f29478 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 22 Jul 2026 13:29:51 +0200 Subject: [PATCH 18/76] The infinite extraspecial p-group `InfiniteExtraspecialGroup(p)`: finitely supported a, b over F_p with a one-digit centre, multiplied by (a, b, c) * (a', b', c') = (a + a', b + b', c + c' + ). Unlike `ExtraspecialGroups` this is a single infinite structure rather than a class of finite ones, so its elements have an advice-free encoding and can be written as constants. The multiplication automaton needs no guessing: the first letter carries the centres, which fixes the pairing value the run must produce as c_z - c_x - c_y; the rest of the word accumulates the sum of a_i * b'_i and acceptance compares the two. Encodings trim trailing (0, 0) pairs, so they are unique and equality is the diagonal. It fills a real gap in what the library can test. Distinguishing `a * x` from `x * a` -- the reflected-operand bug just fixed in `Term.__rmul__` -- needs a structure that is both non-commutative and carries a codec, so that a plain Python value can stand on the left. Skolem arithmetic has a codec but commutes; the group classes do not commute but refuse constants, since a class element's encoding depends on the advice. This structure is the first with both, and reverting `__rmul__` to the unswapped form fails two of its tests. Validated against a reference `multiply()` on 40 random pairs, with the non-abelian sentence checked directly. Co-Authored-By: Claude Opus 4.8 --- autstr/groups.py | 136 ++++++++++++++++++++++++++++ tests/test_infinite_extraspecial.py | 81 +++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 tests/test_infinite_extraspecial.py diff --git a/autstr/groups.py b/autstr/groups.py index 5036b9c..74edd49 100644 --- a/autstr/groups.py +++ b/autstr/groups.py @@ -1281,3 +1281,139 @@ def evaluate_implicit(self, phi, advice: Sequence[str], **elements): def get_structure(self, advice: Sequence[str]) -> AutomaticPresentation: return self.cls.get_structure(advice) + + +class InfiniteExtraspecialGroup: + """The infinite extraspecial p-group: finitely supported vectors over + F_p with a one-digit centre. + + An element is a triple (a, b, c) with a, b finitely supported sequences + over F_p and c in F_p, multiplied by + + (a, b, c) * (a', b', c') = (a + a', b + b', c + c' + ), + + the central extension of F_p^(w) + F_p^(w) by F_p along the standard + symplectic form. It is non-abelian -- the commutator of (a, b, c) and + (a', b', c') is - -- and unlike `ExtraspecialGroups` it is + a single infinite structure rather than a class of finite ones, so its + elements have an advice-free encoding and can be written as constants. + + Encoding: the first letter carries the centre, and letter i + 1 carries + the pair (a_i, b_i); trailing (0, 0) pairs are trimmed, so encodings are + unique and equality is the diagonal. + """ + + PAD = '*' + + def __init__(self, p: int = 3): + if not 2 <= p <= 9: + raise ValueError("p must be a single digit prime power base") + self.p = p + self.centre_letters = [f'c{d}' for d in range(p)] + self.pair_letters = [f'{i}{j}' for i in range(p) for j in range(p)] + self.sigma = {self.PAD, *self.centre_letters, *self.pair_letters} + self.presentation = AutomaticPresentation( + {'U': self._universe(), 'M': self._multiplication(), + 'Eq': self._equality()}, + padding_symbol=self.PAD) + + # ---------------- encoding ---------------- + + def encode(self, element) -> List[str]: + """(a, b, c) -> word. `a` and `b` are sequences of digits.""" + a, b, c = element + a, b = list(a), list(b) + n = max(len(a), len(b)) + a += [0] * (n - len(a)) + b += [0] * (n - len(b)) + while a and a[-1] % self.p == 0 and b[-1] % self.p == 0: + a.pop() + b.pop() + return [f'c{c % self.p}'] + [f'{a[i] % self.p}{b[i] % self.p}' + for i in range(len(a))] + + def decode(self, word): + letters = [s for s in word if s != self.PAD] + centre = int(letters[0][1:]) + a = tuple(int(s[0]) for s in letters[1:]) + b = tuple(int(s[1]) for s in letters[1:]) + return a, b, centre + + # ---------------- automata ---------------- + + def _digits(self, letter): + """The (a, b) digits a letter contributes; padding contributes zeros.""" + return (0, 0) if letter == self.PAD else (int(letter[0]), + int(letter[1])) + + def _universe(self) -> SparseDFA: + states = ['i', 'ok', 'z', 'd'] + + def delta(q, sym): + (letter,) = sym + if q == 'i': + return 'ok' if letter in self.centre_letters else 'd' + if q in ('ok', 'z'): + if letter == self.PAD: + return q # trailing padding only + if letter in self.pair_letters: + return 'z' if letter == '00' else 'ok' + return 'd' + + return dfa_from_delta(self.sigma, states, 1, delta, 'i', {'ok'}) + + def _multiplication(self) -> SparseDFA: + p = self.p + states = ['i', 'd'] + [f'm{r}{s}' for r in range(p) for s in range(p)] + + def delta(q, sym): + lx, ly, lz = sym + if q == 'i': + if not all(l in self.centre_letters for l in (lx, ly, lz)): + return 'd' + cx, cy, cz = (int(l[1:]) for l in (lx, ly, lz)) + return f'm{(cz - cx - cy) % p}0' # required carry, sum so far + if q == 'd': + return 'd' + if any(l in self.centre_letters for l in (lx, ly, lz)): + return 'd' + required, running = int(q[1]), int(q[2]) + ax, bx = self._digits(lx) + ay, by = self._digits(ly) + az, bz = self._digits(lz) + if az != (ax + ay) % p or bz != (bx + by) % p: + return 'd' + return f'm{required}{(running + ax * by) % p}' + + finals = {f'm{r}{r}' for r in range(p)} + return dfa_from_delta(self.sigma, states, 3, delta, 'i', finals) + + def _equality(self) -> SparseDFA: + """Encodings are unique, so equality is the diagonal.""" + def delta(q, sym): + return 'ok' if q == 'ok' and sym[0] == sym[1] else 'd' + + return dfa_from_delta(self.sigma, ['ok', 'd'], 2, delta, 'ok', {'ok'}) + + # ---------------- interface ---------------- + + def multiply(self, x, y): + """The reference product, for checking the automaton against.""" + (ax, bx, cx), (ay, by, cy) = x, y + n = max(len(ax), len(bx), len(ay), len(by)) + pad = lambda v: list(v) + [0] * (n - len(v)) + ax, bx, ay, by = map(pad, (ax, bx, ay, by)) + pairing = sum(ax[i] * by[i] for i in range(n)) % self.p + return (tuple((ax[i] + ay[i]) % self.p for i in range(n)), + tuple((bx[i] + by[i]) % self.p for i in range(n)), + (cx + cy + pairing) % self.p) + + def default_signature(self): + from autstr.symbolic import FunctionCodec, operation_signature + return operation_signature( + self.presentation.get_relation_symbols(), graph='M', operator='*', + codec=FunctionCodec(self.encode, self.decode)) + + def symbolic(self, signature=None): + """A symbolic interface with ``*`` bound to the group product.""" + return self.presentation.symbolic(signature or self.default_signature()) diff --git a/tests/test_infinite_extraspecial.py b/tests/test_infinite_extraspecial.py new file mode 100644 index 0000000..9865758 --- /dev/null +++ b/tests/test_infinite_extraspecial.py @@ -0,0 +1,81 @@ +"""The infinite extraspecial p-group. + +A single infinite structure rather than a class of finite ones, so its +elements have an advice-free encoding and can be written as constants. That +combination -- a non-commutative operator plus a codec -- is what makes it the +structure that can tell `a * x` from `x * a`. +""" +import random + +import pytest + +from autstr.groups import InfiniteExtraspecialGroup + +U = ((1,), (0,), 0) # a = (1), b = (0) +V = ((0,), (1,), 0) # a = (0), b = (1) + + +@pytest.fixture(scope="module") +def group(): + return InfiniteExtraspecialGroup(3) + + +@pytest.fixture(scope="module") +def S(group): + return group.symbolic() + + +class TestPresentation: + def test_encoding_roundtrips(self, group): + rng = random.Random(3) + for _ in range(30): + n = rng.randint(0, 4) + a = tuple(rng.randrange(3) for _ in range(n)) + b = tuple(rng.randrange(3) for _ in range(n)) + element = (a, b, rng.randrange(3)) + assert group.decode(group.encode(element)) == element + + def test_product_matches_the_reference(self, group, S): + x, y, z = S.vars('x y z') + product = (x * y).eq(z).evaluate() + rng = random.Random(11) + for _ in range(40): + def sample(): + n = rng.randint(0, 3) + return (tuple(rng.randrange(3) for _ in range(n)), + tuple(rng.randrange(3) for _ in range(n)), + rng.randrange(3)) + a, b = sample(), sample() + want = group.multiply(a, b) + assert product.contains(x=a, y=b, z=want), (a, b) + other = (want[0], want[1], (want[2] + 1) % 3) + assert not product.contains(x=a, y=b, z=other), (a, b) + + def test_it_really_is_non_abelian(self, group, S): + assert group.multiply(U, V) != group.multiply(V, U) + x, y = S.vars('x y') + assert not (x * y).eq(y * x).all('x y').check() + + +class TestOperandOrder: + """`a * x` must compile as the product in that order. When the left + operand is a plain Python value, Python routes it to `Term.__rmul__`, + which has to swap -- without the swap the query silently answers about + `x * a`, and only a non-commutative structure with a codec can see it.""" + + def test_reflected_multiplication_keeps_the_order(self, group, S): + x, w = S.vars('x w') + # U * x, with U a Python value: this is Term.__rmul__ + relation = (U * x).eq(w).evaluate() + assert relation.contains(x=V, w=group.multiply(U, V)) + assert not relation.contains(x=V, w=group.multiply(V, U)) + + def test_it_differs_from_the_forward_direction(self, group, S): + x, w = S.vars('x w') + forward = (x * U).eq(w).evaluate() + assert forward.contains(x=V, w=group.multiply(V, U)) + assert not forward.contains(x=V, w=group.multiply(U, V)) + + def test_reflected_and_explicit_constant_agree(self, S): + x, w = S.vars('x w') + assert (U * x).eq(w) == (S.const(U) * x).eq(w) From 4f32cf0ae3d04a6d8f29fffe6deb3ccc7506b734 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 22 Jul 2026 13:42:23 +0200 Subject: [PATCH 19/76] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/source/overview.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/source/overview.md b/docs/source/overview.md index 522893d..e01ddf0 100644 --- a/docs/source/overview.md +++ b/docs/source/overview.md @@ -64,6 +64,7 @@ function graphs, which operators they bind to, and how Python values encode as elements. ```python +from autstr.arithmetic import encode, decode from autstr.symbolic import Signature, FunctionCodec signature = (Signature(codec=FunctionCodec(encode, decode)) From 202b19feaae1988c1eb5d6a81ee3c98427d3e967 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Tue, 11 Aug 2026 22:26:06 +0200 Subject: [PATCH 20/76] InfiniteGraph: the shared surface for infinite graph structures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first piece of the infinite-graph phase: a thin, engine-agnostic wrapper over an automatic or tree-automatic presentation that carries a domain U and a binary edge relation. It adds the graph vocabulary on top of the symbolic layer -- x.adj(y) for adjacency, plus .eq -- so every graph factory to come (ordinals, integer grids, the level-2 collapsible pushdown graphs) plugs into one surface, exactly as InfiniteExtraspecialGroup shares a presentation. The wrapper decides nothing itself; it forwards to the presentation, where FO(exists-infinity) is decided by synchronous projection. It works over either engine because both presentations expose the same relational interface. * `graph_signature` (next to `operation_signature`) binds `.adj` to the edge and `.eq` to equality. The edge name is passed in, never guessed -- `E` is the edge here but equality in Skolem, the hazard operation_signature already guards. * `InfiniteGraph` validates the edge exists and is binary, exposes `symbolic`, a decidable `is_symmetric` (so undirected-ness is checked, not assumed), and thin check/evaluate passthroughs. Vertices are Python values when a codec is supplied. Tested against Büchi arithmetic over Z with a formula-defined edge (x != y, the complete graph on Z), so the wrapper's mechanics are exercised without authoring any automaton; the concrete factories and their oracles follow. Co-Authored-By: Claude Opus 4.8 --- autstr/infinite_graphs.py | 90 +++++++++++++++++++++++++++++++++++ autstr/symbolic/__init__.py | 5 +- autstr/symbolic/signature.py | 23 +++++++++ tests/test_infinite_graphs.py | 81 +++++++++++++++++++++++++++++++ 4 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 autstr/infinite_graphs.py create mode 100644 tests/test_infinite_graphs.py diff --git a/autstr/infinite_graphs.py b/autstr/infinite_graphs.py new file mode 100644 index 0000000..9f53075 --- /dev/null +++ b/autstr/infinite_graphs.py @@ -0,0 +1,90 @@ +"""Infinite graphs as automatic structures. + +A thin, engine-agnostic wrapper over an automatic or tree-automatic +presentation whose signature has a domain ``U`` and a binary edge relation. It +adds the graph vocabulary on top of the symbolic layer — ``x.adj(y)`` for +adjacency, plus ``.eq`` — so every infinite-graph factory (ordinals, integer +grids, the level-2 collapsible pushdown graphs, …) plugs into one surface, +exactly as ``InfiniteExtraspecialGroup`` shares a presentation. + +The wrapper decides nothing itself: it forwards to the presentation, which is +where FO(∃^∞) is decided by synchronous projection. It works over either the +string or the tree engine, since both presentations expose the same relational +interface (`symbolic`, `check`, `evaluate`, `relation`, `get_relation_symbols`). +""" +from __future__ import annotations + +from typing import Optional + +from autstr.symbolic import graph_signature + + +class InfiniteGraph: + """A graph presented by automata: a domain and a binary edge relation. + + :param presentation: an `AutomaticPresentation` or `TreeAutomaticPresentation` + carrying the domain ``U`` and the edge relation. + :param edge: the binary relation read as adjacency (default ``'E'``). + :param directed: whether edges are directed. Undirected is the default; the + edge automaton is expected to be symmetric, which `is_symmetric` checks. + :param codec: optional element codec, so vertices can be written as Python + constants and solutions decoded. + """ + + #: the default name of the edge relation + EDGE = 'E' + + def __init__(self, presentation, edge: Optional[str] = None, + directed: bool = False, codec=None) -> None: + self.presentation = presentation + self.edge = edge or self.EDGE + self.directed = directed + self.codec = codec + + symbols = presentation.get_relation_symbols() + if self.edge not in symbols: + raise ValueError( + f"the presentation has no edge relation {self.edge!r}; " + f"its relations are {sorted(s for s in symbols if s != 'U')}") + arity = presentation.relation(self.edge).symbol_arity + if arity != 2: + raise ValueError( + f"the edge relation {self.edge!r} has arity {arity}, not 2; " + f"a graph edge is binary") + + def default_signature(self): + """The signature `symbolic()` uses when none is given: ``.adj`` bound to + the edge relation, and ``.eq`` when the graph declares equality.""" + return graph_signature(self.presentation.get_relation_symbols(), + edge=self.edge, codec=self.codec) + + def symbolic(self, signature=None): + """A symbolic interface to the graph. Build first-order formulas with + ``x.adj(y)`` and the usual connectives / quantifiers; see + `autstr.symbolic`.""" + return self.presentation.symbolic(signature or self.default_signature()) + + def is_symmetric(self) -> bool: + """Whether the edge relation is symmetric — decidable here, since it is + a first-order question over an automatic structure. An undirected graph + must satisfy it.""" + return self.presentation.check( + f"all x.(all y.({self.edge}(x,y) -> {self.edge}(y,x)))") + + # -- thin passthroughs to the presentation ------------------------ + def check(self, phi) -> bool: + """Truth of a formula over the graph (free variables existential).""" + return self.presentation.check(phi) + + def evaluate(self, phi): + """The relation of satisfying assignments of a formula.""" + return self.presentation.evaluate(phi) + + def get_relation_symbols(self): + """All relation symbols of the graph ('U' is the domain).""" + return self.presentation.get_relation_symbols() + + def __repr__(self): + kind = 'directed' if self.directed else 'undirected' + return (f"") diff --git a/autstr/symbolic/__init__.py b/autstr/symbolic/__init__.py index c7b6cdd..6e32bc8 100644 --- a/autstr/symbolic/__init__.py +++ b/autstr/symbolic/__init__.py @@ -29,7 +29,7 @@ from autstr.symbolic.expr import Formula, Term, Var from autstr.symbolic.signature import ( ElementCodec, EQUALITY_SYMBOL, Function, FunctionCodec, Signature, - operation_signature, + graph_signature, operation_signature, ) # `Function` is importable from here but deliberately absent from __all__: @@ -41,5 +41,6 @@ 'EQUALITY_SYMBOL', 'Formula', 'FunctionCodec', 'FunctionSymbol', 'Relation', 'RelationSymbol', 'Signature', 'StructureBackend', 'SymbolicContext', - 'SymbolicSymbolError', 'Term', 'Var', 'operation_signature', + 'SymbolicSymbolError', 'Term', 'Var', 'graph_signature', + 'operation_signature', ] diff --git a/autstr/symbolic/signature.py b/autstr/symbolic/signature.py index 7ea04e8..7884231 100644 --- a/autstr/symbolic/signature.py +++ b/autstr/symbolic/signature.py @@ -75,6 +75,29 @@ def operation_signature(relations, graph: str, operator: str, return signature +def graph_signature(relations, edge: str = 'E', adjacency: str = 'adj', + equality: str = EQUALITY_SYMBOL, codec=None) -> 'Signature': + """The signature of a graph: the binary edge relation `edge` bound to the + method ``.{adjacency}(y)`` (default ``.adj``), plus equality when the + structure declares it. + + A graph carries no operation, so nothing binds to ``+`` or ``*``; adjacency + is a relation method, exactly like ``.lt`` in the arithmetic signature. + The edge name is passed in, never guessed — ``E`` means the edge here but + equality elsewhere, the same hazard `operation_signature` guards against. + + :param relations: the structure's relation symbols. + :param edge: the binary relation read as adjacency. + :param adjacency: the method name it binds to. + :param codec: optional element codec for writing vertices as constants. + """ + signature = Signature(codec=codec) + signature.operator(adjacency, edge) + if equality in relations: + signature.operator('eq', equality) + return signature + + class ElementCodec: """Translation between Python values and element encodings. diff --git a/tests/test_infinite_graphs.py b/tests/test_infinite_graphs.py new file mode 100644 index 0000000..1233921 --- /dev/null +++ b/tests/test_infinite_graphs.py @@ -0,0 +1,81 @@ +"""The generic InfiniteGraph wrapper. + +These tests exercise the wrapper's mechanics against a real presentation +(Büchi arithmetic over ℤ) rather than a bespoke automaton: the edge relation is +defined by a formula, so no automata are authored here. The concrete graph +factories — ordinals, integer grids, … — arrive in later commits, each with its +own ground-truth oracle. +""" +import pytest + +from autstr.arithmetic import decode, encode +from autstr.buildin.presentations import BuechiArithmeticZ +from autstr.infinite_graphs import InfiniteGraph +from autstr.symbolic import FunctionCodec + + +@pytest.fixture(scope="module") +def complete_graph(): + """The complete graph on ℤ: x—y iff x ≠ y. Symmetric, and definable + straight from the order, so it needs no hand-built automaton. Vertices are + integers via Büchi's own codec, so they can be written as Python values.""" + z = BuechiArithmeticZ() + z.update(Adj='Lt(x,y) | Lt(y,x)') + return InfiniteGraph(z, edge='Adj', codec=FunctionCodec(encode, decode)) + + +class TestConstruction: + def test_missing_edge_relation_is_rejected(self): + z = BuechiArithmeticZ() + with pytest.raises(ValueError, match="no edge relation 'Adj'"): + InfiniteGraph(z, edge='Adj') + + def test_non_binary_edge_is_rejected(self): + z = BuechiArithmeticZ() # A is the ternary addition graph + with pytest.raises(ValueError, match="arity 3, not 2"): + InfiniteGraph(z, edge='A') + + def test_repr_names_the_edge(self, complete_graph): + assert "edge='Adj'" in repr(complete_graph) + + +class TestSymbolicSurface: + def test_adjacency_binds_to_the_edge(self, complete_graph): + G = complete_graph.symbolic() + x, y = G.vars('x y') + relation = x.adj(y).evaluate() + assert relation.contains(x=3, y=5) + assert relation.contains(x=5, y=3) + assert not relation.contains(x=4, y=4) # no self-loop + + def test_equality_is_bound(self, complete_graph): + G = complete_graph.symbolic() + x, y = G.vars('x y') + relation = x.eq(y).evaluate() + assert relation.contains(x=7, y=7) + assert not relation.contains(x=7, y=8) + + def test_a_first_order_sentence(self, complete_graph): + G = complete_graph.symbolic() + x, y = G.vars('x y') + # every vertex has a neighbour (the complete graph on ℤ is infinite) + assert x.adj(y).drop('y').all('x').check() + # no vertex is adjacent to itself + assert not x.adj(x).drop('x').check() + + +class TestSymmetry: + def test_undirected_edge_is_symmetric(self, complete_graph): + assert complete_graph.is_symmetric() + + def test_a_directed_edge_is_not(self): + z = BuechiArithmeticZ() + order = InfiniteGraph(z, edge='Lt', directed=True) + assert not order.is_symmetric() + + +class TestPassthrough: + def test_check_and_evaluate_reach_the_presentation(self, complete_graph): + assert complete_graph.check('exists x.(exists y.(Adj(x,y)))') + assert not complete_graph.check('exists x.(Adj(x,x))') + assert 'Adj' in complete_graph.get_relation_symbols() From 2fddb2090b8a05eeb11b88b3506a5f58a6339cef Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Tue, 11 Aug 2026 22:42:09 +0200 Subject: [PATCH 21/76] IntegerGrid: the Cayley graph of Z^n as an async product The first genuine infinite graph on the new wrapper. Vertices are points of Z^n; two are adjacent iff they differ by +/-1 in exactly one coordinate -- which is precisely the *asynchronous* product of n integer paths (move one coordinate, hold the rest). So the grid is built by folding `composition.direct_product(..., kind='async')` over n copies of a minimal integer path, not by authoring an automaton. * The path is a minimal {U, E, Eq} presentation cut from Buechi arithmetic; its +/-1 edge is the FO formula `exists o. One(o) and (x+o=y or y+o=x)`, where One is the least power of two. (This edge is, in miniature, a one-dimensional FO-interpretation of the path in (Z, +) -- the pattern a first-class `interpret` operation would package.) * Vertices are Python n-tuples through a codec that handles the left-nested pair encoding the fold produces; round-trips tested over [-3,3]^n. * Async product of Eq comes out as full equality, so the grid keeps a correct Eq for free. FO is decidable (it is automatic); MSO is not (the grid interprets halting) -- the tidiest FO-vs-MSO contrast. Tested against the ground truth sum(|delta_i|) == 1 across n = 1, 2, 3, plus symmetry, no self-loops, codec round-trips, and Z^1 being 2-regular as a first-order counting sentence. Co-Authored-By: Claude Opus 4.8 --- autstr/infinite_graphs.py | 107 ++++++++++++++++++++++++++++++++++++- tests/test_integer_grid.py | 79 +++++++++++++++++++++++++++ 2 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 tests/test_integer_grid.py diff --git a/autstr/infinite_graphs.py b/autstr/infinite_graphs.py index 9f53075..95e79a0 100644 --- a/autstr/infinite_graphs.py +++ b/autstr/infinite_graphs.py @@ -14,9 +14,9 @@ """ from __future__ import annotations -from typing import Optional +from typing import Optional, Sequence, Tuple -from autstr.symbolic import graph_signature +from autstr.symbolic import FunctionCodec, graph_signature class InfiniteGraph: @@ -88,3 +88,106 @@ def __repr__(self): kind = 'directed' if self.directed else 'undirected' return (f"") + + +# ---------------------------------------------------------------------- +# Concrete factory: the integer grid +# ---------------------------------------------------------------------- + +#: least-power-of-two, i.e. the constant 1, over Büchi arithmetic +_ONE = 'Pt(o) & (all p.(Pt(p) -> (not Lt(p,o))))' +_PAD = '*' + + +class IntegerGrid: + """The n-dimensional integer grid — the Cayley graph of ℤⁿ with the + standard generators. + + Vertices are points of ℤⁿ; two are adjacent iff they differ by ±1 in + exactly one coordinate. That is precisely the *asynchronous* product of n + copies of the two-way integer path (move one coordinate, hold the rest), so + the grid is built by folding `autstr.composition.direct_product` over n + integer paths rather than by authoring an automaton. + + FO is decidable — it is an automatic structure. MSO is *not*: the grid + interprets the halting problem, the tidiest illustration that FO and MSO + are different questions. + + :param n: the dimension (n ≥ 1). ``IntegerGrid(1)`` is the two-way path. + """ + + def __init__(self, n: int = 2) -> None: + if n < 1: + raise ValueError("dimension must be >= 1") + self.n = n + path = self._path() # one Büchi build, reused + presentation = path + for _ in range(n - 1): + presentation = _async_product(presentation, path) + self.presentation = presentation + self.graph = InfiniteGraph( + presentation, edge='E', + codec=FunctionCodec(self.encode, self.decode)) + + @staticmethod + def _path(): + """The two-way integer path as a minimal presentation carrying only + the domain, the ±1 edge, and equality — so the product folds those and + nothing else.""" + from autstr.buildin.presentations import BuechiArithmeticZ + from autstr.presentations import AutomaticPresentation + z = BuechiArithmeticZ() + z.update(E=f'exists o.(({_ONE}) & (A(x,o,y) | A(y,o,x)))') + return AutomaticPresentation( + {'U': z.automata['U'], 'E': z.automata['E'], 'Eq': z.automata['Eq']}, + padding_symbol=z.padding_symbol) + + # -- element codec: an n-tuple <-> its nested-pair convolution ------ + def encode(self, point: Sequence[int]) -> list: + from autstr.arithmetic import encode as encode_int + point = tuple(point) + if len(point) != self.n: + raise ValueError(f"expected a {self.n}-tuple, got {point!r}") + word = encode_int(point[0]) + pad = _PAD + for coordinate in point[1:]: + other = encode_int(coordinate) + length = max(len(word), len(other)) + word = [(word[k] if k < len(word) else pad, + other[k] if k < len(other) else _PAD) + for k in range(length)] + pad = (pad, _PAD) + return word + + def decode(self, word) -> Tuple[int, ...]: + from autstr.arithmetic import decode as decode_int + coordinates = [] + current = list(word) + for _ in range(self.n - 1): + coordinates.append(decode_int([letter[1] for letter in current])) + current = [letter[0] for letter in current] + coordinates.append(decode_int(current)) + return tuple(reversed(coordinates)) + + # -- interface ----------------------------------------------------- + def symbolic(self, signature=None): + """A symbolic interface to the grid; write adjacency as ``x.adj(y)`` + and vertices as Python n-tuples.""" + return self.graph.symbolic(signature) + + def is_symmetric(self) -> bool: + return self.graph.is_symmetric() + + def check(self, phi) -> bool: + return self.graph.check(phi) + + def evaluate(self, phi): + return self.graph.evaluate(phi) + + def __repr__(self): + return f"" + + +def _async_product(left, right): + from autstr.composition import direct_product + return direct_product(left, right, kind='async') diff --git a/tests/test_integer_grid.py b/tests/test_integer_grid.py new file mode 100644 index 0000000..638d874 --- /dev/null +++ b/tests/test_integer_grid.py @@ -0,0 +1,79 @@ +"""The integer grid ℤⁿ (Cayley graph of ℤⁿ) as an automatic structure. + +Adjacency is checked against the ground truth ``sum(|Δ_i|) == 1`` — differ by +±1 in exactly one coordinate — which is what the asynchronous product of n +integer paths should compute. +""" +import itertools + +import pytest + +from autstr.infinite_graphs import IntegerGrid + + +def adjacent(p, q): + """Ground truth: two points of ℤⁿ are grid-adjacent iff they differ by ±1 + in exactly one coordinate.""" + return sum(abs(a - b) for a, b in zip(p, q)) == 1 + + +@pytest.fixture(scope="module") +def grids(): + return {n: IntegerGrid(n) for n in (1, 2, 3)} + + +class TestCodec: + def test_roundtrips(self, grids): + for n, grid in grids.items(): + for point in itertools.product(range(-3, 4), repeat=n): + assert grid.decode(grid.encode(point)) == point, (n, point) + + def test_wrong_dimension_is_rejected(self, grids): + with pytest.raises(ValueError, match="2-tuple"): + grids[2].encode((1, 2, 3)) + + +class TestAdjacency: + def test_matches_ground_truth(self, grids): + for n, grid in grids.items(): + relation = grid.symbolic() + x, y = relation.vars('x y') + edge = x.adj(y).evaluate() + points = list(itertools.product(range(-2, 3), repeat=n)) + # a representative sample of pairs, both directions + for p in points[::3]: + for q in points: + assert edge.contains(x=p, y=q) == adjacent(p, q), (n, p, q) + + def test_symmetric(self, grids): + for grid in grids.values(): + assert grid.is_symmetric() + + def test_no_self_loops(self, grids): + for grid in grids.values(): + G = grid.symbolic() + x = G.vars('x')[0] + assert not x.adj(x).drop('x').check() + + +class TestFirstOrder: + def test_the_path_is_2_regular(self): + """Every vertex of ℤ¹ has exactly two neighbours — a first-order + statement, counting via equality: ∃y∃z distinct, both adjacent, and + every neighbour is one of them.""" + G = IntegerGrid(1).symbolic() + x, y, z, w = G.vars('x y z w') + exactly_two = ( + ~y.eq(z) & x.adj(y) & x.adj(z) + & x.adj(w).implies(w.eq(y) | w.eq(z)).all('w') + ).drop('y z').all('x') + assert exactly_two.check() + + def test_dimension_one_is_the_path(self, grids): + grid = grids[1] + edge = grid.symbolic() + x, y = edge.vars('x y') + rel = x.adj(y).evaluate() + assert rel.contains(x=(5,), y=(6,)) + assert rel.contains(x=(5,), y=(4,)) + assert not rel.contains(x=(5,), y=(7,)) From 71b6ba2397bc3f9611d5444b875f0b3aaffdce58 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Tue, 11 Aug 2026 23:01:41 +0200 Subject: [PATCH 22/76] First-order interpretations, Phase 1: one-dimensional, no quotient Automatic structures are closed under FO-interpretations, and AutStr can *compute* the interpreted presentation because `evaluate(phi)` already returns the automaton of an FO formula's satisfying assignments -- so an interpretation is orchestration, not a new engine. It is the FO counterpart of the set / MSO interpretations that build the Caucal hierarchy, and the operation many structures (the grid, config graphs) are really instances of. `interpret(source, domain, relations)`: the new universe is the source elements satisfying the domain formula; each relation is an FO formula over the source's signature, permuted to its declared argument order. ~30 lines. Efficiency was the gate, and it is met for this phase: because elements keep the source's own encoding, the interpreted automata are the canonical minimal DFAs of the formulas. Measured -- interpreting the path in (Z, +) reproduces the hand-built grid path's automaton exactly, 15 states, and the test pins that equality. The k-dimensional case (elements as k-tuples) is deliberately not here: measurement showed it costs no extra states (minimization collapses the sparse-domain redundancy) but a bounded x-k symbol-width overhead, which wants an optional alphabet-pruning pass -- a separate, measured Phase 2. Tested: reconstructs the grid path (same canonical automaton), domain restriction (the even integers with induced order), argument-order permutation, and the validation guards. Co-Authored-By: Claude Opus 4.8 --- autstr/interpretations.py | 89 ++++++++++++++++++++++++++++++++ tests/test_interpretations.py | 97 +++++++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 autstr/interpretations.py create mode 100644 tests/test_interpretations.py diff --git a/autstr/interpretations.py b/autstr/interpretations.py new file mode 100644 index 0000000..afe937e --- /dev/null +++ b/autstr/interpretations.py @@ -0,0 +1,89 @@ +"""First-order interpretations of automatic structures. + +Automatic structures are closed under first-order interpretations, and AutStr +can *compute* the interpreted presentation: `presentation.evaluate(φ)` already +returns the automaton of an FO formula's satisfying assignments, so an +interpretation is orchestration, not a new engine. It is the FO counterpart of +the set / MSO interpretations that build the Caucal hierarchy. + +**This module is Phase 1: one-dimensional, no quotient.** An element of the +interpreted structure is a single element of the source satisfying the domain +formula; each relation is defined by an FO formula over the source's signature. +Because elements keep the source's own encoding, the interpreted automata are +the canonical minimal DFAs of those formulas — bit-for-bit what a hand-built +presentation of the same relations would produce, with no encoding overhead. + +Higher-dimensional interpretations (elements as k-tuples, encoded as k-tape +convolutions) and quotient interpretations are deliberately not here yet: the +product encoding can be wasteful when the domain realizes only a sparse subset +of the tuples, so they want an alphabet-pruning pass and a measurement against +a bespoke encoding first. +""" +from __future__ import annotations + +from typing import Dict, Optional, Sequence, Tuple, Union + +from nltk.sem import logic + +from autstr.presentations import AutomaticPresentation +from autstr.sparse_automata import SparseDFA +from autstr.utils.automata_tools import permute_tapes +from autstr.utils.logic import get_free_elementary_vars + +Formula = Union[str, logic.Expression] +#: a relation is a formula plus the order its free variables play as arguments; +#: the order may be omitted when the sorted free variables already match +RelationSpec = Union[Formula, Tuple[Formula, Sequence[str]]] + + +def interpret(source: AutomaticPresentation, domain: Formula, + relations: Dict[str, RelationSpec]) -> AutomaticPresentation: + """The one-dimensional first-order interpretation of a structure in + `source`. + + :param source: the structure to interpret in. + :param domain: an FO formula δ(x) with exactly one free variable; the new + universe is the source elements satisfying it. + :param relations: ``{name: spec}`` where ``spec`` is a formula, or a + ``(formula, argument-order)`` pair. The formula's free variables are + the relation's arguments; give the order explicitly whenever their + sorted order is not the intended argument order. + :return: a fresh `AutomaticPresentation` over the source's alphabet, whose + universe and relations are the (minimized) automata of those formulas. + """ + universe = _evaluate(source, domain) + if universe.symbol_arity != 1: + raise ValueError( + f"the domain formula must have exactly one free variable, " + f"got arity {universe.symbol_arity}") + + automata: Dict[str, SparseDFA] = {'U': universe} + for name, spec in relations.items(): + if name == 'U': + raise ValueError("'U' is the reserved universe symbol") + formula, order = spec if isinstance(spec, tuple) else (spec, None) + automata[name] = _evaluate(source, formula, order) + + return AutomaticPresentation(automata, padding_symbol=source.padding_symbol) + + +def _evaluate(source: AutomaticPresentation, formula: Formula, + order: Optional[Sequence[str]] = None) -> SparseDFA: + """The automaton of `formula` over `source`, with its tapes put into + `order` when one is given.""" + if isinstance(formula, str): + formula = logic.Expression.fromstring(formula) + dfa = source.evaluate(formula) + + free = get_free_elementary_vars(formula) # the tape order evaluate used + if order is None: + return dfa + order = list(order) + if sorted(order) != sorted(free): + raise ValueError( + f"argument order {order} does not match the formula's free " + f"variables {free}") + permutation = [free.index(v) for v in order] + if permutation == list(range(len(order))): + return dfa + return permute_tapes(dfa, permutation) diff --git a/tests/test_interpretations.py b/tests/test_interpretations.py new file mode 100644 index 0000000..75d6793 --- /dev/null +++ b/tests/test_interpretations.py @@ -0,0 +1,97 @@ +"""First-order interpretations (Phase 1: one-dimensional, no quotient).""" +import itertools + +import pytest + +from autstr.arithmetic import decode, encode +from autstr.buildin.presentations import BuechiArithmeticZ +from autstr.infinite_graphs import IntegerGrid, InfiniteGraph +from autstr.interpretations import interpret +from autstr.symbolic import FunctionCodec + +# the ±1 edge on ℤ, as a first-order formula over (ℤ, +) +_ONE = 'Pt(o) & (all p.(Pt(p) -> (not Lt(p,o))))' +_STEP = f'exists o.(({_ONE}) & (A(x,o,y) | A(y,o,x)))' +_TRUE = 'Eq(x,x)' + + +@pytest.fixture(scope="module") +def integer_codec(): + return FunctionCodec(encode, decode) + + +class TestReconstructsTheGrid: + """Interpreting the path in (ℤ, +) must reproduce the hand-built grid path + exactly — same relation, and, because the engine minimizes, the same + canonical automaton. This is the efficiency claim as a test.""" + + def test_same_canonical_automaton_as_the_handbuilt_path(self): + path = interpret(BuechiArithmeticZ(), domain=_TRUE, + relations={'E': (_STEP, ['x', 'y'])}) + handbuilt = IntegerGrid(1).presentation + assert path.automata['E'].num_states == handbuilt.automata['E'].num_states == 15 + + def test_adjacency_semantics(self, integer_codec): + path = interpret(BuechiArithmeticZ(), domain=_TRUE, + relations={'E': (_STEP, ['x', 'y'])}) + graph = InfiniteGraph(path, edge='E', codec=integer_codec) + G = graph.symbolic() + x, y = G.vars('x y') + edge = x.adj(y).evaluate() + assert edge.contains(x=3, y=4) + assert edge.contains(x=4, y=3) + assert not edge.contains(x=3, y=5) + assert graph.is_symmetric() + + +class TestDomainRestriction: + """The domain formula carves out a sub-universe; relations restrict to it.""" + + def test_even_integers_with_the_induced_order(self, integer_codec): + # x is even iff x = y + y for some y + evens = interpret(BuechiArithmeticZ(), + domain='exists y.(A(y,y,x))', + relations={'Lt': ('Lt(x,y)', ['x', 'y'])}) + order = InfiniteGraph(evens, edge='Lt', directed=True, + codec=integer_codec) + G = order.symbolic() + x, y = G.vars('x y') + rel = x.adj(y).evaluate() + assert rel.contains(x=2, y=4) # both even, 2 < 4 + assert not rel.contains(x=4, y=2) + # odd numbers are not in the universe: 3 < 4 is not in the relation + assert not rel.contains(x=3, y=4) + assert not order.is_symmetric() # a strict order + + +class TestArgumentOrder: + def test_order_permutes_the_tapes(self): + # R(a, b) defined by Lt(a, b) but declared with reversed argument order + greater = interpret(BuechiArithmeticZ(), domain=_TRUE, + relations={'R': ('Lt(a,b)', ['b', 'a'])}) + rel = greater.evaluate('R(x,y)') + + def holds(a, b): + conv = [tuple(t) for t in + itertools.zip_longest(encode(a), encode(b), fillvalue='*')] + return rel.accepts(conv) + + # R(x, y) reads as "y < x", i.e. x > y + assert holds(5, 3) + assert not holds(3, 5) + + +class TestValidation: + def test_domain_needs_one_free_variable(self): + with pytest.raises(ValueError, match="one free variable"): + interpret(BuechiArithmeticZ(), domain='Lt(x,y)', relations={}) + + def test_argument_order_must_match_free_variables(self): + with pytest.raises(ValueError, match="does not match"): + interpret(BuechiArithmeticZ(), domain=_TRUE, + relations={'R': ('Lt(a,b)', ['a', 'c'])}) + + def test_universe_symbol_is_reserved(self): + with pytest.raises(ValueError, match="reserved universe"): + interpret(BuechiArithmeticZ(), domain=_TRUE, + relations={'U': 'Eq(x,x)'}) From 7e606f8decdea627a9a0ce85d1f75d6aab00a973 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Tue, 11 Aug 2026 23:31:26 +0200 Subject: [PATCH 23/76] First-order interpretations, Phase 2: k-dimensional Elements of the interpreted structure may now be k-tuples of source elements. Gated on the measurement you asked for: a sparse domain does NOT blow up -- the diagonal {(a,a)} of Z^2, isomorphic to Z, gives a successor relation of the same 10 states as the one-dimensional native encoding, because minimization collapses the redundancy. The only cost is a bounded x-k symbol width, intrinsic to representing tuples. New primitive `NodeStore.fold_tapes` (+ `automata_tools.fold_tapes`): group every k consecutive tapes into one tape over the product alphabet Sigma^k, by cofactor reassembly -- the same shape as `map_letters`, but one new tape consumes k old ones, so its cofactors range over all m**k combinations. This is the piece that turns the many-tape automaton of a k-dimensional formula into a structure whose elements are k-tuples. It is NOT a free regroup: for a non-power-of-two alphabet num_bits(m**k) < k*num_bits(m), so the diagram is genuinely rebuilt over fewer bits. Oracle-tested in isolation first (m=3, k=2..3): 2400 acceptance checks, 0 mismatches. `interpret` gains `dimension=k`. dimension 1 is exactly Phase 1 (the fold is a no-op); for k>1 the formula automata are ordered coordinate-major and folded. Decisive cross-check: a 2-dimensional interpretation of the grid reproduces the composition-built IntegerGrid(2) exactly -- 51 states, same canonical automaton from two independent constructions -- with the sparse-domain case pinned as a regression. Quotient interpretations (elements as classes of a definable equivalence) remain the one deferred tier. Co-Authored-By: Claude Opus 4.8 --- autstr/interpretations.py | 107 +++++++++++++++++++-------------- autstr/mtbdd.py | 49 +++++++++++++++ autstr/utils/automata_tools.py | 28 +++++++++ tests/test_automata_tools.py | 40 +++++++++++- tests/test_interpretations.py | 88 ++++++++++++++++++++++++++- 5 files changed, 266 insertions(+), 46 deletions(-) diff --git a/autstr/interpretations.py b/autstr/interpretations.py index afe937e..0562fbb 100644 --- a/autstr/interpretations.py +++ b/autstr/interpretations.py @@ -6,18 +6,23 @@ interpretation is orchestration, not a new engine. It is the FO counterpart of the set / MSO interpretations that build the Caucal hierarchy. -**This module is Phase 1: one-dimensional, no quotient.** An element of the -interpreted structure is a single element of the source satisfying the domain -formula; each relation is defined by an FO formula over the source's signature. -Because elements keep the source's own encoding, the interpreted automata are -the canonical minimal DFAs of those formulas — bit-for-bit what a hand-built -presentation of the same relations would produce, with no encoding overhead. - -Higher-dimensional interpretations (elements as k-tuples, encoded as k-tape -convolutions) and quotient interpretations are deliberately not here yet: the -product encoding can be wasteful when the domain realizes only a sparse subset -of the tuples, so they want an alphabet-pruning pass and a measurement against -a bespoke encoding first. +An element of the interpreted structure is a **k-tuple** of source elements +satisfying the domain formula; each relation is defined by an FO formula over +the source's signature, with its free variables grouped k-per-argument. The +formula automata are folded — every k consecutive coordinate tapes become one +element tape over the product alphabet — so the result is a standard +presentation whose elements are k-tuples. + +For ``dimension == 1`` (the default) this is just a definable reduct/expansion +with a restricted domain: elements keep the source's encoding, and the +interpreted automata are the canonical minimal DFAs of the formulas — bit-for- +bit what a hand-built presentation would produce. For ``k > 1`` elements are +encoded as k-tape convolutions; measurement shows this adds no states (the +engine minimizes away a sparse domain's redundancy) but a bounded ×k +symbol-width overhead, intrinsic to representing tuples. + +Quotient interpretations (elements as classes of a definable equivalence) are +not here yet. """ from __future__ import annotations @@ -27,63 +32,77 @@ from autstr.presentations import AutomaticPresentation from autstr.sparse_automata import SparseDFA -from autstr.utils.automata_tools import permute_tapes +from autstr.utils.automata_tools import fold_tapes, permute_tapes from autstr.utils.logic import get_free_elementary_vars Formula = Union[str, logic.Expression] -#: a relation is a formula plus the order its free variables play as arguments; -#: the order may be omitted when the sorted free variables already match +#: a relation is a formula, or a ``(formula, coordinate-order)`` pair; the +#: coordinate order lists the free variables element-major (each element's k +#: coordinates together), and may be omitted when the sorted order already is RelationSpec = Union[Formula, Tuple[Formula, Sequence[str]]] -def interpret(source: AutomaticPresentation, domain: Formula, - relations: Dict[str, RelationSpec]) -> AutomaticPresentation: - """The one-dimensional first-order interpretation of a structure in - `source`. +def interpret(source: AutomaticPresentation, domain: RelationSpec, + relations: Dict[str, RelationSpec], + dimension: int = 1) -> AutomaticPresentation: + """The first-order interpretation of a structure in `source`. :param source: the structure to interpret in. - :param domain: an FO formula δ(x) with exactly one free variable; the new - universe is the source elements satisfying it. - :param relations: ``{name: spec}`` where ``spec`` is a formula, or a - ``(formula, argument-order)`` pair. The formula's free variables are - the relation's arguments; give the order explicitly whenever their - sorted order is not the intended argument order. - :return: a fresh `AutomaticPresentation` over the source's alphabet, whose - universe and relations are the (minimized) automata of those formulas. + :param domain: the domain formula δ(x̄) with ``dimension`` free variables — + the coordinates of one element; the new universe is the tuples + satisfying it. A ``(formula, coordinate-order)`` pair fixes which free + variable is which coordinate when the sorted order will not do. + :param relations: ``{name: spec}`` where ``spec`` is a formula or a + ``(formula, coordinate-order)`` pair. A relation of arity r has + ``dimension · r`` free variables; the coordinate order lists them + element-major, so folding groups each argument's coordinates. + :param dimension: k — elements are k-tuples of source elements. + :return: a fresh `AutomaticPresentation`. For k > 1 its alphabet is the + source alphabet's k-fold product. """ - universe = _evaluate(source, domain) + if dimension < 1: + raise ValueError("dimension must be >= 1") + k = dimension + + universe = _fold_formula(source, domain, k) if universe.symbol_arity != 1: raise ValueError( - f"the domain formula must have exactly one free variable, " - f"got arity {universe.symbol_arity}") + f"the domain formula must have {k} free variable(s) — the " + f"coordinates of one element — but defines " + f"{universe.symbol_arity} element(s)") automata: Dict[str, SparseDFA] = {'U': universe} for name, spec in relations.items(): if name == 'U': raise ValueError("'U' is the reserved universe symbol") - formula, order = spec if isinstance(spec, tuple) else (spec, None) - automata[name] = _evaluate(source, formula, order) + automata[name] = _fold_formula(source, spec, k) - return AutomaticPresentation(automata, padding_symbol=source.padding_symbol) + padding = source.padding_symbol if k == 1 \ + else (source.padding_symbol,) * k + return AutomaticPresentation(automata, padding_symbol=padding) -def _evaluate(source: AutomaticPresentation, formula: Formula, - order: Optional[Sequence[str]] = None) -> SparseDFA: - """The automaton of `formula` over `source`, with its tapes put into - `order` when one is given.""" +def _fold_formula(source: AutomaticPresentation, spec: RelationSpec, + k: int) -> SparseDFA: + """Evaluate a formula over `source`, order its tapes coordinate-major, and + fold every k of them into one element tape.""" + formula, order = spec if isinstance(spec, tuple) else (spec, None) if isinstance(formula, str): formula = logic.Expression.fromstring(formula) dfa = source.evaluate(formula) free = get_free_elementary_vars(formula) # the tape order evaluate used - if order is None: - return dfa - order = list(order) + order = free if order is None else list(order) if sorted(order) != sorted(free): raise ValueError( - f"argument order {order} does not match the formula's free " + f"coordinate order {order} does not match the formula's free " f"variables {free}") + if len(order) % k: + raise ValueError( + f"formula has {len(order)} free variables, not a multiple of the " + f"dimension {k}") + permutation = [free.index(v) for v in order] - if permutation == list(range(len(order))): - return dfa - return permute_tapes(dfa, permutation) + if permutation != list(range(len(order))): + dfa = permute_tapes(dfa, permutation) + return fold_tapes(dfa, k) if k > 1 else dfa diff --git a/autstr/mtbdd.py b/autstr/mtbdd.py index 495b099..72d2e5e 100644 --- a/autstr/mtbdd.py +++ b/autstr/mtbdd.py @@ -333,6 +333,55 @@ def old_branch(digit: int) -> int: return rebuild(node, 0) + def fold_tapes(self, node: int, arity: int, m: int, bits: int, + k: int) -> int: + """Group every `k` consecutive tapes into one tape over the product + alphabet Sigma^k. + + The result reads ``arity // k`` tapes whose letters are k-tuples, + ordered lexicographically (mixed-radix, first component most + significant) -- which is the order `encode_symbol` gives a set of + tuples, so the encodings line up. New digit D at element-tape e decodes + to old digit ``(D // m**(k-1-i)) % m`` at old tape ``e*k + i``. + + Diagram surgery mirrors `map_letters`, but one new tape consumes k old + tapes, so its cofactors range over all m**k combinations rather than m. + """ + if arity % k: + raise ValueError("arity must be a multiple of k") + if k == 1: + return node + new_arity = arity // k + new_m = m ** k + new_bits = num_bits(new_m) + cache: Dict[Tuple[int, int], int] = {} + + def cofactor_tape(current: int, old_tape: int, digit: int) -> int: + for j in range(bits): + bit = (digit >> (bits - 1 - j)) & 1 + current = self.cofactor(current, old_tape * bits + j, bit) + return current + + def rebuild(current: int, element: int) -> int: + if element == new_arity: + return current # every old tape consumed + key = (current, element) + cached = cache.get(key) + if cached is not None: + return cached + children = [] + for digit in range(new_m): + branch = current + for i in range(k): + old_digit = (digit // (m ** (k - 1 - i))) % m + branch = cofactor_tape(branch, element * k + i, old_digit) + children.append(rebuild(branch, element + 1)) + result = cache[key] = self.letter(element, children, + new_m, new_bits) + return result + + return rebuild(node, 0) + def recode_letters(self, node: int, arity: int, old_m: int, old_bits: int, new_m: int, new_bits: int, digit_map: Sequence[int], fill: int) -> int: diff --git a/autstr/utils/automata_tools.py b/autstr/utils/automata_tools.py index 31571a6..9f77d31 100644 --- a/autstr/utils/automata_tools.py +++ b/autstr/utils/automata_tools.py @@ -109,6 +109,34 @@ def unpad(dfa: SparseDFA, padding_symbol: int = -1) -> SparseDFA: start_state=dfa.start_state, symbol_arity=arity, base_alphabet=base_alphabet, nodes=dfa.nodes).minimize() +def fold_tapes(dfa: SparseDFA, k: int) -> SparseDFA: + """Group every `k` consecutive tapes of a convolution into one tape over + the product alphabet Sigma^k. + + An automaton reading ``k * r`` tapes over Sigma becomes one reading ``r`` + tapes whose letters are k-tuples -- the fold that turns the many-tape + output of a k-dimensional interpretation into a structure whose elements + are k-tuples. The diagram is unchanged in spirit (no state is added); only + the alphabet is regrouped, and lexicographic tuple order matches + `encode_symbol`, so membership lines up. + """ + import itertools + if dfa.symbol_arity % k: + raise ValueError( + f"arity {dfa.symbol_arity} is not a multiple of k={k}") + store = dfa.store + product_alphabet = set( + itertools.product(sorted(dfa.base_alphabet_frozen), repeat=k)) + nodes = [store.fold_tapes(int(node), dfa.symbol_arity, dfa.m, dfa.bits, k) + for node in dfa.nodes.tolist()] + return SparseDFA( + dfa.num_states, is_accepting=dfa.is_accepting, + start_state=dfa.start_state, + symbol_arity=dfa.symbol_arity // k, + base_alphabet=product_alphabet, + nodes=np.array(nodes, dtype=np.int64)) + + def canonical(dfa: SparseDFA, padding_symbol: int = -1) -> SparseDFA: """Keep only the canonical convolution of each tuple: words in which no position is padding on *every* tape. diff --git a/tests/test_automata_tools.py b/tests/test_automata_tools.py index 5319d5e..d8084b5 100644 --- a/tests/test_automata_tools.py +++ b/tests/test_automata_tools.py @@ -8,10 +8,11 @@ import random import numpy as np +import pytest from autstr.sparse_automata import SparseDFA from autstr.utils.automata_tools import ( - expand, pad, permute_tapes, projection, unpad, + expand, fold_tapes, pad, permute_tapes, projection, unpad, ) @@ -155,3 +156,40 @@ def test_collection_preserves_the_language(self): assert swept.num_states == plain.num_states, (trial, tape) for word in words(m, MAX_LENGTH): assert run(swept, word) == run(plain, word), (trial, tape) + + +class TestFoldTapes: + """Grouping every k tapes into one product-alphabet tape must preserve the + accepted language. Contiguous mixed-radix grouping means a symbol's integer + code is unchanged by the fold, so the folded automaton run on the same + codes must agree with the original — a real test where m is not a power of + two, since then the diagram is genuinely rebuilt over fewer bits.""" + + def test_preserves_the_language(self): + # the alphabet grows as m**(k*r), so sample words rather than enumerate + rng = random.Random(2024) + for _ in range(30): + m = rng.randint(2, 3) # 3 is not a power of two + k = rng.randint(2, 3) + r = rng.randint(1, 2) + arity = k * r + symbols = m ** arity + dfa = random_dfa(rng, rng.randint(1, 4), m, arity) + folded = fold_tapes(dfa, k) + assert folded.symbol_arity == r + # a contiguous mixed-radix regroup leaves the integer code unchanged + for _ in range(60): + word = [rng.randrange(symbols) + for _ in range(rng.randint(0, 5))] + assert run(folded, word) == run(dfa, word), (m, k, r, word) + + def test_k_one_is_a_noop_on_the_language(self): + rng = random.Random(5) + dfa = random_dfa(rng, 3, 3, 2) + assert fold_tapes(dfa, 1).symbol_arity == 2 + + def test_arity_must_be_divisible(self): + rng = random.Random(6) + dfa = random_dfa(rng, 2, 2, 3) + with pytest.raises(ValueError, match="multiple"): + fold_tapes(dfa, 2) diff --git a/tests/test_interpretations.py b/tests/test_interpretations.py index 75d6793..917db5b 100644 --- a/tests/test_interpretations.py +++ b/tests/test_interpretations.py @@ -81,9 +81,81 @@ def holds(a, b): assert not holds(3, 5) +def _step(a, b): + return f'exists o.(({_ONE}) & (A({a},o,{b}) | A({b},o,{a})))' + + +class TestTwoDimensional: + """Elements are pairs of integers. The k-dim interpretation of the grid + must reproduce the composition-built one exactly — same canonical + automaton — a cross-check of two independent constructions.""" + + def test_reproduces_the_product_grid(self): + edge = (f'(({_step("x0","y0")}) & Eq(x1,y1)) ' + f'| (({_step("x1","y1")}) & Eq(x0,y0))') + grid = interpret( + BuechiArithmeticZ(), + domain=('Eq(x0,x0) & Eq(x1,x1)', ['x0', 'x1']), + relations={'E': (edge, ['x0', 'x1', 'y0', 'y1'])}, + dimension=2) + from_product = IntegerGrid(2).presentation + assert grid.automata['E'].num_states == \ + from_product.automata['E'].num_states == 51 + assert grid.check('all x.(all y.(E(x,y) -> E(y,x)))') + + def test_adjacency(self): + edge = (f'(({_step("x0","y0")}) & Eq(x1,y1)) ' + f'| (({_step("x1","y1")}) & Eq(x0,y0))') + grid = interpret( + BuechiArithmeticZ(), + domain=('Eq(x0,x0) & Eq(x1,x1)', ['x0', 'x1']), + relations={'E': (edge, ['x0', 'x1', 'y0', 'y1'])}, + dimension=2) + rel = grid.evaluate('E(x,y)') + + def enc(a, b): + wa, wb = encode(a), encode(b) + n = max(len(wa), len(wb)) + wa += ['*'] * (n - len(wa)); wb += ['*'] * (n - len(wb)) + return list(zip(wa, wb)) + + def adjacent(p, q): + wp, wq = enc(*p), enc(*q) + n = max(len(wp), len(wq)) + wp += [('*', '*')] * (n - len(wp)) + wq += [('*', '*')] * (n - len(wq)) + return rel.accepts(list(zip(wp, wq))) + + assert adjacent((0, 0), (1, 0)) + assert adjacent((2, 3), (2, 4)) + assert not adjacent((0, 0), (1, 1)) + assert not adjacent((0, 0), (0, 0)) + + +class TestSparseDomainDoesNotBlowUp: + """The user's concern, as a regression: the diagonal {(a, a)} ≅ ℤ realizes + only a sparse subset of the pairs, yet minimization keeps the successor + relation the same size as the one-dimensional native encoding.""" + + def test_diagonal_matches_native_state_count(self): + succ = f'exists o.(({_ONE}) & A(x0,o,y0))' # y0 = x0 + 1 + diagonal = interpret( + BuechiArithmeticZ(), + domain=('Eq(x0,x1)', ['x0', 'x1']), + relations={'S': (f'Eq(x0,x1) & Eq(y0,y1) & ({succ})', + ['x0', 'x1', 'y0', 'y1'])}, + dimension=2) + native = interpret(BuechiArithmeticZ(), domain=_TRUE, + relations={'S': (succ.replace('x0', 'x').replace( + 'y0', 'y'), ['x', 'y'])}) + assert diagonal.automata['S'].num_states == \ + native.automata['S'].num_states + + class TestValidation: def test_domain_needs_one_free_variable(self): - with pytest.raises(ValueError, match="one free variable"): + # dimension 1 (default), so a two-free-variable domain defines 2 elements + with pytest.raises(ValueError, match="defines 2 element"): interpret(BuechiArithmeticZ(), domain='Lt(x,y)', relations={}) def test_argument_order_must_match_free_variables(self): @@ -95,3 +167,17 @@ def test_universe_symbol_is_reserved(self): with pytest.raises(ValueError, match="reserved universe"): interpret(BuechiArithmeticZ(), domain=_TRUE, relations={'U': 'Eq(x,x)'}) + + def test_domain_dimension_must_match(self): + # dimension 2 but the domain has only one free variable + with pytest.raises(ValueError, match="free variable"): + interpret(BuechiArithmeticZ(), domain=_TRUE, relations={}, + dimension=2) + + def test_free_variables_must_be_a_multiple_of_the_dimension(self): + # dimension 2 but the relation has three free variables + with pytest.raises(ValueError, match="multiple of the dimension"): + interpret(BuechiArithmeticZ(), + domain=('Eq(x0,x0) & Eq(x1,x1)', ['x0', 'x1']), + relations={'R': ('A(x0,x1,x2)', ['x0', 'x1', 'x2'])}, + dimension=2) From 3564126337da3de0709cfa56ab740a9c989297e7 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 12 Aug 2026 10:30:48 +0200 Subject: [PATCH 24/76] Fix all/none sentence markers dropping the structure's alphabet `_build_automaton` collapses a sentence to an all-accepting or all-rejecting one-state automaton, but built it with `one()` / `zero()` and no alphabet, so the marker carried the size-1 default. An enclosing connective then intersects it with the domain, and the product fails with "requires same base alphabet" on any structure whose alphabet is larger than the default -- every product-alphabet presentation. Latent until now because such presentations are new: `IntegerGrid(2).check('not (exists x. E(x,x))')` triggers it, while the equivalent `all x.(not E(x,x))` takes a different branch and worked. Threading `self.sigma` into the markers fixes it. Found while building quotient interpretations. Co-Authored-By: Claude Opus 4.8 --- autstr/presentations.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/autstr/presentations.py b/autstr/presentations.py index e5e4189..fd180e2 100644 --- a/autstr/presentations.py +++ b/autstr/presentations.py @@ -363,7 +363,12 @@ def _build_automaton(self, phi: logic.Expression, verbose=False, init=True) -> S domain = self._domain_product(len(free_vars) - 1) result = projection(dfa_rec, pos).minimize().complement().minimize().intersection(domain).minimize() else: - result = one() if dfa_rec.is_empty() else zero() + # A sentence collapses to the all/none marker, which must carry + # this structure's alphabet: an enclosing connective intersects + # it with the domain, and a size-1 default would mismatch any + # product-alphabet presentation. + result = one(base_alphabet=self.sigma) if dfa_rec.is_empty() \ + else zero(base_alphabet=self.sigma) if verbose: print(f'{str(phi)}: {result.num_states} states') @@ -382,7 +387,8 @@ def _build_automaton(self, phi: logic.Expression, verbose=False, init=True) -> S else: if verbose: print(f'{str(phi)}: 1 state') - return zero() if dfa_rec.is_empty() else one() + return zero(base_alphabet=self.sigma) if dfa_rec.is_empty() \ + else one(base_alphabet=self.sigma) else: result = dfa_rec From 6163be337bd8f8eb6675b056a5926d7fbeb9a98e Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 12 Aug 2026 10:30:48 +0200 Subject: [PATCH 25/76] Quotient interpretations at dimension 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `interpret(..., quotient=ε)`: elements become the classes of a definable equivalence. Built in two stages -- the plain interpretation carrying ε as a relation, then a one-dimensional interpretation restricting the universe to the shortlex-least representative of each class, "x is canonical iff x <= every y equivalent to it". So the whole thing stays first-order over the engine. New `automata_tools.shortlex_order`: the binary automaton for x <= y in shortlex order (length first, then lexicographic, trailing padding ignored) -- a well-order, so every class has a unique least element. Length is the primary key, so a lexicographic verdict on the common prefix must stay overridable by a later length difference; that needs five states, not three. Oracle-tested (7500 checks) and used to pick representatives. Verified: the quotient of Z by "x - y even" has exactly two classes. k-dimensional quotients are refused for now (a clear NotImplementedError): the representative predicate is a nested-quantifier sentence over folded relations, and folded (k>1) automata currently misbehave under the diagonal/complement those sentences use -- the domain and relations come out right, but the sentences do not. That fold bug is the next thing to chase; membership and simple queries on k-dim interpretations are unaffected. Co-Authored-By: Claude Opus 4.8 --- autstr/interpretations.py | 64 ++++++++++++++++++++++++++++++++-- autstr/utils/automata_tools.py | 55 +++++++++++++++++++++++++++++ tests/test_automata_tools.py | 41 +++++++++++++++++++++- tests/test_interpretations.py | 39 +++++++++++++++++++++ 4 files changed, 195 insertions(+), 4 deletions(-) diff --git a/autstr/interpretations.py b/autstr/interpretations.py index 0562fbb..bb0d508 100644 --- a/autstr/interpretations.py +++ b/autstr/interpretations.py @@ -22,7 +22,10 @@ symbol-width overhead, intrinsic to representing tuples. Quotient interpretations (elements as classes of a definable equivalence) are -not here yet. +supported at dimension 1: pass ``quotient=ε``, and the universe is restricted +to the shortlex-least representative of each class. k-dimensional quotients are +not enabled yet — they rest on nested-quantifier sentences over folded +automata, which currently misbehave under the diagonal/complement operations. """ from __future__ import annotations @@ -32,9 +35,16 @@ from autstr.presentations import AutomaticPresentation from autstr.sparse_automata import SparseDFA -from autstr.utils.automata_tools import fold_tapes, permute_tapes +from autstr.utils.automata_tools import ( + fold_tapes, permute_tapes, shortlex_order, +) from autstr.utils.logic import get_free_elementary_vars +#: scaffolding relations a quotient interpretation installs on the intermediate +#: structure and drops from the result +_EQUIV = '_Equiv' +_LE = '_Le' + Formula = Union[str, logic.Expression] #: a relation is a formula, or a ``(formula, coordinate-order)`` pair; the #: coordinate order lists the free variables element-major (each element's k @@ -44,7 +54,8 @@ def interpret(source: AutomaticPresentation, domain: RelationSpec, relations: Dict[str, RelationSpec], - dimension: int = 1) -> AutomaticPresentation: + dimension: int = 1, + quotient: Optional[RelationSpec] = None) -> AutomaticPresentation: """The first-order interpretation of a structure in `source`. :param source: the structure to interpret in. @@ -57,11 +68,19 @@ def interpret(source: AutomaticPresentation, domain: RelationSpec, ``dimension · r`` free variables; the coordinate order lists them element-major, so folding groups each argument's coordinates. :param dimension: k — elements are k-tuples of source elements. + :param quotient: an equivalence formula ε(x̄, ȳ) (a binary relation over + elements, so ``2 · dimension`` free variables). When given, elements + are its equivalence classes: the universe is restricted to the + shortlex-least representative of each class, and the relations are read + on those representatives. The caller must ensure ε really is an + equivalence and that every relation is ε-invariant. :return: a fresh `AutomaticPresentation`. For k > 1 its alphabet is the source alphabet's k-fold product. """ if dimension < 1: raise ValueError("dimension must be >= 1") + if quotient is not None: + return _quotient(source, domain, relations, dimension, quotient) k = dimension universe = _fold_formula(source, domain, k) @@ -82,6 +101,45 @@ def interpret(source: AutomaticPresentation, domain: RelationSpec, return AutomaticPresentation(automata, padding_symbol=padding) +def _quotient(source: AutomaticPresentation, domain: RelationSpec, + relations: Dict[str, RelationSpec], dimension: int, + equivalence: RelationSpec) -> AutomaticPresentation: + """Interpret with a quotient, in two stages: first the plain (k-dim) + interpretation carrying the equivalence as a relation, then a + one-dimensional interpretation restricting the universe to the + shortlex-least representative of each class. + + The representative predicate is first-order over the equivalence and a + shortlex well-order on the element encoding — ``x`` is canonical iff it is + ``<=`` every element equivalent to it — so the whole thing stays inside the + engine. + """ + if _EQUIV in relations or _LE in relations: + raise ValueError(f"{_EQUIV!r} and {_LE!r} are reserved for the " + f"quotient construction") + if dimension > 1: + # The representative predicate is a nested-quantifier sentence over the + # folded relations, and folded (k>1) automata currently misbehave under + # the diagonal/complement operations those sentences use -- so the + # result would be silently wrong. One-dimensional quotients are exact. + raise NotImplementedError( + "quotient interpretations are only available at dimension 1 for " + "now; k-dimensional quotients await a fix to how folded automata " + "compose under quantifiers") + + raw = interpret(source, domain, {**relations, _EQUIV: equivalence}, + dimension) + raw.update(**{_LE: shortlex_order(raw.sigma, raw.padding_symbol)}) + + representative = f'all y.({_EQUIV}(x,y) -> {_LE}(x,y))' + specs: Dict[str, RelationSpec] = {} + for name in relations: + arity = raw.relation(name).symbol_arity + args = [f'v{i}' for i in range(arity)] + specs[name] = (f'{name}({",".join(args)})', args) + return interpret(raw, (representative, ['x']), specs) + + def _fold_formula(source: AutomaticPresentation, spec: RelationSpec, k: int) -> SparseDFA: """Evaluate a formula over `source`, order its tapes coordinate-major, and diff --git a/autstr/utils/automata_tools.py b/autstr/utils/automata_tools.py index 9f77d31..8711a78 100644 --- a/autstr/utils/automata_tools.py +++ b/autstr/utils/automata_tools.py @@ -109,6 +109,61 @@ def unpad(dfa: SparseDFA, padding_symbol: int = -1) -> SparseDFA: start_state=dfa.start_state, symbol_arity=arity, base_alphabet=base_alphabet, nodes=dfa.nodes).minimize() +def shortlex_order(base_alphabet, padding_symbol) -> SparseDFA: + """The binary automaton for ``x <= y`` in shortlex order: shorter words + first, ties broken by the alphabet's own order, trailing padding ignored. + + Shortlex is a well-order, so every non-empty set of elements has a unique + least member -- which is exactly what picks the canonical representative of + an equivalence class for a quotient interpretation. + """ + letters = sorted(base_alphabet) + frozen = frozenset(base_alphabet) + pad = padding_symbol + # Length is the primary key, so a lexicographic verdict on the common + # prefix stays *provisional* -- a later length difference (one word padding + # while the other runs on) overrides it. Hence five states, not three: + # the two provisional verdicts are distinct from the two final ones. + EQ, XLESS, XGREATER, ACCEPT, REJECT = range(5) + default = [EQ, XLESS, XGREATER, ACCEPT, REJECT] + + def target(state, a, b): + if state in (ACCEPT, REJECT): + return state + if a == pad and b == pad: # both ended: verdict stands + return state + if a == pad: # x shorter -> x < y, final + return ACCEPT + if b == pad: # x longer -> x > y, final + return REJECT + if state != EQ: # lex already decided + return state + return XLESS if a < b else XGREATER if a > b else EQ + + symbols = [[] for _ in range(5)] + targets = [[] for _ in range(5)] + for state in range(5): + for a in letters: + for b in letters: + t = target(state, a, b) + if t != default[state]: + symbols[state].append(encode_symbol((a, b), frozen)) + targets[state].append(t) + + width = max(len(row) for row in symbols) + def pad_row(row): + return row + [-1] * (width - len(row)) + return SparseDFA( + num_states=5, + default_states=np.array(default, dtype=np.int64), + exception_symbols=np.array([pad_row(r) for r in symbols], + dtype=np.int64), + exception_states=np.array([pad_row(r) for r in targets], + dtype=np.int64), + is_accepting=[True, True, False, True, False], start_state=EQ, + symbol_arity=2, base_alphabet=set(base_alphabet)) + + def fold_tapes(dfa: SparseDFA, k: int) -> SparseDFA: """Group every `k` consecutive tapes of a convolution into one tape over the product alphabet Sigma^k. diff --git a/tests/test_automata_tools.py b/tests/test_automata_tools.py index d8084b5..e0b5ee0 100644 --- a/tests/test_automata_tools.py +++ b/tests/test_automata_tools.py @@ -12,7 +12,7 @@ from autstr.sparse_automata import SparseDFA from autstr.utils.automata_tools import ( - expand, fold_tapes, pad, permute_tapes, projection, unpad, + expand, fold_tapes, pad, permute_tapes, projection, shortlex_order, unpad, ) @@ -193,3 +193,42 @@ def test_arity_must_be_divisible(self): dfa = random_dfa(rng, 2, 2, 3) with pytest.raises(ValueError, match="multiple"): fold_tapes(dfa, 2) + + +class TestShortlexOrder: + """x <= y in shortlex order: shorter words first, ties by the alphabet's + order, trailing padding ignored. Length is the primary key, so a + lexicographic verdict must stay overridable by a later length difference.""" + + def test_matches_the_reference(self): + PAD = 0 + def reference(x, y): + sx = list(x) + while sx and sx[-1] == PAD: + sx.pop() + sy = list(y) + while sy and sy[-1] == PAD: + sy.pop() + if len(sx) != len(sy): + return len(sx) < len(sy) + return sx <= sy + + rng = random.Random(0) + for _ in range(25): + m = rng.randint(2, 4) + letters = list(range(1, m + 1)) # 1..m real; 0 is padding + order = shortlex_order(set(letters) | {PAD}, PAD) + for _ in range(200): + x = [rng.choice(letters) for _ in range(rng.randint(0, 5))] + y = [rng.choice(letters) for _ in range(rng.randint(0, 5))] + n = max(len(x), len(y)) + conv = list(zip(x + [PAD] * (n - len(x)), + y + [PAD] * (n - len(y)))) + assert order.accepts(conv) == reference(x, y), (x, y) + + def test_length_dominates_lexicographic(self): + # [b] vs [a, a] with a < b: the shorter [b] must be the smaller, even + # though b > a lexicographically at the first position + order = shortlex_order({0, 1, 2}, 0) # 0 pad, letters 1, 2 + assert order.accepts([(2, 1), (0, 1)]) # [2] < [1,1] (shorter) + assert not order.accepts([(1, 1), (1, 0)]) # [1,1] not <= [1] diff --git a/tests/test_interpretations.py b/tests/test_interpretations.py index 917db5b..e8e07f3 100644 --- a/tests/test_interpretations.py +++ b/tests/test_interpretations.py @@ -152,6 +152,45 @@ def test_diagonal_matches_native_state_count(self): native.automata['S'].num_states +class TestQuotient: + """One-dimensional quotient interpretations: the universe becomes the + shortlex-least representative of each equivalence class.""" + + def test_integers_mod_two(self): + # quotient of Z by "x - y is even" has exactly two classes + even = 'exists u.(exists w.(A(u,u,w) & A(y,w,x)))' # x = y + 2u + Q = interpret(BuechiArithmeticZ(), domain=_TRUE, + relations={'Eq': (even, ['x', 'y'])}, + dimension=1, quotient=(even, ['x', 'y'])) + assert Q.check('exists x.(exists y.(not Eq(x,y)))') # >= 2 + assert not Q.check('exists x.(exists y.(exists z.(' # not >= 3 + '(not Eq(x,y)) & (not Eq(x,z)) & (not Eq(y,z)))))') + + def test_a_trivial_equivalence_keeps_every_element(self): + # x ~ y iff x = y: each class is a singleton, so the quotient is the + # whole structure + Q = interpret(BuechiArithmeticZ(), domain=_TRUE, + relations={'Lt': ('Lt(x,y)', ['x', 'y'])}, + dimension=1, quotient=('Eq(x,y)', ['x', 'y'])) + assert Q.check('exists x.(exists y. Lt(x,y))') + assert Q.check('all x.(exists y. Lt(x,y))') # no maximum, like Z + + def test_k_dimensional_quotient_is_refused_for_now(self): + with pytest.raises(NotImplementedError, match="dimension 1"): + interpret(BuechiArithmeticZ(), + domain=('N0(x0) & N0(x1)', ['x0', 'x1']), + relations={}, + dimension=2, + quotient=('exists s.(A(x0,y1,s) & A(y0,x1,s))', + ['x0', 'x1', 'y0', 'y1'])) + + def test_reserved_scaffolding_names(self): + with pytest.raises(ValueError, match="reserved"): + interpret(BuechiArithmeticZ(), domain=_TRUE, + relations={'_Equiv': 'Eq(x,y)'}, + quotient=('Eq(x,y)', ['x', 'y'])) + + class TestValidation: def test_domain_needs_one_free_variable(self): # dimension 1 (default), so a two-free-variable domain defines 2 elements From e3cb14c5fa0340bc0cb34f256f30f8bc2adefb6d Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 12 Aug 2026 11:31:22 +0200 Subject: [PATCH 26/76] Restrict every tape of a relation to the universe, not all but the last `_prepare_automaton` intersected tapes 0..arity-2 with `U` and left the last one alone, so a unary relation was never restricted at all. The tree engine's counterpart always did every tape; this was an off-by-one. The consequence is not a wrong relation but a wrong *sentence*. Quantifiers restrict a bound variable to `U`, so `all x. phi` asks whether `U` is contained in phi -- and an unrestricted relation disagrees with `U` on encodings that are not elements. In Buechi arithmetic over Z, `N0` accepted the single letter '0', which `U` rejects: interpreting a structure with domain `N0` therefore admitted a non-element into its universe, and `all x. Id(x,x)` came out False for literal identity `Id`. That is what blocked k-dimensional quotient interpretations. `fold_tapes` was never at fault -- it was suspected because k-dim interpretations were the first construct to build a universe out of `evaluate`, which is where the leak shows. The guard is lifted in the next commit. Three relations change: `N0` in Buechi arithmetic over Z, and `In` and `Subset` in MSO0 -- the unary and last-tape cases. The two serialized presentations carrying them are rebuilt, since the loaders deserialize with enforce_consistency=False; `scripts/gen_builtin_presentations.py` is the maintenance script for that. Co-Authored-By: Claude Opus 5 --- autstr/buildin/bin/buechiZ.autstr | Bin 28973 -> 102128 bytes autstr/buildin/bin/mso0.autstr | Bin 21596 -> 70412 bytes autstr/presentations.py | 9 +++-- scripts/gen_builtin_presentations.py | 43 ++++++++++++++++++++++++ tests/test_presentations.py | 47 +++++++++++++++++++++++++++ 5 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 scripts/gen_builtin_presentations.py create mode 100644 tests/test_presentations.py diff --git a/autstr/buildin/bin/buechiZ.autstr b/autstr/buildin/bin/buechiZ.autstr index 0ad44055a246e4079f3bfaa03ddc0718160f23ac..f121de438d4144b13a116945c49a50d90e6330a2 100644 GIT binary patch literal 102128 zcmeI5&2B756~~h|vAjfHOttN?ogGMs4T=zm4N-(3xu8gpKoS-R@d`WzPr`d3@dj*o z0P1wdC*_*+YyWGy+B5cTY3_A(b)C;Tb?Ve-|L|u&|HX$NeDJ}4|Mlzx(Rz&))s|v#-AV=8JE> zefQ;Of6ec{{rWHO{{FYiU*_Z|_t#e!`}?blyIuZ!dv%c?b87mfUAnqRH~Ar5Kji=J zt}cf0zsCam$3!kNI9Qd)I6Nn13jXkr-(25ZUEB=c{mtp?$8=_Cd{^dW9xU&nj-5jwZoKs{Y1RJ>)IItHHj%8jQ)=V{VI!WV>yC=;UG#GQ*Wp=yforlp8g21=&O zvUEN0lDc711LFtyNRRhYMOI z){+}Xnu#>h4pE8Xj{{Ybmk<-Gat3m(16DINsDadjLfQBr{2?l23J`_ZbEc}EYw}lQ ztk>jQuTjxrU_h3kjw^eYL(PcuDZTko@u3Nwh;O!w%&BCjTe(B?+WUJ~ z4!VMK*MqI;hCiE5i(gPDG++oD;>nq~J`-cI3Biwd&&>!9x0&<77OWp~uQ=T1M7q!J z&e<-z5FUXQ()XVdG@c3quAeZ|6!lox6Alt4sF>$_A_PCo6w7-tPaL8ObJi{&5!=_R zZ|~5D45BxkH-irhxwm{6#XX6eN~eI6((OyfWia~uVv^Qp~Q1TObyX2WOo9vimf3nvp-DGd#uS(^VM$y0fDk zlsLJ4w#wUD-UX}#7atwGm)%x$yW?+dk8A7G1*`-YA07Oy>c7&%n~`^Ixq8_{YkR5W zqQ~y_*vjYH`dJ&lE08o?<@fSqz3^1*`QzI7ozRqUHJ+-z)|S@=tOOSy9lV!*)|Pi| z{H_pDfxYxrk#jbB;cxYyYx8p^a=Dnj?5Y8stX=-C z>bX}vF7#t=2CaD3uJ^U^yWms8$-Py3J2!bxrRPcy{@9D3UhUc$s}jBVY;EtSqFaei zk7wI{&s=Y=PqJGtFeRSVb8Go?+b6R&|7+uKZPyh){wVFs%f_vibHi~8Q$mF&`)sx! zakD7VqrHSw9(V8JZbjVDzEo9Dwh@-qfCpeyig?hVSu1!qgvtt0M-lPv10GimEW%$?Igw z#0}YpZ4)jFdKxu(>qV->_uGrB7g&vU7P~4tJu-1>D7Pp%IsIFb{1z>OP$9c}GJ!ow zju2S2dy;%lb&gHkjyi?)VYj}AKh-cv+&g?heJbkuj`VOxdQh3aBef9oc6KaO7cy_x z>xU3%4|Zum2%$=KPii4n?nw$?djub_h(~it1VXHvnPZ*zaf5yj3F(#F@fyen1Mx}`_7YVH)XR%Jj3I-VShMZkcQ>+l90L`WiMCJZ^*4RFk9Cmlmd$wmU<8&!qq6fp=* z9EFPGlC-BN1i1-iG(GQ8HTEr_5RB^?<5IR^3``m1j-*%XEMaVl`nw}#s5BWfg&(Zu z`>aFXdLY%{y`o6OkyeR> zL29zAE2`4d#2wo{QU?4hhAj-3& ztV;|%2Mj$24AKXj6h#+2pk)uN9KQP(qt5}OPt}A6j770}aW-_oP;|gc6LB~?;M7^d zXl1}ylxu4F{o)vyq6RT8#n^4YKy<+NIkX3&0|uf4&V7y-1cjb-rlK$uMV(jfQO%Es5Nsyq#$yolC+5XN5Od<; z3-%_tcnC4$7yx2Ge$9c0M9hDOFPJ)tj*h^lwnHMuZ}FMV;5+yIV8e%YQ8xHNvL^y5rUX&4qu?9SO*Y7pkHg5+vlk(e0i!IV9)n=tYb#SB2=hh4tKEbxRD~qx`iTs4 z__NV}i0O_LfqubUOo$5gaJN(sB<^^tVI=POZM^6O`p5CpdJzQDCN-eq?nw=(I2+lA zsjxqTonz}%AiN2aLHPd1E?*+sW8t}lf!M1EXP3L#J_$;Ry=2x?`# zzAOY$y};;pLkuigWXYye8>)t=62^by>Qo4WjcK^8CiChX?b z+C-(F^4?mOx5yQLlL5v z27W`WCZRKFZfN5WJmjFcLG&~98Kc3qrE!QI3X(A7#eFx_GI8>t+05E8jYH^IK>QTr ziyJep&xefX%=U(Rgyw8LCuAVaEZXNFQ&3;RIF4*6WF%(_#Ks~7pD=t&c%mp_Kuc8G z`_F{&X2Ro1X~h~N$&xMJ(91Ait+9lsjL1e39xF9-o-InYauniB*w-fU|}hp9){E_PLmXBt8S$(aKlY;CVuqS20Nz*9rx z=b1u6N5L!{-wk+PJ!H}JO>S$znayL20z5)bBJFJP=@9astVJyfCHOo@4abl<(h2i( zW|&X-)&7LwY0CnY=Yt-k*_kDJkkvNYhf1Jj_+H9vXVtuQ$f`N(@I}|50QJYV=RTy~ zZ5r72(T5bsaPY(&hDx}uZbSW$cKcL+JB{`^;eG>D+btgoPD(#wZpjX9hBRy$DtvJS z5#$#qmu!$9Qn#x`!WTyOvo<~tF%bYx z=-9k(h{*=%;R{@H!%fAcL1K2i>m#I%5ZK`m?QsDpq=8mHUgi;kjO#by8`bVnFdI3C zn0fakK@bhf=k1}WR6d1+M|#FZACQom1rCi#l%>_6!jCt6#KPdYo@A#g0frjq$>qc| zJPBjMr96)pfrNTObHpP(<8>n;uEb}0Fw&N-BinPR1@$Z5A;%e`3g5fj%wk)%TuCi& zqa4p&*~YCCC<3q3>1_ht@tvEER^O8P2?Z@i4;s)(~;i;QOWsRbF1eKat7f8n!BtT#k0P$_P%RA(cP8 zJ4r>*yByyC)SXbCMGZru-h?)M@gNwt3U3OF6}IVR)62AfX$zR6Sv6d>-Wty9Y&bKA z%}>(;aAV_UVXxD|^5WZh#ele#?99gzas!~{IvGJ{MbKuWQ}LtIJvYj)AOc^vv(BP9h_H?u{E4!4QHDukhNCt>Ry%;_-&ptpZ-A z0yaHwdfp;3j>z8p0_Qdp0hQl35?{KGy!15Yx%>I0iwz8A9MUu10?Y%ZRuhg|`q7e0ZA=599UGgNYOK##{Jyq5K(3!rQTu zT{05>lHETPzrkzv5}th+TT+Y-4^iQ@t9FY|hzq|thj;$M*@#cCE-ruk=gW^SKCydm z?3Q8EleMAz=%l~fyExXBF`KMqc)gYCn$Gw)r|rUoF`fB2$jhzO z^6E>JGnzwf9p+3jd44ISi<=YFEF7>be3c``y#Jcr$rA|Dpnx0zD z5myPKUlsF5l^&`dm&L7nZ;Coic^w3rp_X|MrR8v6IIh=|gl`h$q3g5dNIji1uHhdI{ zW{Z&g!I_skGykC48%!ryosdM>gx$Ya3EZ)nX8@|A_GnWXKl}FCsSLw9jQ2Z^t1UL0 zVPC}a9-w3sgLE&Q3EN%w&Q6!vj1v2V`7o!-rvlE{apfI+ZTwD1Oh`>%i{wXCB%ex$ z`h(J%O3|piN{{%Zc%`+x%7 zgOlD`%P-W=v-}XB3+cL;E8%}ut~2M~_2Ytg&U~VmK6~kBAs!dMmt3U1Uif?AAwFFp zRG`W~Rr$5`&ms)Km7oGYeMmW#`mNb^BN)dDDR#O zM6DwhiyR1wh1;Z`OmLR+-H{^jbC>pk(h`HG(RbLlPQ6r_K*{k-qV{pq@P`nnE8FHB zq9Xb3>4jX;A-SA+-|R8)3!CJQ?Q^Fg`Fi)b7eaUZKzcxvc_0J8%CUuL=cG&^jq!`8 zWl2w?U5?)z^n|9;|P{y>QYG*1~DG4Ld8D!J~m}T@MGa${CZ=VG&Ys1rW zLnFZw@8#nNQw$_o&lqB|()g5+$c?Hh*KL(h;ni|fBjlDUcKlJL=Kxhgg(rT=FR6Gd zcKlI=XDcJmk6U+^wle3vN~oe>TYR#BTOki0Rf-{S&Q#&KRi0XxxC;$8)$_VIb?@z_ zNBVBZWM|?+Yaa06qIrP>tf~7{Q657gzQN5uFx*M8C!7F612?8qEPKf{H*%RV}&BG?!y_?mPY;yl~of6Ns zFj++Tzh{<~LWbk{xI7;9%rmdkX)2sf&1gye6Ee(1(^~qKGAJ0zjk|B zh~LE_d0X-K!sCop39q0kzk2jyXTp!Ow-P^RE3Y%=8kkSCmhYv{UU*2lSK5wToVQvp zE?|{d$sSydgui0r+48M+RpIx?c)laASsnSp8Fva(;z)D|Mgw13ZKGi?5~yC#c$)aY z-82rvnE)^1AYZ&9gmuNo!)HJ!(qa&dNU81d&i{m4ofsGCc!uHTwvWqU z$QGU(P#Zaa$xy|>ywRA2RmO)N0OO=SqF3BUOO~c=c9e-4LaDXa7&5b(U zrSw#IwH$8x#FmVAMo_C%S*bMZk1D)ej%)yxQ zqh!)JHADa>J5!Y5%psEuuSwemCmLuxj`$>fdyWJE`7!c;E2NY+mZyV%|5!s`6$n zSF!%|sKRgMwjOSc9&WR%&8{}PnwuKw+dt9_yW9?6X+P5YXgE!D%^eNLnTj1@~`t0 zEi>)23?+%dcFhiUM{E9TyD-PAMO~h9godkoWo13toojpSZ1vZ69*=)>+7@lbbY|!v zFVAOMUcJ*UqshNY!-qLj0nabRtYFJWFJFog7AP}wx)nc_C%P3m=d%@$16By>k@z7z zlu!6u%dahOE1$$qg~!FD@~wKV@cW~*?vNX}TFx1F3R6ObCwbkeRCuZUQhMayRlK|k zL>j_r0GzBR*`K)4QeR90yI<|%N{_0jj3G*TDqgBO!b>JZ6-szYUduGvE-qfSa)h3TSjBH4AWQh7 zlvGKpwa8nw$!_q>0dDV6c_B+sM~USBRrucJsD<>VLp^wJIvYNU)^ZIM-Sqa literal 28973 zcmeI5&5j#I5Xa4fAX$4vK6u97FK$6XTu>B&xPYPvHcJ!RxYh@4+|UelnZQ{{G|7-#>jZo2_m9*Ynxz?aA{W zA3uJ2^W@R1H^2V$?3a_z=O>??EaoTIH$Ojnd3F8h>e|aL-(0=1cduXmcKz<{ z$#*vV;bOIzpIt2G=S%zhbTMxia~s`$waaEPZ_bU>v_@%{XEr)BTIMi99?MM7$={fU zJmJ#btk#S9+Gf80+s3Q^#s=vF+T|_n(jaFcHsGU)$gfDx#2o_yC0l!%?4sZJp7XZY z!pCSn>E2}O;#x`f=Ft}Pfye97IG=h+_vRTcLR&txp!}jM?wZED-!2Vjvq~YiPLC`5 zmaTJ+u|NQw@z`73QCVN(yeb<|CX!|PF`|3ewsX;PC8gv2WoJH`XBxB2xfS5X5MD5vXfd%kQjzrD zQjw-&=tN`kb4+l7*)ltMGtmaYynoq~mA~&Y@`Z_Mmi8?f*;1#-$iyRK#!k&X%OYAb zrcnqeFc8`Em=5&jWkwrpbR;9+>oRh?GQ*r&_%^B6IZtt&I-4dhZM_lr-V<3DL!JP8 z3uji#cau2z8Q4R#~W?z_wh7^>dX&__0O@u}0RZkzX^H*d$ss+VsFyqj0ae8l~*d zG-MfCni-t4m+CvfoZ8InU)ft5F$=fpR!k{u6*16Ig~FnW#@e+E_&`WlUNngKMylHo zp`ej2^>|!dlZhejakNhyP6vvh$;9$xEW!vq=9IZ*cg2`&t{?rl+i_cOr|L&;;bD#F zw~Arkjx=r=Z@5Qb(>_GHjf(E3W+|K75^H=)1B!ccZ=K3Sk!&3SJBw?LGxnORu_Pzm zNd#7$;LK1t38bsi+<$8`PHAyprH2ekYem3F$;P}9a3kUI;w{2hS{vwGPkt$FDmdhP zD2tmyL=g~?@_1lD1Y z?8sIrDD2H@qM>vygESgT>r^VZicd_EgW3ktY%#K`)RjgUGi*m~L>>+SOKLGQM@oxu z4FZdytQ#c^Bu(-Vu()x><)PmaR;F`^)q6UKPS!02+M78DjbS94SDb;Y82!b)04b|b z0)+FcEJs1qP$jgq2A1-%iQN%l))$Qdo9H->4*~HS=#F@s7s(VRP2(80NFJ3+$X<0U zK%U+Zq%?B_91db2Y@y=%)LTso<4DdMo-K!<$%%we#ePE|@pP1-le3egs_aE#@AyC) z{yVcjO)_Sij=AlUn;Ol^^8ww?u^fkMej$d+YQ{7#>j<-fJf$%a$ts;+%D0c6eR#Rs zaMPwO$n!CHDYtZfDIali?9C^RF5cT@-EgHedhog{jZ%SY*XdijRBuuiH1Z({x<>K^ z=UJM&w>+YD&kh{mmF7zR35UZpzzdo+s-r(`YB!eJGn9 z^>2QhIEMYBnm<;|M|chuC#;2$j*qQDo(-vway{N5Xv8zb`Xe33db6nmUAGpT&+>+ z%?*L*hxu592~oHhGoCK_{Fq!>A2OFcBvYr+9`YF=vA4YGdr7>sjf;)`NTTO>72zyv z@O9Jlr!57%@^JWjhj|urh^vQ}H@k&P4K9h9{t~ zLJ((Kw}|OZl|^Nt|>4WQi&jUCI|`-2pvhWo_$=E9@Aj+wQMA zZ@ZfW|I*6Zg>@KB`gh%q_ubf7c-|XwOy4rXSzMTKY!8W(sp~~L^~fd)8||WJ2i7=w;fblC$#p7sq3}hG zHgE2z<+Bq|7$q$9^$`=6Nd1wIgoSKp*qgjHc{I&C*0#Nho+rALcEHsYLtrZ}sU}{f z8BlxQegFDN*9ZHX^nbRUS{Kr!-;zoHG0z?T z{g~1~WH!->j4#qzoS3hr`aC|$_uhLn&LLKhmuvLido<4Bnz=C(19>?1WS7#2p>z&) O6RS7h-jikd@ZCQRHmU#s diff --git a/autstr/buildin/bin/mso0.autstr b/autstr/buildin/bin/mso0.autstr index 4ab5e1770eed8eb86e6b5b17d52e63faba3f5cad..c5274f1aa0ebcf9fdd26a0c283e51118084ae955 100644 GIT binary patch literal 70412 zcmeI5&u$#Y5ys_L2#`CWW2^&b|B&3=atI6qF%U%XDKG?^W()xi6^j%Q!|=Q1A@Vpm zqV`TRlD1RbN$CS6BD+%>L=Ge|!1avuDqK{QZAF|KIOEd$zLj zf9&61p8xCH@4tV2`@^fdpa1#Yn;)P5@$C5@p1(MIeslYeH*c?RUR}Sry?cFke{*~N zv)#LY`|jq~U!H$$#lK%IUz}ZBy*Rr(w}00!&g{}k{a?4R8{KmC;%sq#VWlg(e`yyF z_wB}e+IZo$R_G#;G4OjEyOVgYjNkTOD=#0)CU~!I)7NbbqBV_too6PmSWgWj@grq# z?uAE)?Us{y;c&?ADlv#Z({bFss)bM{KpXyc7y-{+%G#B$)$Rd zTTAZ%_OTUfYUwj3-kfj}J=GH*vX2AE7}Dj(&c9Y)X-2T&s?w2swLRG%!-|+##uyd* zp5)a9Q(Hvrg^XSbM(YkemQ0AORNHoB1eJkG*HQMFCXnLaf|Op6naJP^_7sv$W??nR zI~Jt)vZ%FHqT*@QT5P0tf=X8ul8}ibi*OF*OYNz*Xo*F9s+UeNX(6VuzGF|+0%~Ly z%W}_%J=l>ucVtrxMX2*r$e@sbJzcK`jc@V|wX0(H# z-9|1h+Y2TYoPKA%N|yCL=>cJ&?YLc%gD%m-XpUJBtDW707%XASWV_R0&oK_|b9CpH z$}Cb|-d$Oe(XDM-?Em*LXd)t>J07SF+78qq8y&rx`!?LK$tZ~++ARTn0PTg0p!QPa z<78eC1xa(7mS$~Pk$hE7bd9Kj4X=3VWs*mHR2;;Y5*L-PO6Lk@1+_ilr&R{kXX5=* z#X%`CMk_Vf27izzvZ3uKnh3oEvVyF)h#PZ#S~iw5+J1NZ`ufnEv4y`f9^HirSe)B% zzcd&2Q5=Uq?@WX~Z9HR`wf9HVeofygUhnYp&I&-|EpHer_sdQ;5`ULCHh&lNcwuaP z$L8N8Rsac>4M5|DEKpR(DrJF~1w$af z-Fe*3l%bGBG0$n8H&NfWmUH3e8v$)8c>}%V$IC4w@9aA#u*?iGN1sY>B=Vo=mP^4&?u2_@!WAGrdaZ zz}*skf$Fh;X1+KWY!7r0vIvGiY=9zYg)~&D)9T=1q>#H)mrx&4lAet&zm$8B0FJ-B zfK47DdRpE?NOA2MOqzvd3)%o z!O9G_vHuq7srVvI%jsyQN9rh&4-N*GZT!%{Kox^~vC`D=dYXeMfOdObTr_Oc97 zbY^Fq705L*GQU)Dd4sH=wkNNtHLy?fABY2c8v2bonsAJ*C&?u{W@hqBwcor!R=}aO zi1}A+v>?-9TB%~hKAmd)0luW>_teK~`>E(?tW1UHB$xJ$&BKJj6-RF@AB|lpL&L2p z++f9*1L3erTIv4zr&q%J#X(#0ya;nzS=(CvL{D<5p73Pw{IZtb8}hfb=nCWJDe%`#A9&0R>UMR3XEkHy-F8($Fka< zB17b*lvR0TFzOSkr@4^QdC~!1Ba#x1tQSn)8LT&*~7)Yx%O*%Vy}1j&ZIAt&Bcj&)_iRKE{^a!L0@<>Jegd-bO2scEI1kI zHLzb?s5%v?bPn7l^aZL%{lsl+$|h)Xr|gFEXyQNE9@v3o5e#YAfNl)NPWcWBq~2GN zsC1dr`6U_K39^DJ5A_P+J+dYP#W^F=Q(4AG05Qj{MK~IA8j#eLypu*lUN$0hrkuT; zvgqGTEoQAVv-ec}%!6OFV`IOQ?Lb46_q1u2El)HlS^q+N=VP0urv&@A@0y=7b@D2o zDQE`3UWMU$P!JLH^-qR}V2~X$`X|GwPFE_O11E*Dda{QqkH&bbOnvY)j|L&nCyUf) z>LgmVEw#t}5rWPSD#i7fia2vFza(2bK~_-Zk%dSe8ECY87osTP!@X$C>_(S;`EoxN(;(+ZrafA#v{ z3mA*-C!5mi*=fRBoLjIy?l&-^<*e+J69x~@KO9$XJ#e$R7 z;`D|^w)>T}^!pt8nvrliypj03I_ku#(m8OK&==J9q%S1~w5Cwy(V78M8t{8ey6R8W zsL*kdfgzf=U&svHq8#oml4Tw5m(;W9G1Ne|4|Em2fq~Gce+5Had!a4C6y7;mVX%oo z9?d4?Oq7IVaQ$*D+~^s&2&>fy8M$s*0T1S8bbhJYhTpcJPNm@S$O|17wgo)s&T6(y zh}HIu>1Yy@v-QS!rjpB%-7F;d#ZZ@=6 zedvP&+aKYz9CKw3-(6S$-G0~JS;hf^JGf45V0Ti!;U~!U1MhxKZz8XckllJ#h$v&d zH;k40WhWbn|GvbmhU8{s&2bL2o5PdBsWu&NS-GMvp)XK9*-ynq3p`2N^-CEy0mRJ#7m*c^ z5e^v3QQf6Y6jy24l0FRe{u3uVBYRU88D1GkZR3nHB!#mm>4DM<)4Y8ONZCW-!ehX`PbT^*3J$%Q-m(3qMw^yt?bjv4{QCTmE2Z*YVni( z)g_clNB&Or=%?Z`pX^C%Ns>ofE0p!h_8`UIr`cK8;O2@fl5Bzx!pSJOE>WT28A;y6 zCdZaVQbv!@1W9}TG8;KddX+ls&Hhpak3VR;B(v~u1jn#WIDZ9zCp1hy_QpKft|7J{+xK+ME_C;1`_zN-vwn)G9Q$)or2rjn30dh&> z;F9`3ePW^r7Y`#{70IoV;S#F!%Sc6T)U)a3x?%$y&R3-P6)C>6r51&yFSi%}(k@!@ z#?D?_q}Y(+7)v&!Iri*^G*?HkN*5K2Wg6&_CYDusWNng1cA&TtF~#y&_@azh9!nlR zx5Gz+Vb?lPFsm=;KGDn-$ zB(3I6NAYJWgOg`Xg-4Z3V@Pe^nvSNl)^g^~j|0-Y$E9jlnnu<3Q_*3LmgfH>h^tfC z^;45eS1rgXBpI2m2hn)Zrvpi4?x zFG+vdT3rv{s}b{G4z)jsErR>o*5KoYSReT#NKAE-^p0?6t&Pv5 z;=*R<;n)}l(ThD)tTZjWo^o>6v@bY4^=ec#Ya1_}56LC|we*CCoX>$pwEJZzYlSBh z(VRc&JGR`h`IBC5zzhIglAmPs&bC#*55)gucxG+~he!QbYv)=%wfr4$R`3qZ>glo- zeXYK&=QmQLh%$&YvW=RT@VkC;Gtt|fzQji1^F+wz>dOW z9`z?iaDek304??Locbkcj&SjChB_)#x;QV!vf7@K&d4j4Rlc!JEK{h9G_g$6Aod{y zw#YMkUQv@hgNh zOfj{o6ww7}&{Ai||Neawhq#h3q?ajop#-lkgMH zvGKIx--=JIeY}G*0OF-LC%+}XBAl7r=KRS|Tu=t!W7mu9Tx*BSEPhG+ouDtcd0W|Y zApR%AL-rYn-_6^~o~>}U!bA3nl12gUHyqVf`}*RRNLZ-I(#^WPs&x1_~d-NBXy7b z@*d>+{1FoB3Oq%p+J)R4kKEvyI$0u(pxTjw$G7lE9gYqBj3+)VSG8r4WCrSLJVS>K zD3sJ6p(7Eh74SeE^*KJ;#`AReSXRYIP;i?DPuJm&4<4rF%_Evt@N^xjLEeBnTG$8p z5l`GsBT)F(AKI4p_#md@Cp__0M2su^RwKy9FL)v-{DddBps~p<==nXizz<2|XOu9+ z;1`)tI3C@=Pk5q#;!gmfyLIQ}x@eQ`oaR(D5~*~w6j0lvYT(W&59sNf0rC;g$+W_~ zv(+6zz26OwwN`#|%Z_A`vGm{*mlSh$o*hJMQv($`zAZuv-zYqmRe`ZhEaT_u@lYs! zt~s)e%#dhY*7=xB#(s{Y5l+o=E@i(JYXk43+^>%nVlEnv-EKiV{(JMI%cnpcm* zFHik@>fe4x_vFvz{P|>;r?5+RZg$RA^Zg}$0~UW51%G-Ef5i`fDG+~j5PymWe{B&z z+JYYgg7?Uu*8y^u%88@SRCtpnV;0n9*mKPBs#M7w3Y!rdV*YvGEk8TV*S`#3z&cWcrpP zns%D8)YgHMLRmf8qr|&ZN2>|ij-%BrZEp~L2}`LC_eb&hHdzD@c2WZ}4R#UP0CgxS zT&g39_!yB?dgRpRhr4VEapt9dNnzCqvOsSYg^=9|cx3=}7^NKfSwmQr0UK0Ks> z+jh0c@ia22O7$gmi*;xN SJX6dsSiyOF!yj*MejohMAACO;_6N(=yPMm^@@jFjT3@d>%hlq8T-)5Si{bJY`>i$(sWWkNcvRcSWaW0?S{PR^=kt@|^&|y&shjl0X!nL_v83~86 zh%DcQ9(|@oqc7G(vR|8v#({|t{1~ID1kaK#t$pwx&wYW+gT3ZQs7hX>>(15bXq^}S zTM@4$3NG*7te2ZQZ_m85y@v|#@ws@wzXwg}Itc`76=*`#ny=|oQ78yYp~Z->KUg9w zRFuX{u|FJqK*ac1en?%Th}1Jp_Qf!zlZjmuS;sg_{OBLjv3OxDaDR+$WDnx25g%Wf z6By9XF`C#VusrD&@yY$mD_xd0X?k^qt(b`EzQ4(9CK>KaTQHiaBt-&Kq)7{l3FCUu zQV0uobsa`od#yEf-U=!3tlevz-=@%g4_Yj09p%aiLrzVl*SnGUDQ(`^$|x4XYK2QBWH+gdZqmH2a7>Q`oL_&|ZiN5ixCt zWZn0im87X_1eG?^32d3Ee3SP9AKFja;>GJR+B#R~5A%$XkHjrTGbU2`rtTA)?_XYs zPrRT0x=Ogub2HiRXCOa9j(o?R5Isf!tx}fIV+@nF1|sDm2;({xqS6di?n#BU$-bB# z7Egj61Dbm*9&NA1^swaU*t=Lt@Fg_);!Nh9VkCmj#W_;}EG_&L5{QV_DnCOjh2AH$ z7#TmPyV}viY4D#+l6PPi!Xi8(nd`GM9asXI}`KEQ8VdzW+h8^ z^K65UfyVl5fo`qQ5qv&Y7c>NlJS-X38qZKx#n(Ig=j+-Db1rufh3{(<-{Absf7$i| zPl7_BvCMDH(-gKV9kdsMXLs?Cg*v09tx{ykiRo+-nSd zsr!g&ogA^FGNlag*MHaDLb;j0NA}O`r#`}+VCxD-FTo~VNjhOIt3$*>nTQ^udG4bV z!6dy8FU#gFPahM{SDWSSdbz0m9%m$nim?QSn+RmCYrpuRx!hJ*<{8K zI*6oG(J>=gMJ2bwHieMSv+4?A@y{-+E2@kVZk zspgPtcq&_z#vRNd%kO$DPz9f^Q~loJlhqn#6VBUAJUW(z(ym#tNAe-e zRthYJ31x=evZ(F~y7-8WhVjuL`-?(F&)h6|{*M*Hs~}2h01sP)P@MJo69pu-%RTq0 zYs`1K+mMrfUBEEEjIliUNRf!v1&dGHYYM$blw@YbD_IlmVzKs!QinkfHJdXXg>jB! z!B;&3d}580&k@Koix~|1H}UIH&O+Ci&yJ$UiF$O~wsir+?DEC%eXKS_>w&?eZQl~N zn0~B3htM?^Uom|Sp+mTPqOsdH=kJ~_mmB$g*WJ4A2QdC;FXgYi{Iu@MUzPd>8QPit zN{bbrJ(b<1wOc5{oC6&*m?5bL62hei0otQ}@}O^;h-(piB8n7bK*ie5x19LVP9j4G zYQi~o`1t@p8skpMJ zgDGWUt%}AY4rDs&+hCP;CqhM8(fQJ6`biB03Ul4uyI1OBDW None: self.automata[key] = self._prepare_automaton(self._build_automaton(query)) def _prepare_automaton(self, dfa: SparseDFA) -> SparseDFA: - """Applies restriction to the universe and padding to the automaton""" + """Restrict every tape to the universe, then saturate with padding. + + Every tape, not all but the last: a relation is a relation over L(U)^k, + and quantifiers restrict a bound variable to `U`, so a relation that + still holds of a non-element makes universal sentences come out false + on encodings that are not elements at all.""" arity = dfa.symbol_arity # Get arity from symbol_arity attribute domain = self.automata['U'] - for i in range(arity-1): + for i in range(arity): domain_i = expand(domain, new_arity=arity, pos=[i]).minimize() dfa = dfa.intersection(domain_i).minimize() return pad(dfa, self.padding_symbol).minimize() diff --git a/scripts/gen_builtin_presentations.py b/scripts/gen_builtin_presentations.py new file mode 100644 index 0000000..22ac213 --- /dev/null +++ b/scripts/gen_builtin_presentations.py @@ -0,0 +1,43 @@ +"""Regenerate the serialized built-in presentations in `autstr/buildin/bin`. + +The loaders (`BuechiArithmetic`, `BuechiArithmeticZ`, `MSO0`) deserialize these +artifacts with ``enforce_consistency=False`` — the stored automata are taken to +be already restricted to the universe. So whenever the construction of a +built-in presentation changes, or the consistency preparation itself does, the +artifacts have to be rebuilt from their generators: + + python scripts/gen_builtin_presentations.py [name ...] + +Rewriting an artifact also re-encodes it in the current serializer format, +which is bulkier than the one some of them were written with — so rebuild only +the ones whose content actually changed. +""" +import sys +from pathlib import Path + +from autstr.buildin.presentations import ( + buechi_arithmetic, buechi_arithmetic_Z, finite_powerset, +) + +BIN = Path(__file__).resolve().parent.parent / 'autstr' / 'buildin' / 'bin' + +TARGETS = { + 'buechi.autstr': buechi_arithmetic, + 'buechiZ.autstr': buechi_arithmetic_Z, + 'mso0.autstr': finite_powerset, +} + + +def main(names=()) -> None: + for filename, build in TARGETS.items(): + if names and filename not in names and Path(filename).stem not in names: + continue + presentation = build() + path = BIN / filename + presentation.automatic_presentation_to_file(str(path)) + print(f'{filename}: {len(presentation.automata)} automata ' + f'({", ".join(sorted(presentation.automata))})') + + +if __name__ == '__main__': + main(sys.argv[1:]) diff --git a/tests/test_presentations.py b/tests/test_presentations.py new file mode 100644 index 0000000..55ef7db --- /dev/null +++ b/tests/test_presentations.py @@ -0,0 +1,47 @@ +"""Invariants of `AutomaticPresentation` itself. + +A presentation's relations are relations *over the universe*: a tuple whose +coordinates are not well-formed encodings must not satisfy any of them. The +engine relies on that — quantifiers restrict a bound variable to `U`, so an +unrestricted relation makes a universal sentence look false on encodings that +are not elements at all. +""" +from autstr.buildin.presentations import ( + BuechiArithmetic, BuechiArithmeticZ, MSO0, +) +from autstr.interpretations import interpret + + +def _leaks(presentation, name) -> bool: + """Does the relation accept a tuple outside the universe?""" + dfa = presentation.automata[name] + domain = presentation._domain_product(dfa.symbol_arity) + return not dfa.intersection(domain.complement()).minimize().is_empty() + + +class TestRelationsLiveInTheUniverse: + """Regression: the restriction skipped the *last* tape, so unary relations + were never restricted at all. `N0` then accepted the single letter '0', + which is not a well-formed integer, and every universal sentence over an + interpretation built from `N0` came out false.""" + + def test_builtin_relations_are_restricted(self): + for presentation in (BuechiArithmetic(), BuechiArithmeticZ(), MSO0()): + for name in presentation.automata: + if name == 'U': + continue + assert not _leaks(presentation, name), name + + def test_unary_relation_rejects_a_malformed_encoding(self): + z = BuechiArithmeticZ() + assert not z.automata['U'].accepts([('0',)]) + assert not z.automata['N0'].accepts([('0',)]) + + def test_a_universal_sentence_survives_an_interpretation(self): + # δ = N0, Id = equality: every element of the new universe satisfies + # Id(x, x), so `all x. Id(x,x)` must hold. + z = BuechiArithmeticZ() + naturals = interpret(z, domain=('N0(x)', ['x']), + relations={'Id': ('Eq(x,y)', ['x', 'y'])}) + assert naturals.check('all x. Id(x,x)') + assert not naturals.check('exists x.(not Id(x,x))') From be836a0377b2baf77b3f125df5a37f7bed1a882b Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 12 Aug 2026 11:31:23 +0200 Subject: [PATCH 27/76] k-dimensional quotient interpretations Lift the dimension-1 restriction on `interpret(..., quotient=eps)`: with the universe leak fixed, the representative predicate -- "x is canonical iff x <= every y equivalent to it" -- is exact at every dimension, and the construction needed no change. Test: Z built from N alone, as N^2 quotiented by "same difference", with the order read off the representatives. It comes out a discrete unbounded total order that is not dense. Co-Authored-By: Claude Opus 5 --- autstr/interpretations.py | 16 ++-------------- tests/test_interpretations.py | 31 +++++++++++++++++++++++-------- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/autstr/interpretations.py b/autstr/interpretations.py index bb0d508..e8a6a2d 100644 --- a/autstr/interpretations.py +++ b/autstr/interpretations.py @@ -22,10 +22,8 @@ symbol-width overhead, intrinsic to representing tuples. Quotient interpretations (elements as classes of a definable equivalence) are -supported at dimension 1: pass ``quotient=ε``, and the universe is restricted -to the shortlex-least representative of each class. k-dimensional quotients are -not enabled yet — they rest on nested-quantifier sentences over folded -automata, which currently misbehave under the diagonal/complement operations. +supported at every dimension: pass ``quotient=ε``, and the universe is +restricted to the shortlex-least representative of each class. """ from __future__ import annotations @@ -117,16 +115,6 @@ def _quotient(source: AutomaticPresentation, domain: RelationSpec, if _EQUIV in relations or _LE in relations: raise ValueError(f"{_EQUIV!r} and {_LE!r} are reserved for the " f"quotient construction") - if dimension > 1: - # The representative predicate is a nested-quantifier sentence over the - # folded relations, and folded (k>1) automata currently misbehave under - # the diagonal/complement operations those sentences use -- so the - # result would be silently wrong. One-dimensional quotients are exact. - raise NotImplementedError( - "quotient interpretations are only available at dimension 1 for " - "now; k-dimensional quotients await a fix to how folded automata " - "compose under quantifiers") - raw = interpret(source, domain, {**relations, _EQUIV: equivalence}, dimension) raw.update(**{_LE: shortlex_order(raw.sigma, raw.padding_symbol)}) diff --git a/tests/test_interpretations.py b/tests/test_interpretations.py index e8e07f3..0cbdef2 100644 --- a/tests/test_interpretations.py +++ b/tests/test_interpretations.py @@ -175,14 +175,29 @@ def test_a_trivial_equivalence_keeps_every_element(self): assert Q.check('exists x.(exists y. Lt(x,y))') assert Q.check('all x.(exists y. Lt(x,y))') # no maximum, like Z - def test_k_dimensional_quotient_is_refused_for_now(self): - with pytest.raises(NotImplementedError, match="dimension 1"): - interpret(BuechiArithmeticZ(), - domain=('N0(x0) & N0(x1)', ['x0', 'x1']), - relations={}, - dimension=2, - quotient=('exists s.(A(x0,y1,s) & A(y0,x1,s))', - ['x0', 'x1', 'y0', 'y1'])) + def test_integers_as_difference_pairs_of_naturals(self): + """The textbook two-dimensional quotient: ℤ = ℕ² / "same difference", + with the order read off the representatives. The result must be a + discrete unbounded total order — ℤ, built from ℕ alone.""" + pairs = ('N0(x0) & N0(x1)', ['x0', 'x1']) + # (x0,x1) ~ (y0,y1) iff x0 + y1 = y0 + x1 (same difference) + same = ('exists s.(A(x0,y1,s) & A(y0,x1,s))', + ['x0', 'x1', 'y0', 'y1']) + # x0 - x1 < y0 - y1 iff x0 + y1 < y0 + x1 + less = ('exists s.(exists t.(A(x0,y1,s) & A(y0,x1,t) & Lt(s,t)))', + ['x0', 'x1', 'y0', 'y1']) + Z = interpret(BuechiArithmeticZ(), domain=pairs, + relations={'L': less}, dimension=2, quotient=same) + + assert Z.check('all x.(not L(x,x))') # irreflexive + assert Z.check('all x.(all y.(all z.(' # transitive + '(L(x,y) & L(y,z)) -> L(x,z))))') + assert Z.check('all x.(exists y. L(y,x))') # no least + assert Z.check('all x.(exists y. L(x,y))') # no greatest + assert Z.check('all x.(exists y.(L(x,y) & (not exists z.(' + 'L(x,z) & L(z,y)))))') # has successors + assert not Z.check('all x.(all y.(L(x,y) -> exists z.(' + 'L(x,z) & L(z,y))))') # so: not dense def test_reserved_scaffolding_names(self): with pytest.raises(ValueError, match="reserved"): From bb02877df1a74b8953a314741d0ff8e2b947e163 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 12 Aug 2026 17:16:00 +0200 Subject: [PATCH 28/76] Sentences as operands of a connective A subformula with no free variables evaluates to the all/none marker, whose single tape is a placeholder rather than a variable. The conjunction and disjunction branches renamed their operands' tapes into the enclosing formula's order, which is meaningless for a marker and, when the enclosing formula is itself a sentence, impossible: there is no tape to rename to, so `(all x. phi) & (all y. psi)` raised IndexError. Both branches now go through `_operand_automaton`, which remakes a sentence at the enclosing arity instead: false becomes the empty relation, true the product of universes -- not every word, so a true disjunct still admits no non-elements. Co-Authored-By: Claude Opus 5 --- autstr/presentations.py | 32 +++++++++++++++++++--- tests/test_presentations.py | 53 +++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/autstr/presentations.py b/autstr/presentations.py index d6c31de..f48cbb8 100644 --- a/autstr/presentations.py +++ b/autstr/presentations.py @@ -271,6 +271,30 @@ def _domain_product(self, arity: int) -> SparseDFA: ).minimize() return domain + def _operand_automaton(self, psi, free_operand: List[str], + free_vars: List[str], verbose=False) -> SparseDFA: + """A connective's operand, placed into the enclosing formula's tape + order. + + A sentence has no tapes to place: it collapses to the all/none marker, + whose single tape is a placeholder rather than a variable, so renaming + it into the enclosing arity is meaningless (and, when the enclosing + formula is itself a sentence, impossible — there is no tape to rename + to). It is remade at the enclosing arity instead: false becomes the + empty relation, true the full one, which over a structure is the + product of universes rather than every word. + """ + dfa = self._build_automaton(psi, verbose=verbose, init=False) + arity = max(len(free_vars), 1) + if free_operand: + return expand(dfa, arity, [free_vars.index(v) for v in free_operand]) + if dfa.is_empty(): + return zero(symbol_arity=arity, base_alphabet=self.sigma) + # a true sentence: the marker convention at arity 1, so that an + # enclosing sentence stays a marker; the domain otherwise + return one(base_alphabet=self.sigma) if not free_vars \ + else self._domain_product(arity) + def check(self, phi: logic.Expression | str) -> bool: """Checks if a given first-order formula holds on the presented structure. Free variables are assumed be implicitly existentially quantified. @@ -424,8 +448,8 @@ def _build_automaton(self, phi: logic.Expression, verbose=False, init=True) -> S free_l = get_free_elementary_vars(left) free_r = get_free_elementary_vars(right) - dfa_l = expand(self._build_automaton(left, verbose=verbose, init=False), len(free_vars), pos=[free_vars.index(v) for v in free_l]) - dfa_r = expand(self._build_automaton(right, verbose=verbose, init=False), len(free_vars), pos=[free_vars.index(v) for v in free_r]) + dfa_l = self._operand_automaton(left, free_l, free_vars, verbose) + dfa_r = self._operand_automaton(right, free_r, free_vars, verbose) result = dfa_l.intersection(dfa_r).minimize() if verbose: @@ -440,8 +464,8 @@ def _build_automaton(self, phi: logic.Expression, verbose=False, init=True) -> S free_l = get_free_elementary_vars(left) free_r = get_free_elementary_vars(right) - dfa_l = expand(self._build_automaton(left, verbose=verbose, init=False), len(free_vars), pos=[free_vars.index(v) for v in free_l]) - dfa_r = expand(self._build_automaton(right, verbose=verbose, init=False), len(free_vars), pos=[free_vars.index(v) for v in free_r]) + dfa_l = self._operand_automaton(left, free_l, free_vars, verbose) + dfa_r = self._operand_automaton(right, free_r, free_vars, verbose) result = dfa_l.union(dfa_r).minimize() if verbose: diff --git a/tests/test_presentations.py b/tests/test_presentations.py index 55ef7db..6a75531 100644 --- a/tests/test_presentations.py +++ b/tests/test_presentations.py @@ -45,3 +45,56 @@ def test_a_universal_sentence_survives_an_interpretation(self): relations={'Id': ('Eq(x,y)', ['x', 'y'])}) assert naturals.check('all x. Id(x,x)') assert not naturals.check('exists x.(not Id(x,x))') + + +def _same_language(one, other) -> bool: + """Language equality: the symmetric difference is empty.""" + return (one.intersection(other.complement()).is_empty() and + other.intersection(one.complement()).is_empty()) + + +class TestSentencesUnderConnectives: + """A sentence has no free variables, so it evaluates to the all/none + marker rather than to a relation with tapes. Placing that marker into an + enclosing conjunction or disjunction used to be attempted as a tape + renaming, which raised `IndexError` — for two sentences there is no tape to + rename to at all. + """ + + def test_two_sentences_conjoined(self): + arithmetic = BuechiArithmetic() + true, false = 'all x.(Eq(x,x))', 'all x.(Lt(x,x))' + assert arithmetic.check(f'({true}) & ({true})') + assert not arithmetic.check(f'({true}) & ({false})') + assert not arithmetic.check(f'({false}) & ({true})') + + def test_two_sentences_disjoined(self): + arithmetic = BuechiArithmetic() + true, false = 'all x.(Eq(x,x))', 'all x.(Lt(x,x))' + assert arithmetic.check(f'({false}) | ({true})') + assert not arithmetic.check(f'({false}) | ({false})') + + def test_a_sentence_beside_an_open_formula(self): + """The sentence contributes its truth value and the open formula its + relation: a true conjunct must leave the relation alone, a false one + must empty it, and a true disjunct must fill it with the whole + universe.""" + arithmetic = BuechiArithmetic() + true, false = 'all x.(Eq(x,x))', 'all x.(Lt(x,x))' + + less = arithmetic.evaluate('Lt(y,z)') + assert _same_language(arithmetic.evaluate(f'({true}) & Lt(y,z)'), less) + assert arithmetic.evaluate(f'({false}) & Lt(y,z)').is_empty() + + assert _same_language(arithmetic.evaluate(f'({true}) | Lt(y,z)'), + arithmetic._domain_product(2)) + assert _same_language(arithmetic.evaluate(f'({false}) | Lt(y,z)'), less) + + def test_a_true_disjunct_does_not_admit_non_elements(self): + """The full relation of a structure is the product of its universes, + not every word: a true sentence disjoined with an open formula must + still reject encodings that are not elements.""" + arithmetic = BuechiArithmetic() + filled = arithmetic.evaluate('(all x.(Eq(x,x))) | Lt(y,z)') + assert not filled.accepts([('0', '0'), ('0', '1')]) # '00' is not a + assert filled.accepts([('0', '0')]) # well-formed 0 From fac342f70f2486dd4f6d5b05ee9e84e680030de1 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 12 Aug 2026 17:16:07 +0200 Subject: [PATCH 29/76] Signatures for relational structures and orders `graph_signature` bound the one vocabulary the symbolic layer offered a purely relational structure, and an order is not a graph: `x.lt(y)` and `x.adj(y)` read differently even where both are binary. `relational_signature(relations, methods)` binds any set of relation methods, with equality when the structure declares it; `graph_signature` is now a call to it, and `order_signature` the same for a strict order plus whatever else the structure carries -- a successor, say. A requested method binds whether or not the structure declares the symbol, so a typo fails loudly when a formula uses it rather than going silently unbound. Co-Authored-By: Claude Opus 5 --- autstr/symbolic/__init__.py | 5 ++-- autstr/symbolic/signature.py | 55 ++++++++++++++++++++++++++++++++---- tests/test_symbolic.py | 48 +++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 7 deletions(-) diff --git a/autstr/symbolic/__init__.py b/autstr/symbolic/__init__.py index 6e32bc8..7e696ad 100644 --- a/autstr/symbolic/__init__.py +++ b/autstr/symbolic/__init__.py @@ -29,7 +29,8 @@ from autstr.symbolic.expr import Formula, Term, Var from autstr.symbolic.signature import ( ElementCodec, EQUALITY_SYMBOL, Function, FunctionCodec, Signature, - graph_signature, operation_signature, + graph_signature, operation_signature, order_signature, + relational_signature, ) # `Function` is importable from here but deliberately absent from __all__: @@ -42,5 +43,5 @@ 'FunctionCodec', 'FunctionSymbol', 'Relation', 'RelationSymbol', 'Signature', 'StructureBackend', 'SymbolicContext', 'SymbolicSymbolError', 'Term', 'Var', 'graph_signature', - 'operation_signature', + 'operation_signature', 'order_signature', 'relational_signature', ] diff --git a/autstr/symbolic/signature.py b/autstr/symbolic/signature.py index 7884231..45b48eb 100644 --- a/autstr/symbolic/signature.py +++ b/autstr/symbolic/signature.py @@ -75,6 +75,33 @@ def operation_signature(relations, graph: str, operator: str, return signature +def relational_signature(relations, methods: Dict[str, str], + equality: str = EQUALITY_SYMBOL, + codec=None) -> 'Signature': + """The signature of a purely relational structure: each method name in + `methods` bound to the relation symbol it names, plus equality when the + structure declares it. + + Nothing binds to ``+`` or ``*`` — a relational structure carries no + operation, so every symbol is reached as a method, exactly like ``.lt`` in + the arithmetic signature. The requested methods are bound whether or not + the structure declares them, so a symbol that is missing fails loudly when + a formula uses it rather than silently going unbound; only equality, which + a caller asks for generically, is conditional. + + :param relations: the structure's relation symbols. + :param methods: ``{method name: relation symbol}``. + :param equality: the equality symbol, bound to ``.eq`` when present. + :param codec: optional element codec for writing elements as constants. + """ + signature = Signature(codec=codec) + for method, symbol in methods.items(): + signature.operator(method, symbol) + if equality in relations: + signature.operator('eq', equality) + return signature + + def graph_signature(relations, edge: str = 'E', adjacency: str = 'adj', equality: str = EQUALITY_SYMBOL, codec=None) -> 'Signature': """The signature of a graph: the binary edge relation `edge` bound to the @@ -91,11 +118,29 @@ def graph_signature(relations, edge: str = 'E', adjacency: str = 'adj', :param adjacency: the method name it binds to. :param codec: optional element codec for writing vertices as constants. """ - signature = Signature(codec=codec) - signature.operator(adjacency, edge) - if equality in relations: - signature.operator('eq', equality) - return signature + return relational_signature(relations, {adjacency: edge}, + equality=equality, codec=codec) + + +def order_signature(relations, less: str = 'Lt', order: str = 'lt', + equality: str = EQUALITY_SYMBOL, codec=None, + methods: Optional[Dict[str, str]] = None) -> 'Signature': + """The signature of an ordered structure: the binary relation `less` bound + to ``.{order}(y)`` (default ``.lt``), plus equality when declared. + + An order is not a graph — ``x.lt(y)`` and ``x.adj(y)`` read differently + even where both are binary — so orders get their own vocabulary rather than + being wrapped as graphs. Further relations of the same structure (a + successor, a limit predicate) go in `methods`. + + :param relations: the structure's relation symbols. + :param less: the binary relation read as the strict order. + :param order: the method name it binds to. + :param codec: optional element codec for writing elements as constants. + :param methods: further ``{method name: relation symbol}`` bindings. + """ + return relational_signature(relations, {order: less, **(methods or {})}, + equality=equality, codec=codec) class ElementCodec: diff --git a/tests/test_symbolic.py b/tests/test_symbolic.py index e04c252..096ba40 100644 --- a/tests/test_symbolic.py +++ b/tests/test_symbolic.py @@ -6,6 +6,7 @@ from autstr.buildin.presentations import BuechiArithmeticZ from autstr.symbolic import ( CompileError, FunctionCodec, Signature, SymbolicSymbolError, + graph_signature, order_signature, relational_signature, ) @@ -466,3 +467,50 @@ def test_failed_evaluation_does_not_leave_spliced_relations_installed(S): assert dict(presentation.automata) == before assert 'Spliced' not in presentation.automata + + +# ---------------------------------------------------------------------- +# signature constructors +# ---------------------------------------------------------------------- + +def test_relational_signature_binds_every_method(): + signature = relational_signature({'Lt', 'Eq', 'Div'}, + {'lt': 'Lt', 'divides': 'Div'}) + assert signature.operators == {'lt': 'Lt', 'divides': 'Div', 'eq': 'Eq'} + assert signature.functions == {} # a relational structure has none + + +def test_relational_signature_omits_an_absent_equality(): + signature = relational_signature({'Lt'}, {'lt': 'Lt'}) + assert 'eq' not in signature.operators + + +def test_relational_signature_binds_a_method_whose_symbol_is_absent(): + """A requested method is bound whether or not the structure declares it, + so a typo fails loudly at compile time rather than going silently + unbound.""" + signature = relational_signature({'Lt'}, {'lt': 'Ltt'}) + assert signature.operators['lt'] == 'Ltt' + + +def test_order_signature_binds_the_order_and_extra_methods(): + signature = order_signature({'Lt', 'Eq', 'Succ'}, less='Lt', + methods={'succ': 'Succ'}) + assert signature.operators == {'lt': 'Lt', 'succ': 'Succ', 'eq': 'Eq'} + + +def test_order_signature_renames_the_method(): + signature = order_signature({'Below', 'Eq'}, less='Below', order='below') + assert signature.operators['below'] == 'Below' + assert 'lt' not in signature.operators + + +def test_graph_signature_binds_adjacency(): + signature = graph_signature({'E', 'Eq'}) + assert signature.operators == {'adj': 'E', 'eq': 'Eq'} + + +def test_a_signature_carries_its_codec(): + codec = FunctionCodec(_encode, _decode) + assert order_signature({'Lt'}, codec=codec).codec is codec + assert graph_signature({'E'}, codec=codec).codec is codec From 647fa69ec18216884cd4ed1aadf4389f1ed7edfd Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 12 Aug 2026 17:16:24 +0200 Subject: [PATCH 30/76] Ordinals below omega^omega, as interpretations of Buechi arithmetic Delhommae's theorem puts the word-automatic ordinals exactly at those below omega^omega, and every one of them lives in some omega^n -- so `Ordinal(n)` covers all of them, and past that the string engine cannot go. The structure is derived rather than authored: by Cantor normal form an ordinal below omega^n is a tuple of n natural coefficients, so omega^n is an n-dimensional first-order interpretation of Buechi arithmetic, with the order reverse-lexicographic on the coefficients and the successor an addition in the constant one. It is the first structure built on `interpret`, and the whole factory is three formulas. Ordinals are written as coefficient tuples in descending exponent order, which compare lexicographically exactly as the ordinals do -- so Python is the oracle the tests check the automata against. Co-Authored-By: Claude Opus 5 --- autstr/ordinals.py | 183 +++++++++++++++++++++++++++++++++++++++++ tests/test_ordinals.py | 137 ++++++++++++++++++++++++++++++ 2 files changed, 320 insertions(+) create mode 100644 autstr/ordinals.py create mode 100644 tests/test_ordinals.py diff --git a/autstr/ordinals.py b/autstr/ordinals.py new file mode 100644 index 0000000..afd9367 --- /dev/null +++ b/autstr/ordinals.py @@ -0,0 +1,183 @@ +"""Ordinals below :math:`\\omega^\\omega`, as automatic structures. + +Delhommé's theorem draws the line exactly here: the word-automatic ordinals are +precisely those below :math:`\\omega^\\omega`. Every one of them lives in some +:math:`\\omega^n`, so a factory for :math:`(\\omega^n, <)` covers all of them — +and past that boundary the string engine cannot go. (:math:`\\omega^{\\omega}` +itself and everything up to :math:`\\omega^{\\omega^\\omega}` needs the tree +engine.) + +The structure is *derived, not authored*. By Cantor normal form an ordinal +:math:`\\alpha < \\omega^n` is uniquely :math:`\\sum_{i>> W2 = Ordinal(2) # the ordinals below ω² + >>> S = W2.symbolic() + >>> a, b = S.vars("a b") + >>> a.lt(b).evaluate().contains(a=(0, 7), b=(1, 0)) # 7 < ω + True + +Ordinals are written as Python tuples of Cantor coefficients in *descending* +exponent order, so ``(3, 2, 5)`` in ``Ordinal(3)`` is +:math:`\\omega^2 \\cdot 3 + \\omega \\cdot 2 + 5` and tuples compare +lexicographically exactly as the ordinals do. A plain integer is the finite +ordinal of that value. + +The signature carries the strict order ``.lt``, equality ``.eq`` and the +successor ``.succ``. Everything else is first-order over those and can be +defined on the spot: a limit ordinal is one that is neither zero nor a +successor, and ``Ordinal(n)`` has a definable least element but no greatest. +""" +from __future__ import annotations + +from typing import List, Sequence, Tuple, Union + +from autstr.buildin.presentations import BuechiArithmetic +from autstr.interpretations import interpret +from autstr.symbolic import FunctionCodec, order_signature + +#: base-2 encoding of a natural, least significant bit first — the convention +#: of the Büchi arithmetic presentation over ℕ +_PAD = '*' + +#: the constant 1 of Büchi arithmetic: the least power of two +_ONE = 'Pt(o) & (all p.(Pt(p) -> (not Lt(p,o))))' + +#: a Python ordinal: coefficients in descending exponent order, or an integer +OrdinalValue = Union[int, Sequence[int]] + + +def _encode_nat(value: int) -> List[str]: + """The word encoding a natural number: binary, least significant bit + first.""" + if value < 0: + raise ValueError(f"a Cantor coefficient must be a natural, got {value}") + return list(format(value, 'b')[::-1]) + + +def _decode_nat(word) -> int: + """The natural number encoded by a word, ignoring padding.""" + bits = ''.join(symbol for symbol in word if symbol != _PAD) + if not bits: + raise ValueError("empty encoding") + return int(bits[::-1], base=2) + + +class Ordinal: + """The ordinals below :math:`\\omega^n`, under their natural order. + + :param n: the exponent (n ≥ 1). ``Ordinal(1)`` is :math:`(\\omega, <)`, + i.e. the naturals; ``Ordinal(2)`` is :math:`(\\omega^2, <)`. + + The presentation carries ``Lt`` (the strict order), ``Eq`` and ``Succ`` + (the graph of the successor function, ``Succ(x, y)`` iff ``y = x + 1``). + """ + + def __init__(self, n: int = 1) -> None: + if n < 1: + raise ValueError("the exponent must be >= 1") + self.n = n + coefficients = [f'x{i}' for i in range(n)] # x_i: coefficient of ω^i + others = [f'y{i}' for i in range(n)] + pair = coefficients + others + + self.presentation = interpret( + BuechiArithmetic(), + domain=(' & '.join(f'Eq({c},{c})' for c in coefficients), + coefficients), + relations={ + 'Lt': (self._less(n), pair), + 'Eq': (' & '.join(f'Eq(x{i},y{i})' for i in range(n)), pair), + 'Succ': (self._successor(n), pair), + }, + dimension=n) + + @staticmethod + def _less(n: int) -> str: + """Reverse-lexicographic order on the coefficients: compare the most + significant one first. First-order for each fixed n — a disjunction + over which exponent the two ordinals first differ at.""" + terms = [] + for i in reversed(range(n)): + agree = [f'Eq(x{j},y{j})' for j in range(i + 1, n)] + terms.append(' & '.join([f'Lt(x{i},y{i})'] + agree)) + return ' | '.join(f'({term})' for term in terms) + + @staticmethod + def _successor(n: int) -> str: + """``y = x + 1``: add one to the constant coefficient and hold the + rest. Every ordinal has a successor, so this is a total function.""" + held = [f'Eq(x{j},y{j})' for j in range(1, n)] + add_one = f'exists o.(({_ONE}) & A(x0,o,y0))' + return ' & '.join([f'({add_one})'] + held) + + # -- element codec: a Cantor normal form <-> its folded encoding ---- + def encode(self, value: OrdinalValue) -> list: + """The encoding of an ordinal written as its Cantor coefficients in + descending exponent order (or as a plain integer, for a finite + ordinal).""" + coefficients = self._coefficients(value) + words = [_encode_nat(c) for c in reversed(coefficients)] # x0 first + if self.n == 1: + return words[0] + length = max(len(word) for word in words) + return [tuple(word[k] if k < len(word) else _PAD for word in words) + for k in range(length)] + + def decode(self, word) -> Tuple[int, ...]: + """The ordinal encoded by a word, as a tuple of Cantor coefficients in + descending exponent order.""" + if self.n == 1: + return (_decode_nat(word),) + return tuple(reversed([_decode_nat([letter[i] for letter in word]) + for i in range(self.n)])) + + def _coefficients(self, value: OrdinalValue) -> Tuple[int, ...]: + """`value` as a full n-tuple of coefficients, descending exponent.""" + if isinstance(value, int): + value = (value,) + coefficients = tuple(value) + if len(coefficients) > self.n: + raise ValueError( + f"{value!r} has {len(coefficients)} coefficients, more than " + f"the {self.n} of an ordinal below omega^{self.n}") + return (0,) * (self.n - len(coefficients)) + coefficients + + # -- interface ----------------------------------------------------- + def default_signature(self): + """The signature `symbolic()` uses when none is given: ``.lt``, + ``.eq`` and ``.succ``, with ordinals written as coefficient tuples.""" + return order_signature(self.presentation.get_relation_symbols(), + less='Lt', methods={'succ': 'Succ'}, + codec=FunctionCodec(self.encode, self.decode)) + + def symbolic(self, signature=None): + """A symbolic interface to the order; write ``a.lt(b)`` and ordinals as + Python coefficient tuples.""" + return self.presentation.symbolic(signature or self.default_signature()) + + def check(self, phi) -> bool: + """Truth of a formula over the ordinals (free variables existential).""" + return self.presentation.check(phi) + + def evaluate(self, phi): + """The relation of satisfying assignments of a formula.""" + return self.presentation.evaluate(phi) + + def get_relation_symbols(self): + """All relation symbols of the structure ('U' is the domain).""" + return self.presentation.get_relation_symbols() + + def is_total_order(self) -> bool: + """Whether ``Lt`` is a strict total order — decidable, being a + first-order question. Well-foundedness is *not* first-order, so it is + not checkable here; it holds by construction.""" + return self.presentation.check( + 'all x.(not Lt(x,x)) & ' + 'all x.(all y.(all z.((Lt(x,y) & Lt(y,z)) -> Lt(x,z)))) & ' + 'all x.(all y.(Lt(x,y) | Lt(y,x) | Eq(x,y)))') + + def __repr__(self): + return f"" diff --git a/tests/test_ordinals.py b/tests/test_ordinals.py new file mode 100644 index 0000000..10aedb6 --- /dev/null +++ b/tests/test_ordinals.py @@ -0,0 +1,137 @@ +"""The ordinals below ω^ω, as n-dimensional interpretations of ℕ. + +The oracle is Python itself: an ordinal below ω^n is its tuple of Cantor +coefficients in descending exponent order, and ordinal comparison is that +tuple's lexicographic order. So every order fact checked here has an +independent ground truth that needs no automaton. + +ω³ is exercised sparingly — the interpretation is three-dimensional and its +sentences are correspondingly expensive. +""" +import itertools + +import pytest + +from autstr.ordinals import Ordinal + + +@pytest.fixture(scope="module") +def omega(): + return Ordinal(1) + + +@pytest.fixture(scope="module") +def omega_squared(): + return Ordinal(2) + + +@pytest.fixture(scope="module") +def below(omega_squared): + """``x < y`` over ω², as a relation that takes ordinals as Python + values.""" + x, y = omega_squared.symbolic().vars('x y') + return x.lt(y).evaluate() + + +@pytest.fixture(scope="module") +def successor(omega_squared): + """``y = x + 1`` over ω², likewise.""" + x, y = omega_squared.symbolic().vars('x y') + return x.succ(y).evaluate() + + +class TestCodec: + def test_roundtrip(self, omega_squared): + for value in [(0, 0), (0, 7), (1, 0), (3, 5), (12, 9)]: + assert omega_squared.decode(omega_squared.encode(value)) == value + + def test_an_integer_is_the_finite_ordinal(self, omega_squared): + assert omega_squared.encode(5) == omega_squared.encode((0, 5)) + + def test_short_tuples_are_padded_with_leading_zeros(self): + w3 = Ordinal(3) + assert w3.encode((2, 5)) == w3.encode((0, 2, 5)) + + def test_too_many_coefficients_are_rejected(self, omega_squared): + with pytest.raises(ValueError, match="more than"): + omega_squared.encode((1, 2, 3)) + + def test_negative_coefficients_are_rejected(self, omega_squared): + with pytest.raises(ValueError, match="natural"): + omega_squared.encode((1, -2)) + + +class TestOrder: + """The order is reverse-lexicographic on the coefficients — checked + exhaustively against the Python tuple order on a finite window of ω².""" + + def test_agrees_with_the_tuple_order(self, below): + window = list(itertools.product(range(3), range(4))) + for a, b in itertools.product(window, repeat=2): + assert below.contains(x=a, y=b) == (a < b), (a, b) + + def test_the_finite_ordinals_sit_below_omega(self, below): + for finite in (0, 1, 7, 1000): + assert below.contains(x=finite, y=(1, 0)) + assert not below.contains(x=(1, 0), y=finite) + + def test_is_a_strict_total_order(self, omega, omega_squared): + assert omega.is_total_order() + assert omega_squared.is_total_order() + + +class TestSuccessor: + def test_adds_one_to_the_constant_coefficient(self, successor): + assert successor.contains(x=(1, 3), y=(1, 4)) + assert successor.contains(x=0, y=1) + assert not successor.contains(x=(1, 3), y=(1, 5)) + # ω is a limit: it is nobody's successor, in particular not ω·0 + n + assert not successor.contains(x=(0, 7), y=(1, 0)) + + def test_symbolic_surface(self, omega_squared): + S = omega_squared.symbolic() + a, b = S.vars('a b') + assert a.succ(b).evaluate().contains(a=(2, 0), b=(2, 1)) + assert a.lt(b).evaluate().contains(a=(0, 7), b=(1, 0)) + assert a.eq(b).evaluate().contains(a=(1, 1), b=(1, 1)) + + +class TestOrdinalFacts: + """First-order facts that hold of ω^n and distinguish it from ℕ.""" + + def test_has_a_least_element_and_no_greatest(self, omega_squared): + assert omega_squared.check('exists x.(all y.(Lt(x,y) | Eq(x,y)))') + assert omega_squared.check('all x.(exists y.(Lt(x,y)))') + + def test_omega_squared_has_limit_ordinals_but_omega_does_not( + self, omega, omega_squared): + # a limit is neither zero nor a successor + limit = ('exists x.((exists z.(Lt(z,x))) & ' + '(not exists y.(Succ(y,x))))') + assert not omega.check(limit) # ℕ: every non-zero is a successor + assert omega_squared.check(limit) # ω, ω·2, … are limits + + def test_every_element_has_a_successor(self, omega_squared): + assert omega_squared.check('all x.(exists y.(Succ(x,y)))') + + def test_the_order_is_discrete_upwards(self, omega_squared): + # the successor is the immediate one: nothing sits strictly between + assert omega_squared.check( + 'all x.(all y.(Succ(x,y) -> ' + '(not exists z.(Lt(x,z) & Lt(z,y)))))') + + +class TestConstruction: + def test_exponent_must_be_positive(self): + with pytest.raises(ValueError, match=">= 1"): + Ordinal(0) + + def test_repr(self, omega_squared): + assert repr(omega_squared) == "" + + def test_omega_is_the_naturals(self, omega): + # dimension 1: the interpretation is a definable reduct of ℕ, so the + # order relation is the source's own, minimized + from autstr.buildin.presentations import BuechiArithmetic + assert omega.presentation.automata['Lt'].num_states == \ + BuechiArithmetic().automata['Lt'].num_states From 682b5af0afb49ab95e677a649a021fe4b719b07d Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 12 Aug 2026 17:16:25 +0200 Subject: [PATCH 31/76] The regular tree T_k, with its successors and prefix order Vertices are the words over {0..k-1}: the root is the empty word and x.i is its i-th child, so the tree is its own encoding and every relation is a small automaton read straight off the word operations -- appending a letter, being a prefix. It is the substrate the later tiers stand on, and the Cayley graph of the free monoid on k generators. Authoring those automata wanted a way to write a transition table without spelling out the rejecting majority of it, so `partial_dfa` takes a partial table over a product alphabet and sends everything unlisted to a sink. Co-Authored-By: Claude Opus 5 --- autstr/infinite_graphs.py | 128 ++++++++++++++++++++++++++++++++ autstr/utils/automata_tools.py | 58 ++++++++++++++- tests/test_regular_tree.py | 130 +++++++++++++++++++++++++++++++++ 3 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 tests/test_regular_tree.py diff --git a/autstr/infinite_graphs.py b/autstr/infinite_graphs.py index 95e79a0..ca343c5 100644 --- a/autstr/infinite_graphs.py +++ b/autstr/infinite_graphs.py @@ -191,3 +191,131 @@ def __repr__(self): def _async_product(left, right): from autstr.composition import direct_product return direct_product(left, right, kind='async') + + +# ---------------------------------------------------------------------- +# Concrete factory: the regular tree +# ---------------------------------------------------------------------- + +class RegularTree: + """The infinite k-ary tree :math:`T_k`, with its successors and the prefix + order. + + Vertices are the words over ``{0, …, k-1}``: the root is the empty word, + and ``x·i`` is the i-th child of ``x``. So the tree *is* its own encoding, + and the relations are small automata read straight off the word operations + — appending one letter, and being a prefix — rather than derived from + another structure. + + The presentation carries ``S0 … S{k-1}`` (``Si(x, y)`` iff ``y = x·i``), + their union ``Child``, ``Prefix`` (the reflexive prefix order, i.e. + ancestor-or-self), ``Eq``, and the undirected child edge ``E``, under which + the tree is a graph: the root has degree k and every other vertex degree + k+1. + + :math:`T_k` is the Cayley graph of the free monoid on k generators, and the + 2k-regular version is the Cayley graph of the free group — the same object + the pushdown graphs are unravelled from. + + :param k: the branching degree (k ≥ 1). ``RegularTree(1)`` is a ray. + """ + + def __init__(self, k: int = 2) -> None: + if k < 1: + raise ValueError("the branching degree must be >= 1") + self.k = k + self.letters = [str(i) for i in range(k)] + alphabet = self.letters + [_PAD] + + from autstr.presentations import AutomaticPresentation + automata = {'U': _words(alphabet, self.letters), + 'Eq': _identity(alphabet, self.letters), + 'Prefix': _prefix(alphabet, self.letters)} + automata.update({f'S{i}': _append(alphabet, self.letters, letter) + for i, letter in enumerate(self.letters)}) + self.presentation = AutomaticPresentation(automata, padding_symbol=_PAD) + self.presentation.update( + Child=' | '.join(f'S{i}(x,y)' for i in range(k))) + self.presentation.update(E='Child(x,y) | Child(y,x)') + + self.graph = InfiniteGraph( + self.presentation, edge='E', + codec=FunctionCodec(self.encode, self.decode)) + + # -- element codec: a vertex as its word --------------------------- + def encode(self, vertex) -> list: + """The encoding of a vertex, written as a sequence of child indices — + ``(0, 1, 1)``, or the string ``'011'`` — with the empty sequence for + the root.""" + word = [] + for step in vertex: + index = int(step) + if not 0 <= index < self.k: + raise ValueError( + f"{step!r} is not a child index of a {self.k}-ary tree") + word.append(self.letters[index]) + return word + + def decode(self, word) -> Tuple[int, ...]: + """The vertex encoded by a word, as a tuple of child indices.""" + return tuple(int(symbol) for symbol in word if symbol != _PAD) + + # -- interface ----------------------------------------------------- + def symbolic(self, signature=None): + """A symbolic interface to the tree; write the child edge as + ``x.adj(y)`` and vertices as sequences of child indices. The + successors and the prefix order are reached by name, through + `autstr.symbolic.SymbolicContext.rel`.""" + return self.graph.symbolic(signature) + + def is_symmetric(self) -> bool: + return self.graph.is_symmetric() + + def check(self, phi) -> bool: + return self.graph.check(phi) + + def evaluate(self, phi): + return self.graph.evaluate(phi) + + def get_relation_symbols(self): + return self.presentation.get_relation_symbols() + + def __repr__(self): + return f"" + + +def _dfa(alphabet, arity: int, transitions, initial: str, final): + from autstr.utils.automata_tools import partial_dfa + return partial_dfa(set(alphabet), arity, transitions, initial, set(final)) + + +def _words(alphabet, letters): + """The universe: every word over the child letters, the root included.""" + return _dfa(alphabet, 1, + {'w': {(letter,): 'w' for letter in letters}}, + initial='w', final={'w'}) + + +def _identity(alphabet, letters): + """``x = y``, on the convolution of two words.""" + return _dfa(alphabet, 2, + {'s': {(letter, letter): 's' for letter in letters}}, + initial='s', final={'s'}) + + +def _append(alphabet, letters, letter): + """``y = x·letter``: the tapes agree while x runs, then x pads out and y + carries the one extra letter.""" + table = {'s': {(a, a): 's' for a in letters}} + table['s'][(_PAD, letter)] = 'done' + table['done'] = {} + return _dfa(alphabet, 2, table, initial='s', final={'done'}) + + +def _prefix(alphabet, letters): + """``x`` is a prefix of ``y``, reflexively: the tapes agree while x runs, + and whatever y has left is the descent below it.""" + table = {'s': {(a, a): 's' for a in letters}, + 'below': {(_PAD, a): 'below' for a in letters}} + table['s'].update({(_PAD, a): 'below' for a in letters}) + return _dfa(alphabet, 2, table, initial='s', final={'s', 'below'}) diff --git a/autstr/utils/automata_tools.py b/autstr/utils/automata_tools.py index 8711a78..8e1f7fe 100644 --- a/autstr/utils/automata_tools.py +++ b/autstr/utils/automata_tools.py @@ -744,4 +744,60 @@ def lsbf_Z_automaton(z: int) -> SparseDFA: start_state=0, symbol_arity=1, base_alphabet={"*", "0", "1"} # "*"=0, "0"=1, "1"=2 - ) \ No newline at end of file + ) + +def partial_dfa(base_alphabet: Set, arity: int, + transitions: Dict[str, Dict[tuple, str]], + initial: str, final: Set[str]) -> SparseDFA: + """A DFA over `arity` tapes from a *partial* transition table. + + Every symbol tuple a state does not list goes to a rejecting sink, which is + what an automaton authored by hand almost always wants: the interesting + transitions are few and the alphabet — a product of k copies of the base — + is large, so spelling the table out in full costs |Σ|^k entries per state + to say "reject" over and over. + + :param base_alphabet: the base alphabet, padding symbol included. + :param arity: number of tapes; the automaton reads `arity`-tuples. + :param transitions: ``{state: {symbol tuple: target state}}``. Its keys are + the states, in the order they are numbered. + :param initial: the start state. + :param final: the accepting states. + :return: a minimized `SparseDFA`. + """ + states = list(transitions) + if initial not in states: + raise ValueError(f"the start state {initial!r} has no row in the table") + unknown = {target for row in transitions.values() for target in row.values()} + unknown |= set(final) + unknown -= set(states) + if unknown: + raise ValueError(f"states without a row in the table: {sorted(unknown)}") + + sink = len(states) # the implicit rejecting state + index = {state: i for i, state in enumerate(states)} + rows = [sorted((encode_symbol(symbol, base_alphabet), index[target]) + for symbol, target in transitions[state].items()) + for state in states] + width = max([len(row) for row in rows], default=0) + + exception_symbols = np.full((sink + 1, width), -1, dtype=np.int32) + exception_states = np.full((sink + 1, width), -1, dtype=np.int32) + for i, row in enumerate(rows): + exception_symbols[i, :len(row)] = [symbol for symbol, _ in row] + exception_states[i, :len(row)] = [target for _, target in row] + + is_accepting = np.zeros(sink + 1, dtype=bool) + for state in final: + is_accepting[index[state]] = True + + return SparseDFA( + num_states=sink + 1, + default_states=np.full(sink + 1, sink, dtype=np.int32), + exception_symbols=exception_symbols, + exception_states=exception_states, + is_accepting=is_accepting, + start_state=index[initial], + symbol_arity=arity, + base_alphabet=set(base_alphabet), + ).minimize() diff --git a/tests/test_regular_tree.py b/tests/test_regular_tree.py new file mode 100644 index 0000000..6749fba --- /dev/null +++ b/tests/test_regular_tree.py @@ -0,0 +1,130 @@ +"""The infinite k-ary tree with its successors and prefix order. + +The oracle is Python's own word operations: a vertex is a tuple of child +indices, ``y = x·i`` is a tuple append, and the prefix order is a slice +comparison. Every relation is checked exhaustively against those over the +vertices of depth ≤ 3. +""" +import itertools + +import pytest + +from autstr.infinite_graphs import RegularTree + + +@pytest.fixture(scope="module") +def binary(): + return RegularTree(2) + + +def vertices(k: int, depth: int): + """Every vertex of depth at most `depth`, as a tuple of child indices.""" + return [word + for length in range(depth + 1) + for word in itertools.product(range(k), repeat=length)] + + +class TestCodec: + def test_roundtrip(self, binary): + for vertex in vertices(2, 3): + assert binary.decode(binary.encode(vertex)) == vertex + + def test_the_root_is_the_empty_word(self, binary): + assert binary.encode(()) == [] + + def test_a_string_of_digits_is_accepted(self, binary): + assert binary.encode('011') == binary.encode((0, 1, 1)) + + def test_an_index_outside_the_branching_is_rejected(self, binary): + with pytest.raises(ValueError, match="child index"): + binary.encode((0, 2)) + + +class TestSuccessors: + def test_each_successor_appends_its_letter(self, binary): + S = binary.symbolic() + x, y = S.vars('x y') + for i in range(2): + relation = S.rel(f'S{i}')(x, y).evaluate() + for a, b in itertools.product(vertices(2, 3), repeat=2): + expected = (b == a + (i,)) + assert relation.contains(x=a, y=b) == expected, (i, a, b) + + def test_child_is_the_union_of_the_successors(self, binary): + S = binary.symbolic() + x, y = S.vars('x y') + relation = S.rel('Child')(x, y).evaluate() + for a, b in itertools.product(vertices(2, 3), repeat=2): + assert relation.contains(x=a, y=b) == (b[:-1] == a and len(b) > 0) + + +class TestPrefixOrder: + def test_agrees_with_the_slice_comparison(self, binary): + S = binary.symbolic() + x, y = S.vars('x y') + relation = S.rel('Prefix')(x, y).evaluate() + for a, b in itertools.product(vertices(2, 3), repeat=2): + assert relation.contains(x=a, y=b) == (b[:len(a)] == a), (a, b) + + def test_is_a_partial_order_with_a_least_element(self, binary): + assert binary.check('all x.(Prefix(x,x))') + assert binary.check('all x.(all y.((Prefix(x,y) & Prefix(y,x)) ' + '-> Eq(x,y)))') + assert binary.check('exists r.(all x.(Prefix(r,x)))') # the root + + +class TestGraph: + def test_the_edge_is_the_parent_child_relation(self, binary): + S = binary.symbolic() + x, y = S.vars('x y') + edge = x.adj(y).evaluate() + assert edge.contains(x=(), y=(0,)) + assert edge.contains(x=(0,), y=()) # undirected + assert edge.contains(x=(0, 1), y=(0, 1, 0)) + assert not edge.contains(x=(), y=(0, 1)) # not a grandchild + assert not edge.contains(x=(0,), y=(1,)) # not siblings + assert not edge.contains(x=(0,), y=(0,)) # no self-loop + + def test_is_symmetric(self, binary): + assert binary.is_symmetric() + + def test_every_vertex_but_the_root_has_exactly_one_parent(self, binary): + assert binary.check( + 'all y.((exists p.(Child(p,y))) -> ' + 'all p.(all q.((Child(p,y) & Child(q,y)) -> Eq(p,q))))') + # the root is the one vertex without a parent, and it is unique + assert binary.check( + 'exists r.((not exists p.(Child(p,r))) & ' + 'all x.((not exists p.(Child(p,x))) -> Eq(x,r)))') + + def test_every_vertex_has_a_child_for_each_letter(self, binary): + for i in range(2): + assert binary.check(f'all x.(exists y.(S{i}(x,y)))') + + def test_distinct_letters_give_distinct_children(self, binary): + assert binary.check( + 'all x.(all y.(all z.((S0(x,y) & S1(x,z)) -> (not Eq(y,z)))))') + + +class TestConstruction: + def test_branching_must_be_positive(self): + with pytest.raises(ValueError, match=">= 1"): + RegularTree(0) + + def test_repr(self, binary): + assert repr(binary) == "" + + def test_a_ray_is_the_one_ary_tree(self): + ray = RegularTree(1) + # exactly one child each: the tree degenerates to a chain + assert ray.check('all x.(exists y.(S0(x,y)))') + assert ray.check('all x.(all y.(all z.((Child(x,y) & Child(x,z)) ' + '-> Eq(y,z))))') + + def test_a_wider_tree(self): + wide = RegularTree(4) + assert sorted(s for s in wide.get_relation_symbols() + if s.startswith('S')) == ['S0', 'S1', 'S2', 'S3'] + S = wide.symbolic() + x, y = S.vars('x y') + assert x.adj(y).evaluate().contains(x=(3,), y=(3, 2)) From bc686ce48a2c3ed2b2f5c13ceb54996699e5857b Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 12 Aug 2026 17:16:33 +0200 Subject: [PATCH 32/76] Turing-machine configuration graphs, and where FO stops A configuration is a word -- the tape, with the cell under the head naming both the state and what is written there -- and one step rewrites it locally, so the step relation is a synchronous automaton whose size depends on the transition table alone. That makes the graph's first-order theory decidable, and `TuringMachine.configuration_graph()` lets you ask it: determinism, halting configurations, bounded step counts. Reachability is deliberately absent. It is the halting problem, so it is not first-order over this graph and no construction here can supply it: an automatic presentation with a decidable theory still cannot express the one question you would most like to ask. `Halt` is what can be had, and it is defined from the edge rather than authored. The tape is one-way infinite and trailing blanks are dropped, so each configuration has exactly one word and a step changes that word's length by at most one cell, at the right end -- which is what keeps the relation synchronous. `TuringMachine.step` runs the machine in Python and is the oracle the tests check every edge against. Co-Authored-By: Claude Opus 5 --- autstr/turing.py | 329 +++++++++++++++++++++++++++++++++++++++++++ tests/test_turing.py | 165 ++++++++++++++++++++++ 2 files changed, 494 insertions(+) create mode 100644 autstr/turing.py create mode 100644 tests/test_turing.py diff --git a/autstr/turing.py b/autstr/turing.py new file mode 100644 index 0000000..724e870 --- /dev/null +++ b/autstr/turing.py @@ -0,0 +1,329 @@ +"""Turing machines and their configuration graphs. + +The configuration graph of a Turing machine is the standard example of an +automatic structure that is genuinely *about* computation: a configuration is a +word, and one step of the machine rewrites that word locally, at the head, so +the step relation is recognized by a small synchronous automaton — one whose +size depends only on the transition table, not on the tape. + +That makes the first-order theory of the graph decidable, and this module lets +you ask it:: + + >>> machine = TuringMachine( + ... transitions={('q', '1'): ('q', '1', 'R'), + ... ('q', '_'): ('halt', '1', 'S')}, + ... blank='_') + >>> graph = machine.configuration_graph() + >>> graph.check('all x.(all y.(all z.((E(x,y) & E(x,z)) -> Eq(y,z))))') + True + +**The boundary lesson.** Reachability — "does some sequence of steps lead from +this configuration to a halting one?" — is exactly the halting problem, so it +is undecidable, and therefore *not first-order definable* over this graph. +Nothing here provides it, and nothing can: the transitive closure of ``E`` is +outside FO. A decidable first-order theory is not a decidable graph. It is the +same lesson as the random graph in reverse — there, a decidable theory without +an automatic presentation; here, an automatic presentation whose decidable +theory still cannot express the one question you would most like to ask. + +**Encoding.** The tape is one-way infinite, to the right. A configuration is +the word ``left · (state, symbol) · right``: the tape contents, with the cell +under the head replaced by a symbol naming both the state and what is written +there. Trailing blanks are dropped, so each configuration has exactly one word. +A step changes the word only at the head and, at most, by one cell at the right +end, which is why a synchronous automaton can read it. A left move off cell 0 +has no successor, as does a configuration whose ``(state, symbol)`` pair the +transition table does not list — those are the halting configurations, and +``Halt`` is defined from ``E`` rather than authored. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Optional, Sequence, Tuple + +from autstr.infinite_graphs import InfiniteGraph +from autstr.symbolic import FunctionCodec +from autstr.utils.automata_tools import partial_dfa + +#: the padding symbol of the configuration encoding +_PAD = '*' + +#: how a transition may move the head +DIRECTIONS = ('L', 'R', 'S') + + +@dataclass(frozen=True) +class Configuration: + """A machine configuration: the control state, the tape, and where the + head is. + + :param state: the control state. + :param tape: the tape contents from cell 0, as a tuple of tape symbols. + :param head: the index of the cell under the head. + """ + state: str + tape: Tuple[str, ...] + head: int = 0 + + def __post_init__(self): + if not 0 <= self.head < max(len(self.tape), 1): + raise ValueError( + f"the head is at cell {self.head}, off a tape of " + f"{len(self.tape)} cell(s)") + + +class TuringMachine: + """A deterministic single-tape Turing machine with a one-way infinite + tape. + + :param transitions: ``{(state, read): (state, write, direction)}``, with + direction one of ``'L'``, ``'R'``, ``'S'``. A pair the table omits is + halting. + :param blank: the blank symbol, which fills the tape beyond what has been + written. + :param states: the control states. Read off the table when omitted. + :param tape_alphabet: the tape symbols. Read off the table when omitted; + the blank always belongs. + """ + + def __init__(self, transitions: Dict[Tuple[str, str], Tuple[str, str, str]], + blank: str = '_', states: Optional[Sequence[str]] = None, + tape_alphabet: Optional[Sequence[str]] = None) -> None: + self.blank = blank + self.transitions = dict(transitions) + + mentioned_states, mentioned_symbols = set(), {blank} + for (state, read), (target, write, direction) in self.transitions.items(): + if direction not in DIRECTIONS: + raise ValueError( + f"{direction!r} is not a direction; use one of " + f"{', '.join(DIRECTIONS)}") + mentioned_states |= {state, target} + mentioned_symbols |= {read, write} + + self.states = sorted(mentioned_states | set(states or ())) + self.tape_alphabet = sorted(mentioned_symbols | set(tape_alphabet or ())) + if blank not in self.tape_alphabet: + raise ValueError(f"the blank {blank!r} is not a tape symbol") + for symbol in self.tape_alphabet + self.states: + if symbol == _PAD: + raise ValueError(f"{_PAD!r} is reserved as the padding symbol") + + #: the head letters: one per (state, symbol) pair + self._head_letters = {(q, a): f'{q}|{a}' + for q in self.states for a in self.tape_alphabet} + self.alphabet = (list(self.tape_alphabet) + + sorted(self._head_letters.values()) + [_PAD]) + + # -- the machine itself -------------------------------------------- + def canonical(self, configuration: Configuration) -> Configuration: + """The configuration with trailing blanks past the head dropped — the + one word that stands for it.""" + tape = list(configuration.tape) or [self.blank] + while len(tape) - 1 > configuration.head and tape[-1] == self.blank: + tape.pop() + return Configuration(configuration.state, tuple(tape), + configuration.head) + + def step(self, configuration: Configuration) -> Optional[Configuration]: + """The successor configuration, or None if there is none — the + transition table does not cover this ``(state, symbol)`` pair, or the + head would move off the left end of the tape. + + This is the Python oracle the automaton is checked against, and the + obvious way to run the machine. + """ + configuration = self.canonical(configuration) + tape = list(configuration.tape) + key = (configuration.state, tape[configuration.head]) + if key not in self.transitions: + return None + state, write, direction = self.transitions[key] + + tape[configuration.head] = write + head = configuration.head + {'L': -1, 'R': 1, 'S': 0}[direction] + if head < 0: + return None + if head == len(tape): + tape.append(self.blank) + return self.canonical(Configuration(state, tuple(tape), head)) + + def run(self, configuration: Configuration, limit: int = 1000): + """The run from a configuration: it, then its successors, stopping when + the machine halts or after `limit` steps.""" + current = self.canonical(configuration) + yield current + for _ in range(limit): + current = self.step(current) + if current is None: + return + yield current + + # -- the configuration graph --------------------------------------- + def configuration_graph(self) -> 'ConfigurationGraph': + """The graph of configurations under one step of the machine.""" + return ConfigurationGraph(self) + + # -- element codec: a configuration <-> its word -------------------- + def encode(self, configuration: Configuration) -> list: + """The word encoding a configuration.""" + configuration = self.canonical(configuration) + word = list(configuration.tape) + head = configuration.head + word[head] = self._head_letters[(configuration.state, word[head])] + return word + + def decode(self, word) -> Configuration: + """The configuration a word encodes.""" + tape, state, head = [], None, None + for position, symbol in enumerate(word): + if symbol == _PAD: + break + if '|' in symbol: + state, _, cell = symbol.partition('|') + head = position + tape.append(cell) + else: + tape.append(symbol) + if head is None: + raise ValueError(f"{word!r} carries no head, so it is no " + f"configuration") + return Configuration(state, tuple(tape), head) + + +class ConfigurationGraph: + """The configuration graph of a `TuringMachine`: configurations as + vertices, one machine step as a directed edge. + + The presentation carries ``E`` (one step), ``Eq``, and ``Halt`` — the + configurations with no successor, *defined* as ``not exists y. E(x,y)`` + rather than authored, since the engine can compute it. + + Reachability is deliberately absent; see the module docstring. + """ + + def __init__(self, machine: TuringMachine) -> None: + from autstr.presentations import AutomaticPresentation + self.machine = machine + + presentation = AutomaticPresentation( + {'U': _configurations(machine), + 'Eq': _identity(machine), + 'E': _step(machine)}, + padding_symbol=_PAD) + presentation.update(Halt='not exists y.(E(x,y))') + self.presentation = presentation + self.graph = InfiniteGraph( + presentation, edge='E', directed=True, + codec=FunctionCodec(machine.encode, machine.decode)) + + def symbolic(self, signature=None): + """A symbolic interface to the graph; write one step as ``x.adj(y)`` + and configurations as `Configuration` values.""" + return self.graph.symbolic(signature) + + def check(self, phi) -> bool: + return self.graph.check(phi) + + def evaluate(self, phi): + return self.graph.evaluate(phi) + + def get_relation_symbols(self): + return self.presentation.get_relation_symbols() + + def is_deterministic(self) -> bool: + """Whether every configuration has at most one successor — a + first-order question, and so decidable here, unlike reachability.""" + return self.presentation.check( + 'all x.(all y.(all z.((E(x,y) & E(x,z)) -> Eq(y,z))))') + + def __repr__(self): + return (f"") + + +# ---------------------------------------------------------------------- +# the automata +# ---------------------------------------------------------------------- + +def _configurations(machine: TuringMachine): + """The universe: words with exactly one head letter, not ending in a + blank — so each configuration has exactly one encoding.""" + blank, heads = machine.blank, machine._head_letters.values() + plain = [a for a in machine.tape_alphabet if a != blank] + + table = { + # before the head, a blank is an ordinary cell: what follows keeps it + # from being trailing + 'before': {(blank,): 'before', **{(a,): 'before' for a in plain}, + **{(h,): 'after' for h in heads}}, + # after the head, a word may not stop on a blank — that encoding is + # the same configuration as the word without it + 'after': {(blank,): 'trailing', **{(a,): 'after' for a in plain}}, + 'trailing': {(blank,): 'trailing', **{(a,): 'after' for a in plain}}, + } + return partial_dfa(set(machine.alphabet), 1, table, 'before', {'after'}) + + +def _identity(machine: TuringMachine): + """``x = y`` on configuration words.""" + letters = [a for a in machine.alphabet if a != _PAD] + return partial_dfa(set(machine.alphabet), 2, + {'s': {(a, a): 's' for a in letters}}, 's', {'s'}) + + +def _step(machine: TuringMachine): + """One machine step, as a synchronous two-tape automaton. + + Reading the two words in lockstep, the automaton walks the agreeing prefix, + applies the local rewrite where the head is, and walks the agreeing suffix. + A left move is the one case needing lookahead — the head lands on the cell + *before* the one being read — and is handled by remembering, for one + symbol, which control state the target word's head announced. + """ + blank, pad = machine.blank, _PAD + head_letter = machine._head_letters + cells = machine.tape_alphabet + + # the agreeing prefix, and the two ways a rewrite can start + prefix = {(a, a): 'prefix' for a in cells} + for (state, read), (target, write, direction) in machine.transitions.items(): + source = head_letter[(state, read)] + if direction == 'R': + # the head letter becomes what was written; the head reappears on + # the next cell, which the target word announces + prefix[(source, write)] = f'right:{target}' + elif direction == 'S': + prefix[(source, head_letter[(target, write)])] = 'suffix' + else: # 'L': the head moves back one + for cell in cells: + # the target word already carries its head on the cell just + # read — unchanged, which this pair checks — so remember the + # state it announced and check the rewrite on the next symbol + prefix[(cell, head_letter[(target, cell)])] = f'left:{target}' + + table = {'prefix': prefix, + 'suffix': {(a, a): 'suffix' for a in cells}, + 'end': {}} + + for state in machine.states: + # a right move: the target's head sits on the next cell, which holds + # whatever the source had there — or a blank, when the tape grows + table[f'right:{state}'] = { + **{(a, head_letter[(state, a)]): 'suffix' for a in cells}, + (pad, head_letter[(state, blank)]): 'end', + } + + for (state, read), (target, write, direction) in machine.transitions.items(): + if direction != 'L': + continue + source = head_letter[(state, read)] + row = table.setdefault(f'left:{target}', {}) + row[(source, write)] = 'suffix' + if write == blank: + # the written blank would now be the last cell: it is dropped, and + # the target word is one shorter + row[(source, pad)] = 'end' + + return partial_dfa(set(machine.alphabet), 2, table, 'prefix', + {'suffix', 'end'}) diff --git a/tests/test_turing.py b/tests/test_turing.py new file mode 100644 index 0000000..0f54dde --- /dev/null +++ b/tests/test_turing.py @@ -0,0 +1,165 @@ +"""Turing-machine configuration graphs. + +The oracle is the machine itself: `TuringMachine.step` runs one step in Python, +and the edge relation must agree with it on every pair of configurations up to +a bounded tape length. Three machines exercise the three directions, including +the two cases where the encoding changes length — a right move off the written +tape, and a left move that writes a blank into the last cell. +""" +import itertools + +import pytest + +from autstr.turing import Configuration, TuringMachine + + +#: walks right over 1s, writes a 1 into the first blank and stops +SCANNER = TuringMachine({('q', '1'): ('q', '1', 'R'), + ('q', '_'): ('h', '1', 'S')}, blank='_') + +#: walks left erasing as it goes — the encoding shrinks at every step +ERASER = TuringMachine({('q', '1'): ('q', '_', 'L'), + ('q', '_'): ('q', '_', 'L')}, blank='_') + +#: flips a bit in place, then moves right; two states, two symbols +FLIPPER = TuringMachine({('a', '0'): ('b', '1', 'R'), + ('a', '1'): ('b', '0', 'R'), + ('b', '0'): ('a', '0', 'L'), + ('b', '1'): ('a', '1', 'L')}, blank='0') + + +def configurations(machine, length: int): + """Every canonical configuration with a tape of at most `length` cells.""" + found = [] + for size in range(1, length + 1): + for tape in itertools.product(machine.tape_alphabet, repeat=size): + for head in range(size): + for state in machine.states: + configuration = Configuration(state, tape, head) + if machine.canonical(configuration) == configuration: + found.append(configuration) + return found + + +@pytest.fixture(scope="module") +def scanner_graph(): + return SCANNER.configuration_graph() + + +class TestMachine: + def test_step_runs_the_machine(self): + run = list(SCANNER.run(Configuration('q', ('1', '1'), 0))) + assert run[-1] == Configuration('h', ('1', '1', '1'), 2) + + def test_a_missing_transition_halts(self): + assert SCANNER.step(Configuration('h', ('1',), 0)) is None + + def test_a_left_move_off_the_tape_halts(self): + assert ERASER.step(Configuration('q', ('1',), 0)) is None + + def test_trailing_blanks_are_dropped(self): + canonical = SCANNER.canonical(Configuration('q', ('1', '_', '_'), 0)) + assert canonical == Configuration('q', ('1',), 0) + + def test_the_head_cell_is_never_dropped(self): + configuration = Configuration('q', ('1', '_'), 1) + assert SCANNER.canonical(configuration) == configuration + + def test_an_unknown_direction_is_rejected(self): + with pytest.raises(ValueError, match="not a direction"): + TuringMachine({('q', '1'): ('q', '1', 'up')}) + + def test_the_padding_symbol_is_reserved(self): + with pytest.raises(ValueError, match="reserved"): + TuringMachine({('q', '*'): ('q', '*', 'R')}) + + def test_the_head_is_on_the_tape(self): + with pytest.raises(ValueError, match="off a tape"): + Configuration('q', ('1',), 3) + + +class TestCodec: + def test_roundtrip(self): + for configuration in configurations(SCANNER, 3): + assert SCANNER.decode(SCANNER.encode(configuration)) == configuration + + def test_the_word_marks_the_head_cell(self): + assert SCANNER.encode(Configuration('q', ('1', '_'), 1)) == ['1', 'q|_'] + + def test_a_word_without_a_head_is_no_configuration(self): + with pytest.raises(ValueError, match="no head"): + SCANNER.decode(['1', '1']) + + +class TestStepRelation: + """The edge relation, checked exhaustively against the Python oracle.""" + + @pytest.mark.parametrize("machine", [SCANNER, ERASER, FLIPPER], + ids=['scanner', 'eraser', 'flipper']) + def test_agrees_with_the_oracle(self, machine): + graph = machine.configuration_graph() + x, y = graph.symbolic().vars('x y') + edge = x.adj(y).evaluate() + + space = configurations(machine, 3) + for source in space: + successor = machine.step(source) + for target in space: + assert edge.contains(x=source, y=target) == \ + (successor == target), (source, target) + + def test_the_tape_may_grow(self, scanner_graph): + x, y = scanner_graph.symbolic().vars('x y') + edge = x.adj(y).evaluate() + # the head steps off the written tape, and the word gains a cell + assert edge.contains(x=Configuration('q', ('1',), 0), + y=Configuration('q', ('1', '_'), 1)) + + def test_the_tape_may_shrink(self): + graph = ERASER.configuration_graph() + x, y = graph.symbolic().vars('x y') + edge = x.adj(y).evaluate() + # erasing the last cell drops it from the encoding + assert edge.contains(x=Configuration('q', ('1', '1'), 1), + y=Configuration('q', ('1',), 0)) + + +class TestFirstOrderTheory: + def test_the_machine_is_deterministic(self, scanner_graph): + assert scanner_graph.is_deterministic() + + def test_halting_configurations_are_definable(self, scanner_graph): + S = scanner_graph.symbolic() + halting = S.rel('Halt')(S.vars('x')[0]).evaluate() + assert halting.contains(x=Configuration('h', ('1',), 0)) + assert not halting.contains(x=Configuration('q', ('1',), 0)) + + def test_a_stuck_left_move_halts(self): + graph = ERASER.configuration_graph() + S = graph.symbolic() + halting = S.rel('Halt')(S.vars('x')[0]).evaluate() + # the head cannot leave cell 0, so this configuration has no successor + assert halting.contains(x=Configuration('q', ('1',), 0)) + assert not halting.contains(x=Configuration('q', ('1', '1'), 1)) + + def test_some_configuration_halts_and_some_does_not(self, scanner_graph): + assert scanner_graph.check('exists x.(Halt(x))') + assert scanner_graph.check('exists x.(not Halt(x))') + + def test_every_configuration_has_at_most_one_successor(self): + assert FLIPPER.configuration_graph().is_deterministic() + + +class TestTheBoundary: + """Reachability is the halting problem, so it is not first-order over this + graph and the construction does not offer it. What is offered is exactly + what the engine can decide.""" + + def test_no_reachability_relation_is_provided(self, scanner_graph): + assert sorted(scanner_graph.get_relation_symbols()) == \ + ['E', 'Eq', 'Halt', 'U'] + + def test_one_step_reachability_is_fine(self, scanner_graph): + # bounded step counts stay first-order: they are just nested edges + assert scanner_graph.check( + 'exists x.(exists y.(exists z.(E(x,y) & E(y,z) & Halt(z))))') From f427131c39c1b13a59faa980eb68fa81bf139e96 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 12 Aug 2026 17:17:53 +0200 Subject: [PATCH 33/76] Call partial_dfa directly The local wrapper only re-wrapped its arguments as sets. Co-Authored-By: Claude Opus 5 --- autstr/infinite_graphs.py | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/autstr/infinite_graphs.py b/autstr/infinite_graphs.py index ca343c5..791af59 100644 --- a/autstr/infinite_graphs.py +++ b/autstr/infinite_graphs.py @@ -17,6 +17,7 @@ from typing import Optional, Sequence, Tuple from autstr.symbolic import FunctionCodec, graph_signature +from autstr.utils.automata_tools import partial_dfa class InfiniteGraph: @@ -225,7 +226,7 @@ def __init__(self, k: int = 2) -> None: raise ValueError("the branching degree must be >= 1") self.k = k self.letters = [str(i) for i in range(k)] - alphabet = self.letters + [_PAD] + alphabet = set(self.letters) | {_PAD} from autstr.presentations import AutomaticPresentation automata = {'U': _words(alphabet, self.letters), @@ -284,23 +285,18 @@ def __repr__(self): return f"" -def _dfa(alphabet, arity: int, transitions, initial: str, final): - from autstr.utils.automata_tools import partial_dfa - return partial_dfa(set(alphabet), arity, transitions, initial, set(final)) - - def _words(alphabet, letters): """The universe: every word over the child letters, the root included.""" - return _dfa(alphabet, 1, - {'w': {(letter,): 'w' for letter in letters}}, - initial='w', final={'w'}) + return partial_dfa(alphabet, 1, + {'w': {(letter,): 'w' for letter in letters}}, + initial='w', final={'w'}) def _identity(alphabet, letters): """``x = y``, on the convolution of two words.""" - return _dfa(alphabet, 2, - {'s': {(letter, letter): 's' for letter in letters}}, - initial='s', final={'s'}) + return partial_dfa(alphabet, 2, + {'s': {(letter, letter): 's' for letter in letters}}, + initial='s', final={'s'}) def _append(alphabet, letters, letter): @@ -309,7 +305,7 @@ def _append(alphabet, letters, letter): table = {'s': {(a, a): 's' for a in letters}} table['s'][(_PAD, letter)] = 'done' table['done'] = {} - return _dfa(alphabet, 2, table, initial='s', final={'done'}) + return partial_dfa(alphabet, 2, table, initial='s', final={'done'}) def _prefix(alphabet, letters): @@ -318,4 +314,5 @@ def _prefix(alphabet, letters): table = {'s': {(a, a): 's' for a in letters}, 'below': {(_PAD, a): 'below' for a in letters}} table['s'].update({(_PAD, a): 'below' for a in letters}) - return _dfa(alphabet, 2, table, initial='s', final={'s', 'below'}) + return partial_dfa(alphabet, 2, table, initial='s', + final={'s', 'below'}) From 3e276353f1167d8ef403cdc86bdf23a764e9866d Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 12 Aug 2026 17:21:03 +0200 Subject: [PATCH 34/76] List the infinite structures and interpretations in the README Co-Authored-By: Claude Opus 5 --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 431f9fd..7c12a35 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,7 @@ batch: |---------|------------| | `autstr.arithmetic`, `autstr.buildin` | Presburger and Büchi arithmetic (ℤ, +, <, \|₂), Skolem arithmetic (ℕ, ·), the MSO0 finite-powerset structure | | `autstr.algebra` | the localizations **ℤ[1/p]**, finite **Boolean algebras** | +| `autstr.infinite_graphs`, `autstr.ordinals`, `autstr.turing` | the **integer grid** ℤⁿ, the **regular tree** T_k, the **ordinals** below ω^ω, **Turing-machine configuration graphs** | **Classes** — one automaton for a whole family, indexed by advice: @@ -140,10 +141,16 @@ batch: | `autstr.groups`, `autstr.tree_groups` | finite **abelian** groups, **index-≤2 cyclic** groups (dihedral, quaternion, semidihedral, modular), **extraspecial** p-groups, class-2 groups of bounded **rank-width** (over F_p or ℤ/pᵈ) | group multiplication `M` | | `autstr.cocycle_groups` | **distributed-center** class-2 groups of bounded rank-width | multiplication `M` | -Three capabilities cut across all of these: +Four capabilities cut across all of these: - **Composition** (`autstr.composition`) — disjoint union and direct products of structures, and union and finite-product closure of classes. +- **First-order interpretations** (`autstr.interpretations`) — automatic + structures are closed under FO-interpretations, and `interpret` *computes* the + interpreted presentation: a domain formula, a formula per relation, elements as + k-tuples, optionally quotiented by a definable equivalence. It is how the + ordinals are built — three formulas over Büchi arithmetic, no automaton + authored. - **Implicit evaluation** (`autstr.implicit`) — `check_implicit` / `evaluate_implicit` decide formulas and compute satisfying sets *without building a query automaton*, reaching members whose automata are far too large From 10db3137e7d2ff9c56b7f1ce5cbaa1029e984d10 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 12 Aug 2026 17:21:52 +0200 Subject: [PATCH 35/76] Test partial_dfa directly It was only exercised through the structures built on it. Co-Authored-By: Claude Opus 5 --- tests/test_automata_tools.py | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/tests/test_automata_tools.py b/tests/test_automata_tools.py index e0b5ee0..6b3b8e8 100644 --- a/tests/test_automata_tools.py +++ b/tests/test_automata_tools.py @@ -12,7 +12,8 @@ from autstr.sparse_automata import SparseDFA from autstr.utils.automata_tools import ( - expand, fold_tapes, pad, permute_tapes, projection, shortlex_order, unpad, + expand, fold_tapes, pad, partial_dfa, permute_tapes, projection, + shortlex_order, unpad, ) @@ -232,3 +233,36 @@ def test_length_dominates_lexicographic(self): order = shortlex_order({0, 1, 2}, 0) # 0 pad, letters 1, 2 assert order.accepts([(2, 1), (0, 1)]) # [2] < [1,1] (shorter) assert not order.accepts([(1, 1), (1, 0)]) # [1,1] not <= [1] + + +class TestPartialDFA: + """A transition table with the rejecting majority left out.""" + + def test_unlisted_symbols_reject(self): + # accepts exactly the word 'ab' over {a, b} + dfa = partial_dfa({'a', 'b'}, 1, + {'0': {('a',): '1'}, '1': {('b',): '2'}, '2': {}}, + initial='0', final={'2'}) + assert dfa.accepts([('a',), ('b',)]) + for word in [[], [('a',)], [('b',), ('a',)], [('a',), ('b',), ('a',)]]: + assert not dfa.accepts(word), word + + def test_a_product_alphabet_needs_only_the_listed_pairs(self): + # the diagonal over a 3-letter alphabet: 3 entries, not 9 + letters = {'a', 'b', 'c'} + table = {'s': {(letter, letter): 's' for letter in letters}} + diagonal = partial_dfa(letters, 2, table, 's', {'s'}) + assert diagonal.accepts([('a', 'a'), ('c', 'c')]) + assert not diagonal.accepts([('a', 'a'), ('c', 'b')]) + + def test_an_unreachable_start_state_is_rejected(self): + with pytest.raises(ValueError, match="start state"): + partial_dfa({'a'}, 1, {'s': {}}, initial='t', final={'s'}) + + def test_a_target_without_a_row_is_rejected(self): + with pytest.raises(ValueError, match="without a row"): + partial_dfa({'a'}, 1, {'s': {('a',): 'gone'}}, 's', {'s'}) + + def test_an_accepting_state_without_a_row_is_rejected(self): + with pytest.raises(ValueError, match="without a row"): + partial_dfa({'a'}, 1, {'s': {}}, 's', {'other'}) From eef8c4276243747dbd18591fa19588b367059220 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 12 Aug 2026 17:35:08 +0200 Subject: [PATCH 36/76] Pin the sentence-under-connective behaviour All four connectives against their truth tables, on both operand orders, for sentence-sentence and sentence-beside-relation; plus the degenerate structure the marker convention exists for -- over an empty universe a universal sentence is vacuously true, which is why a true sentence is the all-marker and not the universe, while an open formula beside it still collapses to nothing. Co-Authored-By: Claude Opus 5 --- tests/test_presentations.py | 137 +++++++++++++++++++++++++++--------- 1 file changed, 104 insertions(+), 33 deletions(-) diff --git a/tests/test_presentations.py b/tests/test_presentations.py index 6a75531..5298437 100644 --- a/tests/test_presentations.py +++ b/tests/test_presentations.py @@ -6,10 +6,16 @@ unrestricted relation makes a universal sentence look false on encodings that are not elements at all. """ +import itertools + +import pytest + +from autstr.buildin.automata import zero from autstr.buildin.presentations import ( BuechiArithmetic, BuechiArithmeticZ, MSO0, ) from autstr.interpretations import interpret +from autstr.presentations import AutomaticPresentation def _leaks(presentation, name) -> bool: @@ -53,48 +59,113 @@ def _same_language(one, other) -> bool: other.intersection(one.complement()).is_empty()) +#: a true and a false sentence over Buechi arithmetic +TRUE, FALSE = 'all x.(Eq(x,x))', 'all x.(Lt(x,x))' + +#: the binary connectives, with their Python truth tables +CONNECTIVES = { + '&': lambda a, b: a and b, + '|': lambda a, b: a or b, + '->': lambda a, b: (not a) or b, + '<->': lambda a, b: a == b, +} + + +def _convolution(a: int, b: int): + """The two-tape encoding of a pair of naturals, padded to equal length.""" + wa, wb = list(format(a, 'b')[::-1]), list(format(b, 'b')[::-1]) + n = max(len(wa), len(wb)) + return list(zip(wa + ['*'] * (n - len(wa)), wb + ['*'] * (n - len(wb)))) + + class TestSentencesUnderConnectives: """A sentence has no free variables, so it evaluates to the all/none - marker rather than to a relation with tapes. Placing that marker into an - enclosing conjunction or disjunction used to be attempted as a tape - renaming, which raised `IndexError` — for two sentences there is no tape to - rename to at all. + marker rather than to a relation with tapes: an arity-1 automaton that is + non-empty exactly when the sentence is true. + + The marker is deliberately not the universe -- over an empty structure a + universal sentence is still vacuously true -- so placing it into an + enclosing formula is not a tape renaming. Attempting one raised IndexError, + and for two sentences there was no tape to rename to at all. """ - def test_two_sentences_conjoined(self): + @pytest.mark.parametrize("connective", list(CONNECTIVES)) + @pytest.mark.parametrize("left,right", list(itertools.product([True, False], + repeat=2))) + def test_two_sentences(self, connective, left, right): arithmetic = BuechiArithmetic() - true, false = 'all x.(Eq(x,x))', 'all x.(Lt(x,x))' - assert arithmetic.check(f'({true}) & ({true})') - assert not arithmetic.check(f'({true}) & ({false})') - assert not arithmetic.check(f'({false}) & ({true})') + phi = (f'({TRUE if left else FALSE}) {connective} ' + f'({TRUE if right else FALSE})') + expected = CONNECTIVES[connective](left, right) + assert arithmetic.check(phi) == expected + assert arithmetic.check(f'not ({phi})') == (not expected) - def test_two_sentences_disjoined(self): + def test_sentences_nest(self): arithmetic = BuechiArithmetic() - true, false = 'all x.(Eq(x,x))', 'all x.(Lt(x,x))' - assert arithmetic.check(f'({false}) | ({true})') - assert not arithmetic.check(f'({false}) | ({false})') - - def test_a_sentence_beside_an_open_formula(self): + assert arithmetic.check(f'(({TRUE}) | ({FALSE})) & (not ({FALSE}))') + assert arithmetic.check( + f'all y.((({TRUE}) & ({TRUE})) & Eq(y,y))') + assert not arithmetic.check(f'(all w.({TRUE})) & (exists w.({FALSE}))') + + @pytest.mark.parametrize("connective", list(CONNECTIVES)) + @pytest.mark.parametrize("truth", [True, False]) + @pytest.mark.parametrize("first", [True, False]) + def test_a_sentence_beside_an_open_formula(self, connective, truth, first): """The sentence contributes its truth value and the open formula its - relation: a true conjunct must leave the relation alone, a false one - must empty it, and a true disjunct must fill it with the whole - universe.""" + relation, on either side of the connective.""" arithmetic = BuechiArithmetic() - true, false = 'all x.(Eq(x,x))', 'all x.(Lt(x,x))' - - less = arithmetic.evaluate('Lt(y,z)') - assert _same_language(arithmetic.evaluate(f'({true}) & Lt(y,z)'), less) - assert arithmetic.evaluate(f'({false}) & Lt(y,z)').is_empty() - - assert _same_language(arithmetic.evaluate(f'({true}) | Lt(y,z)'), - arithmetic._domain_product(2)) - assert _same_language(arithmetic.evaluate(f'({false}) | Lt(y,z)'), less) - - def test_a_true_disjunct_does_not_admit_non_elements(self): - """The full relation of a structure is the product of its universes, - not every word: a true sentence disjoined with an open formula must - still reject encodings that are not elements.""" + sentence = TRUE if truth else FALSE + phi = (f'({sentence}) {connective} Lt(y,z)' if first + else f'Lt(y,z) {connective} ({sentence})') + relation = arithmetic.evaluate(phi) + + for a, b in itertools.product(range(4), repeat=2): + expected = (CONNECTIVES[connective](truth, a < b) if first + else CONNECTIVES[connective](a < b, truth)) + assert relation.accepts(_convolution(a, b)) == expected, (a, b) + + def test_a_true_operand_is_the_universe_not_every_word(self): + """A subformula's automaton is a relation over the universe. The + marker is not -- it accepts every word -- so a true sentence beside an + open formula must contribute the product of universes instead, or the + result admits encodings that are not elements.""" arithmetic = BuechiArithmetic() - filled = arithmetic.evaluate('(all x.(Eq(x,x))) | Lt(y,z)') + filled = arithmetic.evaluate(f'({TRUE}) | Lt(y,z)') assert not filled.accepts([('0', '0'), ('0', '1')]) # '00' is not a assert filled.accepts([('0', '0')]) # well-formed 0 + assert _same_language(filled, arithmetic._domain_product(2)) + + kept = arithmetic.evaluate(f'({TRUE}) & Lt(y,z)') + assert _same_language(kept, arithmetic.evaluate('Lt(y,z)')) + assert arithmetic.evaluate(f'({FALSE}) & Lt(y,z)').is_empty() + + +class TestSentencesOverAnEmptyUniverse: + """The degenerate case the marker convention exists for: over an empty + structure every universal sentence is vacuously true, which is why a true + sentence is the all-marker and not the universe. An open formula beside it + still collapses, since every relation over an empty universe is empty. + """ + + @staticmethod + def _empty(): + sigma = {'0', '1', '*'} + return AutomaticPresentation( + {'U': zero(1, sigma), 'Eq': zero(2, sigma), 'Lt': zero(2, sigma)}, + padding_symbol='*') + + def test_a_universal_sentence_is_vacuously_true(self): + assert self._empty().check('all x.(Lt(x,x))') + + def test_an_existential_sentence_is_false(self): + assert not self._empty().check('exists x.(Eq(x,x))') + + def test_vacuous_truth_survives_a_connective(self): + empty = self._empty() + assert empty.check('(all x.(Lt(x,x))) & (all x.(Lt(x,x)))') + assert empty.check('(all x.(Lt(x,x))) | (exists x.(Eq(x,x)))') + assert not empty.check('(all x.(Lt(x,x))) & (exists x.(Eq(x,x)))') + + def test_an_open_formula_beside_it_is_still_empty(self): + empty = self._empty() + assert empty.evaluate('(all x.(Lt(x,x))) | Lt(y,z)').is_empty() From f21fa179a30b43fc76fd033ed86be826f447cb01 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 12 Aug 2026 17:46:49 +0200 Subject: [PATCH 37/76] Escape the alphabet-size expression in partial_dfa's docstring Bare pipes read as a substitution reference in RST, so the docs build warned. Co-Authored-By: Claude Opus 5 --- autstr/utils/automata_tools.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/autstr/utils/automata_tools.py b/autstr/utils/automata_tools.py index 8e1f7fe..1882435 100644 --- a/autstr/utils/automata_tools.py +++ b/autstr/utils/automata_tools.py @@ -754,8 +754,8 @@ def partial_dfa(base_alphabet: Set, arity: int, Every symbol tuple a state does not list goes to a rejecting sink, which is what an automaton authored by hand almost always wants: the interesting transitions are few and the alphabet — a product of k copies of the base — - is large, so spelling the table out in full costs |Σ|^k entries per state - to say "reject" over and over. + is large, so spelling the table out in full costs ``|Σ|^k`` entries per + state to say "reject" over and over. :param base_alphabet: the base alphabet, padding symbol included. :param arity: number of tapes; the automaton reads `arity`-tuples. From c86a24bc1a3e95a2dcb9736bb3f303d0d4bb1e1a Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 12 Aug 2026 18:04:38 +0200 Subject: [PATCH 38/76] Sentences as operands of a connective, on the tree engine The same gap the string engine had: a subformula with no free variables evaluates to the all/none marker, whose single tape is a placeholder rather than a variable, and both connective branches renamed their operands' tapes into the enclosing formula's order. Meaningless for a marker, and for two sentences impossible -- there is no tape to rename to -- so `(all x. phi) & (all y. psi)` raised IndexError here too. `_operand_automaton` remakes a sentence at the enclosing arity: false becomes the empty relation, true the product of domains rather than every tree, so a true disjunct still admits nothing outside the structure. Co-Authored-By: Claude Opus 5 --- autstr/tree_presentations.py | 31 ++++++++++++++++++--- tests/test_tree_presentations.py | 46 +++++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/autstr/tree_presentations.py b/autstr/tree_presentations.py index 88112d9..c4100cb 100644 --- a/autstr/tree_presentations.py +++ b/autstr/tree_presentations.py @@ -171,6 +171,31 @@ def evaluate(self, phi, updates: Optional[Dict[str, Union[ # leave the temporary relations installed on the presentation. self.automata = backup + def _operand_automaton(self, psi, free_operand, free_vars + ) -> SparseTreeAutomaton: + """A connective's operand, placed into the enclosing formula's tape + order. + + A sentence has no tapes to place: it collapses to the all/none marker, + whose single tape is a placeholder rather than a variable, so renaming + it into the enclosing arity is meaningless (and, when the enclosing + formula is itself a sentence, impossible — there is no tape to rename + to). It is remade at the enclosing arity instead: false becomes the + empty relation, true the full one, which over a structure is the + product of domains rather than every tree. + """ + sta = self._build_automaton(psi) + arity = max(len(free_vars), 1) + if free_operand: + return expand(sta, arity, [free_vars.index(v) + for v in free_operand]) + if sta.is_empty(): + return tree_zero(arity, self.base_alphabet) + # a true sentence: the marker convention at arity 1, so that an + # enclosing sentence stays a marker; the domain otherwise + return tree_one(1, self.base_alphabet) if not free_vars \ + else self._domain_product(arity) + def _build_automaton(self, phi) -> SparseTreeAutomaton: if isinstance(phi, str): phi = logic.Expression.fromstring(phi) @@ -214,10 +239,8 @@ def _build_automaton(self, phi) -> SparseTreeAutomaton: free_vars = get_free_elementary_vars(phi) free_l = get_free_elementary_vars(phi.first) free_r = get_free_elementary_vars(phi.second) - left = expand(self._build_automaton(phi.first), len(free_vars), - [free_vars.index(v) for v in free_l]) - right = expand(self._build_automaton(phi.second), len(free_vars), - [free_vars.index(v) for v in free_r]) + left = self._operand_automaton(phi.first, free_l, free_vars) + right = self._operand_automaton(phi.second, free_r, free_vars) if isinstance(phi, logic.AndExpression): return minimize(left.intersection(right)) return minimize(left.union(right)) diff --git a/tests/test_tree_presentations.py b/tests/test_tree_presentations.py index 25f565c..e64fc12 100644 --- a/tests/test_tree_presentations.py +++ b/tests/test_tree_presentations.py @@ -7,7 +7,9 @@ from autstr.sparse_automata import SparseDFA from autstr.sparse_tree_automata import Tree from autstr.tree_presentations import TreeAutomaticPresentation -from autstr.utils.tree_automata_tools import from_string_dfa, string_chain +from autstr.utils.tree_automata_tools import ( + equivalent, from_string_dfa, minimize, string_chain, +) from test_tree_automata import random_sta # noqa: F401 (import path check) @@ -139,3 +141,45 @@ def test_addition_memberships_agree(self, engines): t_got = t_rel.accepts(string_chain(word)) assert s_got == want, (x, y, z) assert t_got == want, (x, y, z) + + +class TestSentencesUnderConnectives: + """A sentence has no free variables, so it evaluates to the all/none + marker: an arity-1 automaton, non-empty exactly when the sentence is true. + Placing it into an enclosing formula is therefore not a tape renaming -- + attempting one raised IndexError, and for two sentences there was no tape + to rename to at all. The tree engine had the same gap as the string one, + and answers the same way. + """ + + CONNECTIVES = {'&': lambda a, b: a and b, + '|': lambda a, b: a or b, + '->': lambda a, b: (not a) or b, + '<->': lambda a, b: a == b} + + @pytest.mark.parametrize("connective", list(CONNECTIVES)) + @pytest.mark.parametrize("left,right", [(True, True), (True, False), + (False, True), (False, False)]) + def test_two_sentences(self, engines, connective, left, right): + _, trees = engines + true, false = 'all x.(exists y.(Lt(x,y)))', 'exists x.(all y.(Lt(x,y)))' + phi = (f'({true if left else false}) {connective} ' + f'({true if right else false})') + assert trees.check(phi) == self.CONNECTIVES[connective](left, right) + + def test_a_sentence_beside_an_open_formula(self, engines): + _, trees = engines + true, false = 'all x.(exists y.(Lt(x,y)))', 'exists x.(all y.(Lt(x,y)))' + assert trees.check(f'({true}) & Lt(y,z)') + assert not trees.check(f'({false}) & Lt(y,z)') + assert trees.check(f'({false}) | Lt(y,z)') + + def test_a_true_operand_is_the_domain_not_every_tree(self, engines): + """A subformula's automaton is a relation over the domain. The marker + is not -- it accepts every tree -- so a true sentence beside an open + formula must contribute the product of domains instead.""" + _, trees = engines + true = 'all x.(exists y.(Lt(x,y)))' + filled = trees.evaluate(f'({true}) | Lt(y,z)') + assert equivalent(minimize(filled), + minimize(trees._domain_product(2))) From 2c017532596b8841c7009e7ebd5156707a68ab28 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 12 Aug 2026 18:04:38 +0200 Subject: [PATCH 39/76] fold_tapes for tree automata Group every k tapes of a convolution into one tape over the product alphabet, so a k-dimensional interpretation's many-tape output becomes a structure whose elements are k-tuples. Over trees that reading is immediate: the convolution of k trees is already one tree over k-tuples, so nothing about the shapes changes and only the alphabet is regrouped. Both engines encode a convolution letter as an MTBDD over its binary digits, tape-major, so this is the same diagram surgery as on words -- the existing `NodeStore.fold_tapes` primitive does the work, applied per child pair. Co-Authored-By: Claude Opus 5 --- autstr/utils/tree_automata_tools.py | 34 +++++++++++++++++++++++ tests/test_tree_tools.py | 43 ++++++++++++++++++++++++++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/autstr/utils/tree_automata_tools.py b/autstr/utils/tree_automata_tools.py index 918015f..21a62e6 100644 --- a/autstr/utils/tree_automata_tools.py +++ b/autstr/utils/tree_automata_tools.py @@ -127,6 +127,40 @@ def permute_tapes(sta: SparseTreeAutomaton, perm: List[int] pair_keys=sta.pair_keys, pair_nodes=np.array(nodes, dtype=np.int64)) +def fold_tapes(sta: SparseTreeAutomaton, k: int) -> SparseTreeAutomaton: + """Group every `k` consecutive tapes of a convolution into one tape over + the product alphabet Sigma^k. + + An automaton reading ``k * r`` tapes over Sigma becomes one reading ``r`` + tapes whose letters are k-tuples — the fold that turns the many-tape output + of a k-dimensional interpretation into a structure whose elements are + k-tuples of trees. Since the convolution of k trees is one tree over + k-tuples, an element of the interpreted structure *is* such a tree, and + nothing about the shapes changes: the fold only regroups the alphabet. + + The regrouping is the same diagram surgery as in the string engine — the + symbol diagrams are ordinary MTBDDs either way, so `NodeStore.fold_tapes` + does the work here too, applied to each child pair's diagram. No state is + added, and lexicographic tuple order matches `encode_symbol`, so encodings + line up. + """ + import itertools + if sta.symbol_arity % k: + raise ValueError( + f"arity {sta.symbol_arity} is not a multiple of k={k}") + if k == 1: + return sta + store = sta.store + product_alphabet = set( + itertools.product(sorted(sta.base_alphabet_frozen), repeat=k)) + nodes = [store.fold_tapes(int(node), sta.symbol_arity, sta.m, sta.bits, k) + for node in sta.pair_nodes.tolist()] + return SparseTreeAutomaton( + sta.num_states, sta.default_state, is_accepting=sta.is_accepting, + symbol_arity=sta.symbol_arity // k, base_alphabet=product_alphabet, + pair_keys=sta.pair_keys, pair_nodes=np.array(nodes, dtype=np.int64)) + + # ==================================================================== # Existential projection # ==================================================================== diff --git a/tests/test_tree_tools.py b/tests/test_tree_tools.py index 6c9e982..ab5a177 100644 --- a/tests/test_tree_tools.py +++ b/tests/test_tree_tools.py @@ -7,7 +7,7 @@ from autstr.sparse_tree_automata import SparseTreeAutomaton, Tree, tree_to_arrays from autstr.utils.misc import encode_symbol from autstr.utils.tree_automata_tools import ( - attach_padding, equivalent, expand, minimize, project, + attach_padding, equivalent, expand, fold_tapes, minimize, project, ) from test_tree_automata import RefDTA, random_sta, random_tree @@ -393,3 +393,44 @@ def test_depth_must_be_on_one_path(self): Tree(('*', 'a'), Tree(('*', 'a')))) assert self._automaton(k=2).accepts(forked) assert not self._automaton(k=3).accepts(forked) + + +# ==================================================================== +# fold_tapes +# ==================================================================== + +class TestFoldTapes: + """Grouping every k tapes into one product-alphabet tape must preserve the + accepted language. The grouping is contiguous and mixed-radix, so a + symbol's integer code is unchanged by the fold and the folded automaton run + on the same codes must agree with the original — a real test when m is not + a power of two, since the diagram is then genuinely rebuilt over a + different number of bits.""" + + def test_preserves_the_language(self): + rng = random.Random(2026) + for trial in range(25): + m = rng.randint(2, 3) # 3 is not a power of two + k = rng.randint(2, 3) + r = rng.randint(1, 2) + source = random_sta_arity(rng, k * r, m) + folded = fold_tapes(source, k) + assert folded.symbol_arity == r + assert len(folded.base_alphabet) == m ** k + for _ in range(20): + tree = random_tree(rng, m ** (k * r)) + arrays = tree_to_arrays_encoded(tree) + got = bool(folded.is_accepting[folded.run(*arrays)]) + want = bool(source.is_accepting[source.run(*arrays)]) + assert got == want, trial + + def test_k_one_is_the_automaton_itself(self): + rng = random.Random(7) + source = random_sta_arity(rng, 2, 3) + assert fold_tapes(source, 1) is source + + def test_arity_must_be_divisible(self): + rng = random.Random(8) + source = random_sta_arity(rng, 3, 2) + with pytest.raises(ValueError, match="multiple"): + fold_tapes(source, 2) From 245cb08f905a1444e47b200cdc8f2becde12408a Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 12 Aug 2026 18:04:38 +0200 Subject: [PATCH 40/76] First-order interpretations over the tree engine `interpret` now takes an `AutomaticPresentation` or a `TreeAutomaticPresentation` and returns a presentation of the same kind. The orchestration was already engine-agnostic -- evaluate the formulas, order the tapes, fold every k into one element tape -- so the dispatch is a small record of the three operations that differ. Skolem arithmetic is the test source: multiplication is not string-automatic, so these interpretations exist only over trees. The sparse-domain measurement carries over too -- the two-dimensional diagonal gives multiplication the same 13 states as the one-dimensional encoding. Quotients stay string-only, and say so. Picking class representatives needs a well-order on encodings; shortlex works over words because the convolution aligns positions, which makes comparing lengths free, while a tree convolution aligns shapes and a bottom-up automaton cannot compare two trees' sizes at all. Every order it can decide is settled by a finite-state summary, which the usual trick of pushing the difference deeper turns into an infinite descending chain. A caller who wants a tree quotient names the representatives with a formula and restricts the domain, which is the same construction with the choice explicit. Co-Authored-By: Claude Opus 5 --- autstr/interpretations.py | 119 +++++++++++++++---- tests/test_tree_interpretations.py | 181 +++++++++++++++++++++++++++++ 2 files changed, 277 insertions(+), 23 deletions(-) create mode 100644 tests/test_tree_interpretations.py diff --git a/autstr/interpretations.py b/autstr/interpretations.py index e8a6a2d..88d3249 100644 --- a/autstr/interpretations.py +++ b/autstr/interpretations.py @@ -24,18 +24,25 @@ Quotient interpretations (elements as classes of a definable equivalence) are supported at every dimension: pass ``quotient=ε``, and the universe is restricted to the shortlex-least representative of each class. + +**Both engines.** The source may be an `AutomaticPresentation` or a +`TreeAutomaticPresentation`, and the result is a presentation of the same kind; +the orchestration is identical because both engines encode a convolution letter +the same way. Over trees an element of a k-dimensional interpretation is a +k-tuple of trees, which *is* one tree over k-tuples — the same fold, since the +tree convolution already overlays the shapes. The one thing the tree engine +cannot do is choose quotient representatives: that needs a well-order on +encodings, and shortlex has no tree counterpart. Name the representatives with +a formula and restrict the domain instead; `_quotient` says why in full. """ from __future__ import annotations -from typing import Dict, Optional, Sequence, Tuple, Union +from dataclasses import dataclass +from typing import Callable, Dict, Optional, Sequence, Tuple, Union from nltk.sem import logic from autstr.presentations import AutomaticPresentation -from autstr.sparse_automata import SparseDFA -from autstr.utils.automata_tools import ( - fold_tapes, permute_tapes, shortlex_order, -) from autstr.utils.logic import get_free_elementary_vars #: scaffolding relations a quotient interpretation installs on the intermediate @@ -43,6 +50,49 @@ _EQUIV = '_Equiv' _LE = '_Le' +@dataclass(frozen=True) +class _Engine: + """The tape machinery of whichever engine the source belongs to. + + Interpreting is the same orchestration over words and over trees — evaluate + the formulas, order their tapes, fold every k into one element tape — and + the operations differ only in which automaton type they act on. Both + engines encode a symbol the same way (an MTBDD over the binary digits of + the convolution letter, tape-major), which is why the fold is literally the + same diagram surgery on either side. + """ + name: str + permute_tapes: Callable + fold_tapes: Callable + presentation: Callable + #: a well-order on encodings, or None where the engine has none — the + #: quotient construction needs one to pick class representatives + well_order: Optional[Callable] + + +def _engine_for(source) -> _Engine: + from autstr.tree_presentations import TreeAutomaticPresentation + if isinstance(source, TreeAutomaticPresentation): + from autstr.utils import tree_automata_tools as trees + return _Engine( + name='tree', + permute_tapes=trees.permute_tapes, + fold_tapes=trees.fold_tapes, + presentation=lambda automata, padding: TreeAutomaticPresentation( + automata, padding_symbol=padding, + max_states=source.max_states), + well_order=None) + + from autstr.utils import automata_tools as words + return _Engine( + name='string', + permute_tapes=words.permute_tapes, + fold_tapes=words.fold_tapes, + presentation=lambda automata, padding: AutomaticPresentation( + automata, padding_symbol=padding), + well_order=words.shortlex_order) + + Formula = Union[str, logic.Expression] #: a relation is a formula, or a ``(formula, coordinate-order)`` pair; the #: coordinate order lists the free variables element-major (each element's k @@ -50,13 +100,15 @@ RelationSpec = Union[Formula, Tuple[Formula, Sequence[str]]] -def interpret(source: AutomaticPresentation, domain: RelationSpec, +def interpret(source, domain: RelationSpec, relations: Dict[str, RelationSpec], dimension: int = 1, - quotient: Optional[RelationSpec] = None) -> AutomaticPresentation: + quotient: Optional[RelationSpec] = None): """The first-order interpretation of a structure in `source`. - :param source: the structure to interpret in. + :param source: the structure to interpret in — an `AutomaticPresentation` + or a `TreeAutomaticPresentation`. The result is a presentation of the + same kind. :param domain: the domain formula δ(x̄) with ``dimension`` free variables — the coordinates of one element; the new universe is the tuples satisfying it. A ``(formula, coordinate-order)`` pair fixes which free @@ -71,37 +123,41 @@ def interpret(source: AutomaticPresentation, domain: RelationSpec, are its equivalence classes: the universe is restricted to the shortlex-least representative of each class, and the relations are read on those representatives. The caller must ensure ε really is an - equivalence and that every relation is ε-invariant. - :return: a fresh `AutomaticPresentation`. For k > 1 its alphabet is the - source alphabet's k-fold product. + equivalence and that every relation is ε-invariant. **String engine + only** — see `_quotient` for why, and for what to write instead over + trees. + :return: a fresh presentation of the same kind as `source`. For k > 1 its + alphabet is the source alphabet's k-fold product. """ if dimension < 1: raise ValueError("dimension must be >= 1") + engine = _engine_for(source) if quotient is not None: - return _quotient(source, domain, relations, dimension, quotient) + return _quotient(source, domain, relations, dimension, quotient, + engine) k = dimension - universe = _fold_formula(source, domain, k) + universe = _fold_formula(source, domain, k, engine) if universe.symbol_arity != 1: raise ValueError( f"the domain formula must have {k} free variable(s) — the " f"coordinates of one element — but defines " f"{universe.symbol_arity} element(s)") - automata: Dict[str, SparseDFA] = {'U': universe} + automata = {'U': universe} for name, spec in relations.items(): if name == 'U': raise ValueError("'U' is the reserved universe symbol") - automata[name] = _fold_formula(source, spec, k) + automata[name] = _fold_formula(source, spec, k, engine) padding = source.padding_symbol if k == 1 \ else (source.padding_symbol,) * k - return AutomaticPresentation(automata, padding_symbol=padding) + return engine.presentation(automata, padding) -def _quotient(source: AutomaticPresentation, domain: RelationSpec, +def _quotient(source, domain: RelationSpec, relations: Dict[str, RelationSpec], dimension: int, - equivalence: RelationSpec) -> AutomaticPresentation: + equivalence: RelationSpec, engine: _Engine): """Interpret with a quotient, in two stages: first the plain (k-dim) interpretation carrying the equivalence as a relation, then a one-dimensional interpretation restricting the universe to the @@ -111,13 +167,31 @@ def _quotient(source: AutomaticPresentation, domain: RelationSpec, shortlex well-order on the element encoding — ``x`` is canonical iff it is ``<=`` every element equivalent to it — so the whole thing stays inside the engine. + + That last step is what the tree engine cannot supply. Picking a + representative needs a **well-order** on encodings, and shortlex works over + words because the convolution aligns positions, which makes comparing + lengths free. A tree convolution aligns *shapes*, so a bottom-up automaton + cannot compare two trees\' sizes at all, and every order it can decide is + settled by a finite-state summary — which the usual trick of pushing the + difference deeper turns into an infinite descending chain. So a caller who + wants a quotient over trees names the representatives instead: pick a + formula ρ(x̄) true of exactly one element per class and interpret with + ``domain=δ ∧ ρ``, which is the same construction with the choice made + explicit. """ + if engine.well_order is None: + raise NotImplementedError( + f"the {engine.name} engine has no well-order on encodings, so a " + f"quotient cannot pick class representatives; restrict the domain " + f"to a definable set of representatives instead (see the " + f"documentation of this construction)") if _EQUIV in relations or _LE in relations: raise ValueError(f"{_EQUIV!r} and {_LE!r} are reserved for the " f"quotient construction") raw = interpret(source, domain, {**relations, _EQUIV: equivalence}, dimension) - raw.update(**{_LE: shortlex_order(raw.sigma, raw.padding_symbol)}) + raw.update(**{_LE: engine.well_order(raw.sigma, raw.padding_symbol)}) representative = f'all y.({_EQUIV}(x,y) -> {_LE}(x,y))' specs: Dict[str, RelationSpec] = {} @@ -128,8 +202,7 @@ def _quotient(source: AutomaticPresentation, domain: RelationSpec, return interpret(raw, (representative, ['x']), specs) -def _fold_formula(source: AutomaticPresentation, spec: RelationSpec, - k: int) -> SparseDFA: +def _fold_formula(source, spec: RelationSpec, k: int, engine: _Engine): """Evaluate a formula over `source`, order its tapes coordinate-major, and fold every k of them into one element tape.""" formula, order = spec if isinstance(spec, tuple) else (spec, None) @@ -150,5 +223,5 @@ def _fold_formula(source: AutomaticPresentation, spec: RelationSpec, permutation = [free.index(v) for v in order] if permutation != list(range(len(order))): - dfa = permute_tapes(dfa, permutation) - return fold_tapes(dfa, k) if k > 1 else dfa + dfa = engine.permute_tapes(dfa, permutation) + return engine.fold_tapes(dfa, k) if k > 1 else dfa diff --git a/tests/test_tree_interpretations.py b/tests/test_tree_interpretations.py new file mode 100644 index 0000000..70840e0 --- /dev/null +++ b/tests/test_tree_interpretations.py @@ -0,0 +1,181 @@ +"""First-order interpretations over the tree engine. + +The source is Skolem arithmetic, (N_{>0}, ·) — multiplication is not +string-automatic, so these interpretations exist only over trees. Python's own +arithmetic is the oracle throughout. + +An element of a k-dimensional interpretation is a k-tuple of trees, which *is* +one tree over k-tuples, since a tree convolution already overlays the shapes. +So the fold that regroups the tapes is the same operation as in the string +engine, and these tests check that the structure it produces means what it +should. +""" +import itertools + +import pytest + +from autstr.buildin.tree_presentations import SkolemArithmetic +from autstr.interpretations import interpret +from autstr.sparse_tree_automata import Tree, convolve_trees +from autstr.tree_presentations import TreeAutomaticPresentation + +PAD = SkolemArithmetic.PAD +LETTERS = SkolemArithmetic.LETTERS + + +@pytest.fixture(scope="module") +def skolem(): + return SkolemArithmetic() + + +def tuple_tree(*numbers): + """A k-tuple of naturals as one tree over k-tuples — an element of a + k-dimensional interpretation of Skolem arithmetic.""" + return convolve_trees([SkolemArithmetic.encode(n) for n in numbers], + LETTERS, PAD) + + +def as_one_tape(tree): + """An element tree wrapped for a *unary* relation, whose single tape reads + whole elements.""" + if tree is None: + return None + return Tree((tree.label,), as_one_tape(tree.left), as_one_tape(tree.right)) + + +class TestOneDimensional: + """A definable reduct with a restricted domain: elements keep the source's + trees, so the oracle is plain arithmetic on the numbers they encode.""" + + @pytest.fixture(scope="class") + def squares(self, skolem): + return interpret(skolem, domain=('exists r.(M(r,r,x))', ['x']), + relations={'M': ('M(x,y,z)', ['x', 'y', 'z'])}) + + def test_the_result_is_a_tree_presentation(self, squares): + assert isinstance(squares, TreeAutomaticPresentation) + assert sorted(squares.get_relation_symbols()) == ['M', 'U'] + + def test_the_domain_is_the_squares(self, squares): + universe = squares.automata['U'] + for n in range(1, 30): + is_square = round(n ** 0.5) ** 2 == n + assert universe.accepts(SkolemArithmetic.encode(n)) == is_square, n + + def test_multiplication_restricted_to_the_domain(self, squares): + product = squares.automata['M'] + squares_only = [1, 4, 9, 16, 25, 36, 100] + for a, b in itertools.product(squares_only, repeat=2): + for c in squares_only: + want = a * b == c + assert product.accepts(*map(SkolemArithmetic.encode, + (a, b, c))) == want, (a, b, c) + # a product of the source whose factors leave the domain is gone, + # even though it holds in the source: 2 · 2 = 4 and 2 is no square + assert product.accepts(*map(SkolemArithmetic.encode, (4, 9, 36))) + assert not product.accepts(*map(SkolemArithmetic.encode, (2, 2, 4))) + + def test_a_first_order_fact_about_the_squares(self, squares): + # the squares are closed under multiplication, and every square is a + # product of two squares (itself and 1) + assert squares.check('all x.(all y.(exists z.(M(x,y,z))))') + assert squares.check('all x.(exists y.(exists z.(M(y,z,x))))') + + +class TestTwoDimensional: + """Elements are pairs of naturals under componentwise multiplication.""" + + @pytest.fixture(scope="class") + def pairs(self, skolem): + return interpret( + skolem, + domain=('Eq(x0,x0) & Eq(x1,x1)', ['x0', 'x1']), + relations={'M': ('M(x0,y0,z0) & M(x1,y1,z1)', + ['x0', 'x1', 'y0', 'y1', 'z0', 'z1'])}, + dimension=2) + + def test_the_alphabet_is_the_square_of_the_sources(self, pairs, skolem): + assert len(pairs.base_alphabet) == len(skolem.base_alphabet) ** 2 + assert pairs.padding_symbol == (PAD, PAD) + assert pairs.automata['U'].symbol_arity == 1 + assert pairs.automata['M'].symbol_arity == 3 + + def test_the_universe_is_every_pair(self, pairs): + universe = pairs.automata['U'] + for a, b in itertools.product([1, 2, 6, 10, 27], repeat=2): + assert universe.accepts(as_one_tape(tuple_tree(a, b))), (a, b) + + def test_multiplication_is_componentwise(self, pairs): + product = pairs.automata['M'] + space = [(1, 1), (2, 3), (5, 7), (10, 21), (4, 9), (2, 2)] + for x, y in itertools.product(space, repeat=2): + for z in space: + want = (x[0] * y[0], x[1] * y[1]) == z + assert product.accepts(tuple_tree(*x), tuple_tree(*y), + tuple_tree(*z)) == want, (x, y, z) + + def test_first_order_facts_about_the_product_monoid(self, pairs): + assert pairs.check('exists u.(all x.(M(u,x,x)))') # identity + assert pairs.check('all x.(all y.(exists z.(M(x,y,z))))') # total + assert pairs.check( # commutes + 'all x.(all y.(all z.(M(x,y,z) -> M(y,x,z))))') + + def test_a_sparse_domain_does_not_blow_up(self, skolem): + """The diagonal {(a, a)} realizes only a sparse subset of the pairs, + yet minimization keeps multiplication the size of the one-dimensional + encoding — the tree counterpart of the same measurement on words.""" + diagonal = interpret( + skolem, domain=('Eq(x0,x1)', ['x0', 'x1']), + relations={'M': ('Eq(x0,x1) & Eq(y0,y1) & Eq(z0,z1) & M(x0,y0,z0)', + ['x0', 'x1', 'y0', 'y1', 'z0', 'z1'])}, + dimension=2) + native = interpret(skolem, domain=('Eq(x,x)', ['x']), + relations={'M': ('M(x,y,z)', ['x', 'y', 'z'])}) + assert diagonal.automata['M'].num_states == \ + native.automata['M'].num_states == \ + skolem.automata['M'].num_states + + +class TestQuotientsAreNotAvailableOverTrees: + """Choosing class representatives needs a well-order on encodings. + Shortlex works over words because the convolution aligns positions, which + makes comparing lengths free; a tree convolution aligns shapes instead, and + a bottom-up automaton cannot compare two trees' sizes at all. So the + construction refuses rather than quietly picking a non-representative. + """ + + def test_the_quotient_is_refused_with_an_explanation(self, skolem): + with pytest.raises(NotImplementedError, match="well-order"): + interpret(skolem, domain=('Eq(x,x)', ['x']), + relations={'M': ('M(x,y,z)', ['x', 'y', 'z'])}, + quotient=('Eq(x,y)', ['x', 'y'])) + + def test_naming_the_representatives_is_the_way_round_it(self, skolem): + """The same construction with the choice made explicit: restrict the + domain to one element per class. Here the classes are "same square", + and the representative is the square itself.""" + representatives = interpret( + skolem, domain=('exists r.(M(r,r,x))', ['x']), + relations={'M': ('M(x,y,z)', ['x', 'y', 'z'])}) + assert representatives.automata['U'].accepts( + SkolemArithmetic.encode(9)) + assert not representatives.automata['U'].accepts( + SkolemArithmetic.encode(3)) + + +class TestValidationCarriesOverToTrees: + def test_domain_needs_one_element(self, skolem): + with pytest.raises(ValueError, match="defines 2 element"): + interpret(skolem, domain=('M(x,y,y)', ['x', 'y']), relations={}) + + def test_free_variables_must_be_a_multiple_of_the_dimension(self, skolem): + with pytest.raises(ValueError, match="multiple of the dimension"): + interpret(skolem, + domain=('Eq(x0,x0) & Eq(x1,x1)', ['x0', 'x1']), + relations={'R': ('M(x0,x1,x2)', ['x0', 'x1', 'x2'])}, + dimension=2) + + def test_universe_symbol_is_reserved(self, skolem): + with pytest.raises(ValueError, match="reserved universe"): + interpret(skolem, domain=('Eq(x,x)', ['x']), + relations={'U': ('Eq(x,y)', ['x', 'y'])}) From 05d94ebfdebdcd7a3a06e7a56df87ff705bf1d63 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 12 Aug 2026 18:08:29 +0200 Subject: [PATCH 41/76] Cross-check the two engines on sentences under connectives Both engines answer a sentence with a marker and place it into a connective the same way; the same formula on the same structure must come out the same. Held back until the string-side fix was on dev. Co-Authored-By: Claude Opus 5 --- tests/test_tree_presentations.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_tree_presentations.py b/tests/test_tree_presentations.py index e64fc12..d2019e6 100644 --- a/tests/test_tree_presentations.py +++ b/tests/test_tree_presentations.py @@ -174,6 +174,17 @@ def test_a_sentence_beside_an_open_formula(self, engines): assert not trees.check(f'({false}) & Lt(y,z)') assert trees.check(f'({false}) | Lt(y,z)') + def test_the_two_engines_agree(self, engines): + """Both engines answer a sentence with a marker and place it into a + connective the same way, so the same formula must come out the same on + the same structure.""" + strings, trees = engines + true, false = 'all x.(exists y.(Lt(x,y)))', 'exists x.(all y.(Lt(x,y)))' + for phi in [f'({true}) & ({false})', f'({true}) | ({false})', + f'({false}) -> ({true})', f'({true}) <-> ({true})', + f'({false}) <-> ({true})', f'not (({true}) & ({false}))']: + assert strings.check(phi) == trees.check(phi), phi + def test_a_true_operand_is_the_domain_not_every_tree(self, engines): """A subformula's automaton is a relation over the domain. The marker is not -- it accepts every tree -- so a true sentence beside an open From fb80135dfe654763be5eba5d0cae67a286ee93ab Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 12 Aug 2026 18:26:17 +0200 Subject: [PATCH 42/76] Correct why tree quotients are unavailable The claim was too strong. Representatives are not missing: every tree-automatic equivalence has a regular complete system of them (Colcombet & Loding 2007), computable in polynomial space with exponentially many states (Kuske & Weidner, MFCS 2011, Thm 3.1). What fails is only the string engine's route to them. Taking the least element of a class needs well-foundedness. A tree-automatic linear order does exist -- compare at the lexicographically least differing position -- but growing a tree there makes it smaller, so descending chains are easy and a class need have no least element. Kuske and Weidner minimize instead over a class's *descriptions*: the members whose subtrees below the frontier of the class's shadow are no taller than the equivalence automaton has states, a set that is non-empty and finite. The exponential price is provably necessary. The error now says that, and points at what building it would take. Co-Authored-By: Claude Opus 5 --- autstr/interpretations.py | 66 ++++++++++++++++++++---------- tests/test_tree_interpretations.py | 15 +++---- 2 files changed, 53 insertions(+), 28 deletions(-) diff --git a/autstr/interpretations.py b/autstr/interpretations.py index 88d3249..70703b1 100644 --- a/autstr/interpretations.py +++ b/autstr/interpretations.py @@ -30,10 +30,12 @@ the orchestration is identical because both engines encode a convolution letter the same way. Over trees an element of a k-dimensional interpretation is a k-tuple of trees, which *is* one tree over k-tuples — the same fold, since the -tree convolution already overlays the shapes. The one thing the tree engine -cannot do is choose quotient representatives: that needs a well-order on -encodings, and shortlex has no tree counterpart. Name the representatives with -a formula and restrict the domain instead; `_quotient` says why in full. +tree convolution already overlays the shapes. Quotients are the exception: over +trees the representatives exist and are regular, but reaching them takes a +different construction from the string engine's "least element of the class", +because no tree-automatic order is well-founded. Name the representatives with +a formula and restrict the domain instead; `_quotient` says why in full, with +the reference for building them properly. """ from __future__ import annotations @@ -124,8 +126,8 @@ def interpret(source, domain: RelationSpec, shortlex-least representative of each class, and the relations are read on those representatives. The caller must ensure ε really is an equivalence and that every relation is ε-invariant. **String engine - only** — see `_quotient` for why, and for what to write instead over - trees. + only so far** — see `_quotient` for what the tree engine would need, + and for what to write instead meanwhile. :return: a fresh presentation of the same kind as `source`. For k > 1 its alphabet is the source alphabet's k-fold product. """ @@ -168,24 +170,46 @@ def _quotient(source, domain: RelationSpec, ``<=`` every element equivalent to it — so the whole thing stays inside the engine. - That last step is what the tree engine cannot supply. Picking a - representative needs a **well-order** on encodings, and shortlex works over - words because the convolution aligns positions, which makes comparing - lengths free. A tree convolution aligns *shapes*, so a bottom-up automaton - cannot compare two trees\' sizes at all, and every order it can decide is - settled by a finite-state summary — which the usual trick of pushing the - difference deeper turns into an infinite descending chain. So a caller who - wants a quotient over trees names the representatives instead: pick a - formula ρ(x̄) true of exactly one element per class and interpret with - ``domain=δ ∧ ρ``, which is the same construction with the choice made - explicit. + That last step is what the tree engine does not have. It is *not* that + representatives fail to exist: every tree-automatic equivalence has a + regular complete system of representatives (Colcombet & Löding 2007), and + one is computable in polynomial space with ``2^O(|A|)`` states (Kuske & + Weidner, *Size and computation of injective tree automatic presentations*, + MFCS 2011, Thm. 3.1). What fails is *this* way of getting them. + + Taking the least element of a class needs a well-founded order, and the + tree-automatic order does not qualify. There is a tree-automatic linear + order — compare at the lexicographically least position where two trees + differ, counting an absent position as larger — but growing a tree at that + position makes it *smaller*, so an infinite descending chain is easy to + write down and a class need have no least element. Shortlex avoids this + over words only because the convolution aligns positions, which makes + length the primary key for free; a tree convolution aligns shapes instead, + and comparing two trees' sizes is not a finite-state property at all. + + Kuske and Weidner get round it by not minimizing over the whole class. + Their *shadow* of a class is the set of positions present in every member, + and a *description* is a member whose subtrees below the shadow's frontier + are no taller than the equivalence automaton has states. That set is + non-empty and finite for every class, so its least element under the linear + order exists — and the exponential price is unavoidable: they also show a + structure where every automaton recognizing a complete system of + representatives has exponentially many states. + + Until that is built, a caller who wants a quotient over trees names the + representatives instead: pick a formula ρ(x̄) true of exactly one element + per class and interpret with ``domain=δ ∧ ρ``, which is the same + construction with the choice made explicit. """ if engine.well_order is None: raise NotImplementedError( - f"the {engine.name} engine has no well-order on encodings, so a " - f"quotient cannot pick class representatives; restrict the domain " - f"to a definable set of representatives instead (see the " - f"documentation of this construction)") + f"the {engine.name} engine does not build quotient representatives " + f"yet: they exist and are regular, but reaching them needs the " + f"shadow construction of Kuske & Weidner (MFCS 2011) rather than " + f"the least element of a class, since no tree-automatic order is " + f"well-founded. Restrict the domain to a definable set of " + f"representatives instead (see the documentation of this " + f"construction)") if _EQUIV in relations or _LE in relations: raise ValueError(f"{_EQUIV!r} and {_LE!r} are reserved for the " f"quotient construction") diff --git a/tests/test_tree_interpretations.py b/tests/test_tree_interpretations.py index 70840e0..53d774e 100644 --- a/tests/test_tree_interpretations.py +++ b/tests/test_tree_interpretations.py @@ -136,16 +136,17 @@ def test_a_sparse_domain_does_not_blow_up(self, skolem): skolem.automata['M'].num_states -class TestQuotientsAreNotAvailableOverTrees: - """Choosing class representatives needs a well-order on encodings. - Shortlex works over words because the convolution aligns positions, which - makes comparing lengths free; a tree convolution aligns shapes instead, and - a bottom-up automaton cannot compare two trees' sizes at all. So the - construction refuses rather than quietly picking a non-representative. +class TestQuotientsAreNotBuiltForTreesYet: + """Representatives exist — every tree-automatic equivalence has a regular + complete system of them — but not by the string engine's route of taking + the least element of a class, since no tree-automatic order is + well-founded. Reaching them needs the shadow construction of Kuske & + Weidner (MFCS 2011), which is not built here, so the construction refuses + rather than quietly picking a non-representative. """ def test_the_quotient_is_refused_with_an_explanation(self, skolem): - with pytest.raises(NotImplementedError, match="well-order"): + with pytest.raises(NotImplementedError, match="well-founded"): interpret(skolem, domain=('Eq(x,x)', ['x']), relations={'M': ('M(x,y,z)', ['x', 'y', 'z'])}, quotient=('Eq(x,y)', ['x', 'y'])) From 712e6a64601aefdae1f947bd0bff1ef3f5a13315 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 12 Aug 2026 18:42:21 +0200 Subject: [PATCH 43/76] Ordinals below omega^(omega^n), on the tree engine Delhomme's two boundaries now both have a factory: `Ordinal(n)` covers the word-automatic ordinals, those below omega^omega, and `TreeOrdinal(n)` the tree-automatic ones, those below omega^(omega^omega). The encoding nests the Cantor normal form. An ordinal below omega^(omega^n) is a sum of terms whose exponents lie below omega^n, and such an exponent is an n-tuple of naturals; splitting off its leading coefficient turns that into a recursion -- a sequence of blocks indexed densely by that coefficient, each block an ordinal below omega^(omega^(n-1)), bottoming out at a natural number. That nesting is a left spine of blocks whose payloads hang right and are themselves spines, ending in binary chains. Indexing blocks densely by position is what makes the order cheap: two ordinals align by construction, so the comparison is settled at the deepest position where they differ, and one three-state automaton computes it bottom-up across every level of the nesting at once. Deeper is more significant everywhere -- a deeper spine node is a higher exponent, a deeper bit a higher power of two -- and a position one ordinal has and the other lacks is a term the other is missing, so present beats absent. `Succ` is then first-order over the order and is defined rather than authored. Authored, not interpreted, and deliberately: for n = 1 the trees coincide with Skolem arithmetic's, but permuting the primes is an automorphism of (N, *), so no ordering of the exponent positions is definable there while the encoding fixes one. The tests separate the two engines with a single sentence: in omega^2 the limits are the omega*k and none of them is a limit of limits, while in omega^omega the ordinal omega^2 is one. They also cross-check the two constructions where they overlap -- omega^2 sits inside omega^omega, built once by interpreting Buechi arithmetic and once from hand-written tree automata. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- autstr/ordinals.py | 301 +++++++++++++++++++++++++++++++++++++++-- tests/test_ordinals.py | 211 +++++++++++++++++++++++++++++ 3 files changed, 501 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 7c12a35..aec87e9 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ batch: |---------|------------| | `autstr.arithmetic`, `autstr.buildin` | Presburger and Büchi arithmetic (ℤ, +, <, \|₂), Skolem arithmetic (ℕ, ·), the MSO0 finite-powerset structure | | `autstr.algebra` | the localizations **ℤ[1/p]**, finite **Boolean algebras** | -| `autstr.infinite_graphs`, `autstr.ordinals`, `autstr.turing` | the **integer grid** ℤⁿ, the **regular tree** T_k, the **ordinals** below ω^ω, **Turing-machine configuration graphs** | +| `autstr.infinite_graphs`, `autstr.ordinals`, `autstr.turing` | the **integer grid** ℤⁿ, the **regular tree** T_k, the **ordinals** below ω^ω and — on the tree engine — below ω^(ω^ω), **Turing-machine configuration graphs** | **Classes** — one automaton for a whole family, indexed by advice: diff --git a/autstr/ordinals.py b/autstr/ordinals.py index afd9367..776fd11 100644 --- a/autstr/ordinals.py +++ b/autstr/ordinals.py @@ -1,13 +1,16 @@ -"""Ordinals below :math:`\\omega^\\omega`, as automatic structures. - -Delhommé's theorem draws the line exactly here: the word-automatic ordinals are -precisely those below :math:`\\omega^\\omega`. Every one of them lives in some -:math:`\\omega^n`, so a factory for :math:`(\\omega^n, <)` covers all of them — -and past that boundary the string engine cannot go. (:math:`\\omega^{\\omega}` -itself and everything up to :math:`\\omega^{\\omega^\\omega}` needs the tree -engine.) - -The structure is *derived, not authored*. By Cantor normal form an ordinal +"""Ordinals as automatic structures, on both engines. + +Delhommé's theorem draws two lines. The word-automatic ordinals are precisely +those below :math:`\\omega^\\omega`, and the tree-automatic ones precisely those +below :math:`\\omega^{\\omega^\\omega}`. Every ordinal below the first line +lives in some :math:`\\omega^n` and every ordinal below the second in some +:math:`\\omega^{\\omega^n}`, so two factories cover both classes exactly: +`Ordinal` on the string engine and `TreeOrdinal` on the tree engine. Neither +boundary ordinal is itself reachable — :math:`\\omega^\\omega` is not +word-automatic, :math:`\\omega^{\\omega^\\omega}` not tree-automatic — which is +why both take an exponent rather than being a single structure. + +`Ordinal` is *derived, not authored*. By Cantor normal form an ordinal :math:`\\alpha < \\omega^n` is uniquely :math:`\\sum_{i bool: def __repr__(self): return f"" + + +#: a Python ordinal below omega^(omega^n): Cantor coefficients keyed by their +#: exponent, itself an ordinal below omega^n written as a coefficient tuple +CantorForm = Union[int, Mapping[Union[int, Sequence[int]], int]] + + +class TreeOrdinal(TreeAutomaticPresentation): + """The ordinals below :math:`\\omega^{\\omega^n}`, under their natural + order — the tree-automatic ordinals, which reach exactly this far. + + :param n: the exponent (n ≥ 1). ``TreeOrdinal(1)`` is + :math:`(\\omega^\\omega, <)`, the first ordinal past the word barrier. + :param max_states: optional cap on the subset determinizations inside + projection. + + **The encoding.** By Cantor normal form an ordinal below + :math:`\\omega^{\\omega^n}` is a finite sum :math:`\\sum \\omega^{\\beta_i} + c_i` with exponents :math:`\\beta_i < \\omega^n` — and an exponent below + :math:`\\omega^n` is an n-tuple of naturals, exactly what `Ordinal` orders. + Splitting off the leading coefficient of the exponent turns that into a + recursion: an ordinal below :math:`\\omega^{\\omega^n}` is a sequence of + *blocks* indexed densely by that leading coefficient, each block an ordinal + below :math:`\\omega^{\\omega^{n-1}}`, and at n = 0 a natural number. So the + encoding is a left spine of blocks whose payloads hang right and are + themselves spines, bottoming out in binary chains — the shape the tree + engine reads natively. + + Indexing the blocks *densely* by position is what makes the order easy: + two ordinals are aligned by construction, so the comparison is decided at + the deepest position where they differ, and a single three-state automaton + (``less``, ``equal``, ``greater``) computes it bottom-up at every level of + the nesting at once. Deeper is more significant everywhere — a deeper spine + node is a higher exponent, a deeper bit is a higher power of two — and a + position one ordinal has and the other lacks is a term the other is + missing, so present beats absent. + + **Authored, not interpreted.** For n = 1 the trees coincide with Skolem + arithmetic's, since both encode a finitely supported map from + :math:`\\mathbb{N}` to :math:`\\mathbb{N}`. The order is *not* an + interpretation of it: permuting the primes is an automorphism of + :math:`(\\mathbb{N}_{>0}, \\cdot)`, so no ordering of the exponent + positions is definable there, while the encoding fixes one. The comparison + automaton is small enough that authoring it is the honest route. + + Ordinals are written as maps from exponent to coefficient:: + + >>> W = TreeOrdinal(1) # ordinals below ω^ω + >>> a, b = W.symbolic().vars("a b") + >>> a.lt(b).evaluate().contains(a={2: 5}, b={3: 1}) # ω²·5 < ω³ + True + + An exponent is written as `Ordinal` writes one — an integer, or a tuple of + coefficients in descending order, left-padded to length n — and a plain + integer ordinal is the finite ordinal of that value. + """ + + #: spine node, tree root, the two bits, and padding + SPINE, ROOT, PAD = 's', 'o', '*' + LETTERS = frozenset({SPINE, ROOT, '0', '1', PAD}) + + #: the three verdicts the comparison automaton computes bottom-up + _EQUAL, _LESS, _GREATER = 0, 1, 2 + + #: y is the immediate successor of x -- first-order over the order, since + #: every ordinal has one, so it is defined rather than authored + _SUCCESSOR = 'Lt(x,y) & (not exists z.(Lt(x,z) & Lt(z,y)))' + + def __init__(self, n: int = 1, max_states: Optional[int] = None) -> None: + if n < 1: + raise ValueError("the exponent must be >= 1") + self.n = n + super().__init__( + {'U': self._universe(n), + 'Lt': self._comparison({self._LESS}), + 'Eq': self._comparison({self._EQUAL})}, + padding_symbol=self.PAD, max_states=max_states) + self._declare_deferred({'Succ': self._SUCCESSOR}) + + def default_signature(self): + """The strict order as ``.lt``, equality as ``.eq``, the successor as + ``.succ``, and ordinals written as Cantor maps.""" + return order_signature(self.get_relation_symbols(), less='Lt', + methods={'succ': 'Succ'}, + codec=FunctionCodec(self.encode, self.decode)) + + def is_total_order(self) -> bool: + """Whether ``Lt`` is a strict total order — decidable, being + first-order. Well-foundedness is not first-order, so it is not + checkable here; it holds by construction.""" + return self.check( + 'all x.(not Lt(x,x)) & ' + 'all x.(all y.(all z.((Lt(x,y) & Lt(y,z)) -> Lt(x,z)))) & ' + 'all x.(all y.(Lt(x,y) | Lt(y,x) | Eq(x,y)))') + + def __repr__(self): + return f"" + + # ---------------- encoding elements ---------------- + def encode(self, value: CantorForm) -> Tree: + """The tree encoding an ordinal, written as a map from exponent to + coefficient (or as a plain integer, for a finite ordinal).""" + return Tree(self.ROOT, self._nest(self._normalize(value), self.n), None) + + def decode(self, tree: Tree) -> Dict[Tuple[int, ...], int]: + """The ordinal a tree encodes, as a map from exponent tuple to + coefficient; the empty map is the ordinal zero.""" + if tree is None or tree.label != self.ROOT or tree.right is not None: + raise ValueError("not an ordinal tree") + return self._unnest(tree.left, self.n) + + def _normalize(self, value: CantorForm) -> Dict[Tuple[int, ...], int]: + """`value` as a map from full-length exponent tuples to non-zero + coefficients.""" + if isinstance(value, int): + value = {(0,) * self.n: value} + normalized = {} + for exponent, coefficient in value.items(): + if coefficient < 0: + raise ValueError( + f"a Cantor coefficient must be a natural, got {coefficient}") + if not coefficient: + continue # a zero term is no term + normalized[self._exponent(exponent)] = coefficient + return normalized + + def _exponent(self, exponent) -> Tuple[int, ...]: + """An exponent as a full n-tuple: an integer is the exponent of that + value, and a short tuple is left-padded, as in `Ordinal`.""" + if isinstance(exponent, int): + exponent = (exponent,) + exponent = tuple(exponent) + if len(exponent) > self.n: + raise ValueError( + f"exponent {exponent} has {len(exponent)} coefficients, more " + f"than the {self.n} of an ordinal below omega^{self.n}") + if any(coefficient < 0 for coefficient in exponent): + raise ValueError(f"exponent {exponent} is not an ordinal") + return (0,) * (self.n - len(exponent)) + exponent + + def _nest(self, terms: Dict[Tuple[int, ...], int], level: int): + """The blocks of `terms` as a spine, recursively; None is the zero + ordinal, which is no spine at all.""" + if not terms: + return None + if not level: + return self._chain(terms[()]) + blocks: Dict[int, Dict[Tuple[int, ...], int]] = {} + for exponent, coefficient in terms.items(): + blocks.setdefault(exponent[0], {})[exponent[1:]] = coefficient + spine = None + for index in range(max(blocks), -1, -1): # deepest block first + spine = Tree(self.SPINE, spine, + self._nest(blocks.get(index, {}), level - 1)) + return spine + + def _unnest(self, node, level: int) -> Dict[Tuple[int, ...], int]: + if node is None: + return {} + if not level: + return {(): self._value(node)} + terms, index = {}, 0 + while node is not None: + for exponent, coefficient in self._unnest(node.right, + level - 1).items(): + terms[(index,) + exponent] = coefficient + node, index = node.left, index + 1 + return terms + + @staticmethod + def _chain(coefficient: int): + """A positive natural as a binary chain, most significant bit deepest + — so that deeper is more significant here too.""" + node = None + for bit in format(coefficient, 'b'): + node = Tree(bit, node, None) + return node + + @staticmethod + def _value(node) -> int: + value, power = 0, 1 + while node is not None: + value += power * int(node.label) + power, node = power * 2, node.left + return value + + # ---------------- the automata ---------------- + @classmethod + def _enc(cls, letters) -> int: + return encode_symbol(tuple(letters), cls.LETTERS) + + @classmethod + def _universe(cls, n: int) -> SparseTreeAutomaton: + """One tree per ordinal: chains carry no leading zero, spines no + trailing zero block, and the nesting is exactly n deep.""" + chain = 0 + spine = {level: level for level in range(1, n + 1)} # states 1..n + root, dead = n + 1, n + 2 + bot = n + 3 + + exc = [(bot, bot, ('1',), chain), # deepest bit is the MSB + (chain, bot, ('0',), chain), + (chain, bot, ('1',), chain)] + for level in range(1, n + 1): + payload = chain if level == 1 else spine[level - 1] + exc += [ + # the deepest block of a spine must be non-zero, or the spine + # would encode an ordinal it is already too long for + (bot, payload, (cls.SPINE,), spine[level]), + (spine[level], payload, (cls.SPINE,), spine[level]), + (spine[level], bot, (cls.SPINE,), spine[level]), + ] + exc += [(bot, bot, (cls.ROOT,), root), # the ordinal zero + (spine[n], bot, (cls.ROOT,), root)] + + accepting = [False] * (n + 3) + accepting[root] = True + return SparseTreeAutomaton( + n + 3, dead, + [e[0] for e in exc], [e[1] for e in exc], + [cls._enc(e[2]) for e in exc], [e[3] for e in exc], + accepting, 1, set(cls.LETTERS)) + + @classmethod + def _comparison(cls, accepting) -> SparseTreeAutomaton: + """The order, decided at the deepest position where two ordinals + differ. + + Every node combines its children the same way, whatever level of the + nesting it sits at: a verdict from the left subtree stands, since left + is deeper and deeper is more significant; failing that the right + subtree, which is this block's payload; failing that the labels + themselves, where a present position beats an absent one. + """ + equal, less, greater = cls._EQUAL, cls._LESS, cls._GREATER + bot = 3 + + def verdict(one: str, other: str) -> int: + if one == other: + return equal + if one == cls.PAD: # a term the other ordinal has + return less + if other == cls.PAD: + return greater + return less if one < other else greater # '0' before '1' + + # both subtrees agree so far: the labels here decide + exc = [(left, right, (one, other), verdict(one, other)) + for left in (bot, equal) for right in (bot, equal) + for one in sorted(cls.LETTERS) for other in sorted(cls.LETTERS) + if verdict(one, other) != equal] + + # a subtree that has already decided outranks everything above it + defaults = [(left, right, left if left in (less, greater) else right) + for left in (bot, equal, less, greater) + for right in (bot, equal, less, greater) + if left in (less, greater) or right in (less, greater)] + + return SparseTreeAutomaton( + 3, equal, + [e[0] for e in exc], [e[1] for e in exc], + [cls._enc(e[2]) for e in exc], [e[3] for e in exc], + [state in accepting for state in range(3)], 2, set(cls.LETTERS), + pd_left=[d[0] for d in defaults], pd_right=[d[1] for d in defaults], + pd_target=[d[2] for d in defaults]) diff --git a/tests/test_ordinals.py b/tests/test_ordinals.py index 10aedb6..af2a9f2 100644 --- a/tests/test_ordinals.py +++ b/tests/test_ordinals.py @@ -135,3 +135,214 @@ def test_omega_is_the_naturals(self, omega): from autstr.buildin.presentations import BuechiArithmetic assert omega.presentation.automata['Lt'].num_states == \ BuechiArithmetic().automata['Lt'].num_states + + +# ====================================================================== +# TreeOrdinal: the ordinals below omega^(omega^n), on the tree engine +# ====================================================================== + +from autstr.ordinals import TreeOrdinal # noqa: E402 +from autstr.sparse_tree_automata import Tree # noqa: E402 + + +@pytest.fixture(scope="module") +def omega_to_the_omega(): + return TreeOrdinal(1) + + +@pytest.fixture(scope="module") +def big(): + return TreeOrdinal(2) + + +def ordinal_key(structure, cantor): + """The oracle: a Cantor map as the list of its terms in descending + exponent order. Python compares those lists exactly as the ordinals they + denote compare -- a larger leading exponent wins, then a larger leading + coefficient, and a longer list wins over a prefix of itself since every + further term is positive.""" + return sorted(structure._normalize(cantor).items(), reverse=True) + + +#: ordinals below omega^omega, written as Cantor maps +SMALL_SPACE = [0, 1, 2, 5, {1: 1}, {1: 2}, {1: 1, 0: 1}, {2: 1}, {2: 5}, + {2: 1, 1: 3, 0: 7}, {4: 1}, {4: 1, 0: 1}, {3: 2, 1: 1}] + +#: ordinals below omega^(omega^2); an exponent is now a pair +BIG_SPACE = [0, 1, 7, {(0, 1): 1}, {(0, 2): 3}, {(1, 0): 1}, {(1, 0): 2}, + {(1, 0): 1, (0, 5): 4}, {(2, 0): 1}, {(1, 3): 1}, + {(1, 3): 1, (0, 0): 9}, {3: 2}] + + +class TestTreeCodec: + def test_roundtrip(self, omega_to_the_omega): + W = omega_to_the_omega + for value in SMALL_SPACE: + assert W.decode(W.encode(value)) == W._normalize(value) + + def test_zero_is_the_empty_map(self, omega_to_the_omega): + W = omega_to_the_omega + assert W.decode(W.encode(0)) == {} + assert W.encode(0) == Tree('o') + + def test_an_integer_is_the_finite_ordinal(self, omega_to_the_omega): + W = omega_to_the_omega + assert W.encode(5) == W.encode({0: 5}) + + def test_an_exponent_may_be_an_integer_or_a_short_tuple(self, big): + assert big.encode({3: 2}) == big.encode({(0, 3): 2}) + assert big.decode(big.encode({3: 2})) == {(0, 3): 2} + + def test_a_zero_coefficient_is_no_term(self, omega_to_the_omega): + W = omega_to_the_omega + assert W.encode({2: 1, 5: 0}) == W.encode({2: 1}) + + def test_a_negative_coefficient_is_rejected(self, omega_to_the_omega): + with pytest.raises(ValueError, match="natural"): + omega_to_the_omega.encode({1: -2}) + + def test_too_long_an_exponent_is_rejected(self, omega_to_the_omega): + with pytest.raises(ValueError, match="more than"): + omega_to_the_omega.encode({(1, 2): 1}) + + +class TestTreeUniverse: + """One tree per ordinal: the universe automaton rejects every other tree, + which is what makes the encoding a bijection and the order automaton's + position-wise alignment sound.""" + + def test_accepts_the_canonical_trees(self, omega_to_the_omega): + W = omega_to_the_omega + for value in SMALL_SPACE: + assert W.automata['U'].accepts(W.encode(value)), value + + def test_rejects_a_trailing_zero_block(self, omega_to_the_omega): + # a spine node with no payload is a zero block; as the deepest node it + # would give the ordinal zero a second encoding + assert not omega_to_the_omega.automata['U'].accepts( + Tree('o', Tree('s', None, None), None)) + + def test_rejects_a_leading_zero_bit(self, omega_to_the_omega): + # the chain '01' is the coefficient 1 written with a leading zero: + # the deepest bit is the most significant and must be a one + chain = Tree('1', Tree('0', None, None), None) + assert not omega_to_the_omega.automata['U'].accepts( + Tree('o', Tree('s', None, chain), None)) + + def test_rejects_the_wrong_nesting_depth(self, big): + # at n = 2 a block is itself a spine, never a bare coefficient chain + flat = Tree('o', Tree('s', None, Tree('1', None, None)), None) + assert not big.automata['U'].accepts(flat) + assert big.automata['U'].accepts(big.encode({(0, 0): 1})) + + +class TestTreeOrder: + @pytest.mark.parametrize("structure_name,space", + [('omega_to_the_omega', SMALL_SPACE), + ('big', BIG_SPACE)]) + def test_agrees_with_the_cantor_oracle(self, structure_name, space, + request): + structure = request.getfixturevalue(structure_name) + less, equal = structure.automata['Lt'], structure.automata['Eq'] + for a, b in itertools.product(space, repeat=2): + x, y = structure.encode(a), structure.encode(b) + key_a, key_b = (ordinal_key(structure, a), + ordinal_key(structure, b)) + assert less.accepts(x, y) == (key_a < key_b), (a, b) + assert equal.accepts(x, y) == (key_a == key_b), (a, b) + + def test_is_a_strict_total_order(self, omega_to_the_omega, big): + assert omega_to_the_omega.is_total_order() + assert big.is_total_order() + + def test_the_symbolic_surface(self, omega_to_the_omega): + a, b = omega_to_the_omega.symbolic().vars('a b') + assert a.lt(b).evaluate().contains(a={2: 5}, b={3: 1}) # ω²·5 < ω³ + assert not a.lt(b).evaluate().contains(a={3: 1}, b={2: 5}) + + def test_agrees_with_the_word_engine_where_they_overlap( + self, omega_to_the_omega): + """Two independent constructions of the same order: ω² sits inside + ω^ω, and `Ordinal(2)` builds it by interpreting Büchi arithmetic while + `TreeOrdinal(1)` builds it from hand-written tree automata.""" + word = Ordinal(2) + x, y = word.symbolic().vars('x y') + word_less = x.lt(y).evaluate() + tree_less = omega_to_the_omega.automata['Lt'] + + window = list(itertools.product(range(3), range(3))) + for (a1, a0), (b1, b0) in itertools.product(window, repeat=2): + tree = tree_less.accepts( + omega_to_the_omega.encode({1: a1, 0: a0}), + omega_to_the_omega.encode({1: b1, 0: b0})) + assert word_less.contains(x=(a1, a0), y=(b1, b0)) == tree + + +class TestTreeSuccessor: + """`Succ` is defined from the order rather than authored -- every ordinal + has an immediate successor, so `y = x + 1` is first-order.""" + + def test_semantics(self, omega_to_the_omega): + # deferred: it is built the first time a query mentions it + x, y = omega_to_the_omega.symbolic().vars('x y') + successor = x.succ(y).evaluate() + for a, b, want in [(3, 4, True), (3, 5, False), + ({1: 1}, {1: 1, 0: 1}, True), + ({1: 1, 0: 2}, {1: 1, 0: 3}, True), + (7, {1: 1}, False)]: # ω is a limit + assert successor.contains(x=a, y=b) == want, (a, b) + + def test_every_ordinal_has_one(self, omega_to_the_omega): + assert omega_to_the_omega.check('all x.(exists y.(Succ(x,y)))') + + def test_the_order_is_discrete_upwards(self, omega_to_the_omega): + assert omega_to_the_omega.check( + 'all x.(all y.(Succ(x,y) -> (not exists z.(Lt(x,z) & Lt(z,y)))))') + + +def _zero(v): + return f'all q.(Lt({v},q) | Eq({v},q))' + + +def _limit(v): + return f'((not ({_zero(v)})) & (not exists p.(Succ(p,{v}))))' + + +def _limit_of_limits(v): + return (f'({_limit(v)} & all w.(Lt(w,{v}) -> exists u.(' + f'Lt(w,u) & Lt(u,{v}) & {_limit("u")})))') + + +class TestPastTheWordBarrier: + """What the tree engine buys. In ω^n the limit ordinals stack only n-1 + deep -- the limits of ω² are the ω·k, and no ω·k is a limit of those. In + ω^ω they stack arbitrarily, and ω² itself is a limit of limits. So one + first-order sentence separates the two structures, and only the tree + engine can decide it on ω^ω. + """ + + def test_both_have_limit_ordinals(self, omega_to_the_omega): + assert omega_to_the_omega.check(f'exists x.({_limit("x")})') + assert Ordinal(2).check(f'exists x.({_limit("x")})') + + def test_only_omega_to_the_omega_has_a_limit_of_limits( + self, omega_to_the_omega): + assert omega_to_the_omega.check(f'exists x.({_limit_of_limits("x")})') + assert not Ordinal(2).check(f'exists x.({_limit_of_limits("x")})') + + def test_it_has_a_least_element_and_no_greatest(self, omega_to_the_omega): + assert omega_to_the_omega.check('exists x.(all y.(Lt(x,y) | Eq(x,y)))') + assert omega_to_the_omega.check('all x.(exists y.(Lt(x,y)))') + + +class TestTreeConstruction: + def test_exponent_must_be_positive(self): + with pytest.raises(ValueError, match=">= 1"): + TreeOrdinal(0) + + def test_repr(self, omega_to_the_omega): + assert repr(omega_to_the_omega) == "" + + def test_the_relations(self, omega_to_the_omega): + assert sorted(omega_to_the_omega.get_relation_symbols()) == \ + ['Eq', 'Lt', 'Succ', 'U'] From d3c0e5d12067724979f6fa5d907127934dd70773 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 12 Aug 2026 22:28:39 +0200 Subject: [PATCH 44/76] The countable atomless boolean algebra There is exactly one up to isomorphism, and it is the infinite counterpart of `FiniteBooleanAlgebras`, carrying the same signature -- Leq, Meet, Join, Compl, Atom -- so the same formula runs against both. The one that separates them is the definition of the name: over a finite algebra `exists x. Atom(x)` is true, and here it is false. Elements are the clopen subsets of Cantor space. Such a set is decided by finitely many bits of a point, so it is a finite binary decision tree with leaves labelled in or out, and making the tree reduced -- no split whose two sides are the same constant -- makes the encoding a bijection. That matters here rather than being a nicety: a non-canonical encoding would need a quotient, which is exactly what the tree engine cannot yet supply. Reduction is also what makes the order cheap. In a reduced tree a subtree is constant exactly when it is a leaf, so at any position the comparison is already settled unless both sides split there, and nothing below a leaf is ever consulted: `Leq` is a two-state bottom-up automaton. Meet, join, complement, the atom predicate and equality are all first-order over it, defined rather than authored and built on first use. Meet, join and complement bind to `*`, `+` and unary `-`. They are terms, not connectives -- `&`, `|` and `~` already mean conjunction, disjunction and negation of formulas, so binding those would make `x & y` ambiguous. Co-Authored-By: Claude Opus 5 --- autstr/tree_algebra.py | 235 +++++++++++++++++++++++++++++++++++++ tests/test_tree_algebra.py | 189 +++++++++++++++++++++++++++++ 2 files changed, 424 insertions(+) create mode 100644 autstr/tree_algebra.py create mode 100644 tests/test_tree_algebra.py diff --git a/autstr/tree_algebra.py b/autstr/tree_algebra.py new file mode 100644 index 0000000..5eb152a --- /dev/null +++ b/autstr/tree_algebra.py @@ -0,0 +1,235 @@ +"""The countable atomless boolean algebra, as a tree-automatic structure. + +There is exactly one countable atomless boolean algebra up to isomorphism — +Cantor's back-and-forth argument again — and it is the infinite counterpart of +the `autstr.algebra.FiniteBooleanAlgebras` class, carrying the same signature +``Leq``, ``Meet``, ``Join``, ``Compl``, ``Atom``. The same formula runs against +both, and the one that separates them is the definition of the name: over a +finite algebra ``exists x. Atom(x)`` is true, and here it is false. + +**Elements are clopen subsets of Cantor space.** A clopen subset of +:math:`2^\\omega` is a finite union of cylinders, so it is decided by finitely +many bits of a point — that is, by a finite binary decision tree with leaves +labelled in or out. Making the tree *reduced* (no split whose two sides are the +same constant) makes the encoding a bijection, which matters here: a +non-canonical encoding would need a quotient, and quotients are exactly what +the tree engine cannot yet supply (see `autstr.interpretations`). + +**One authored automaton.** Reduction is what makes the order cheap. In a +reduced tree a subtree is constant exactly when it is a leaf, so at any +position the comparison is already decided unless *both* sides split there:: + + x is 0 here -> fine, nothing of x to contain + y is 1 here -> fine, y contains everything + x is 1, y is not -> y misses a point of x + y is 0, x is not -> x has a point y misses + both split -> recurse + +Nothing below a leaf is ever consulted, so ``Leq`` is a two-state bottom-up +automaton. Everything else — ``Meet``, ``Join``, ``Compl``, ``Atom``, and +equality — is first-order over it and is defined rather than authored, built +the first time a query asks for it. + + >>> B = AtomlessBooleanAlgebra() + >>> x, y = B.symbolic().vars("x y") + >>> (x * y).eq({'00'}).check() # some meet is the cylinder 00 + True + >>> B.check('exists x.(Atom(x))') # atomless, by construction + False + +A clopen set is written as the set of binary strings whose cylinders it +contains: ``{'0', '10'}`` is everything starting ``0`` or ``10``, ``set()`` is +empty and ``{''}`` is everything. Any covering set may be given — ``{'0','1'}`` +and ``{''}`` denote the same element — and `decode` returns the canonical one. +""" +from __future__ import annotations + +import itertools +from typing import FrozenSet, Iterable, Optional + +from autstr.sparse_tree_automata import SparseTreeAutomaton, Tree +from autstr.symbolic import FunctionCodec, Signature +from autstr.tree_presentations import TreeAutomaticPresentation +from autstr.utils.misc import encode_symbol + +#: a clopen set, as the binary strings whose cylinders it contains +Clopen = Iterable[str] + + +class AtomlessBooleanAlgebra(TreeAutomaticPresentation): + """The unique countable atomless boolean algebra. + + :param max_states: optional cap on the subset determinizations inside + projection. + + Elements are the clopen subsets of Cantor space, encoded as reduced binary + decision trees: ``'0'`` and ``'1'`` label leaves that are out and in, and + ``'n'`` labels a split on the next bit of a point. + """ + + #: split, out-leaf, in-leaf, padding + SPLIT, OUT, IN, PAD = 'n', '0', '1', '*' + LETTERS = frozenset({SPLIT, OUT, IN, PAD}) + + #: `x` is the bottom / the top, spelled out so that the definitions below + #: stay first-order over `Leq` alone + _BOTTOM = 'all v.(Leq({0},v))' + _TOP = 'all v.(Leq(v,{0}))' + + def __init__(self, max_states: Optional[int] = None) -> None: + super().__init__({'U': self._universe(), 'Leq': self._order()}, + padding_symbol=self.PAD, max_states=max_states) + bottom, top = self._BOTTOM, self._TOP + self._declare_deferred({ + # antisymmetry is equality, as in the finite algebras + 'Eq': 'Leq(x,y) & Leq(y,x)', + # the greatest lower bound, and dually the least upper bound + 'Meet': ('Leq(z,x) & Leq(z,y) & ' + 'all w.((Leq(w,x) & Leq(w,y)) -> Leq(w,z))'), + 'Join': ('Leq(x,z) & Leq(y,z) & ' + 'all w.((Leq(x,w) & Leq(y,w)) -> Leq(z,w))'), + # y is the complement of x: they meet in the bottom and join to + # the top + 'Compl': (f'(all w.((Leq(w,x) & Leq(w,y)) -> {bottom.format("w")}))' + f' & (all w.((Leq(x,w) & Leq(y,w)) -> ' + f'{top.format("w")}))'), + # nothing lies strictly between an atom and the bottom -- here the + # relation is empty, which is the whole point + 'Atom': (f'(not {bottom.format("x")}) & ' + f'all y.(Leq(y,x) -> ({bottom.format("y")} | Leq(x,y)))'), + }) + + def default_signature(self): + """Meet as ``*``, join as ``+``, complement as unary ``-``, the order + as ``.leq`` and equality as ``.eq``. + + The lattice operations are terms, not connectives: ``&``, ``|`` and + ``~`` already mean conjunction, disjunction and negation of *formulas*, + so binding them here would make ``x & y`` ambiguous — the same reason + `autstr.arithmetic` spells its divisibility relation + ``divided_by_power``. + """ + signature = Signature(codec=FunctionCodec(self.encode, self.decode)) + signature.function('meet', graph='Meet', out=2) + signature.function('join', graph='Join', out=2) + signature.function('compl', graph='Compl', out=1) + signature.operator('*', 'meet') + signature.operator('+', 'join') + signature.operator('-', 'compl') + signature.operator('leq', 'Leq') + signature.operator('eq', 'Eq') + # the operations are built on first use, so their arities cannot be + # read off automata that do not exist yet + signature.relations = {'Meet': 3, 'Join': 3, 'Compl': 2, 'Eq': 2, + 'Atom': 1} + return signature + + def is_atomless(self) -> bool: + """Whether every non-empty element strictly contains a non-empty one — + decidable, being first-order, and the property that pins the algebra + down to isomorphism.""" + bottom = self._BOTTOM + return self.check( + f'all x.((not {bottom.format("x")}) -> exists y.(' + f'(not {bottom.format("y")}) & Leq(y,x) & (not Leq(x,y))))') + + def __repr__(self): + return "" + + # ---------------- encoding elements ---------------- + def encode(self, clopen: Clopen) -> Tree: + """The reduced decision tree of a clopen set, written as the binary + strings whose cylinders it contains. Any covering set is accepted and + canonicalized.""" + cylinders = frozenset(clopen) + for cylinder in cylinders: + if set(cylinder) - {self.OUT, self.IN}: + raise ValueError( + f"{cylinder!r} is not a binary string, so it names no " + f"cylinder of Cantor space") + return self._reduce(cylinders, '') + + def decode(self, tree: Tree) -> FrozenSet[str]: + """The clopen set a tree encodes, as its canonical cylinders.""" + found = set() + + def walk(node, prefix): + if node is None: + raise ValueError("not a decision tree") + if node.label == self.IN: + found.add(prefix) + elif node.label == self.SPLIT: + walk(node.left, prefix + self.OUT) + walk(node.right, prefix + self.IN) + elif node.label != self.OUT: + raise ValueError(f"{node.label!r} labels no decision node") + + walk(tree, '') + return frozenset(found) + + def _reduce(self, cylinders: FrozenSet[str], prefix: str) -> Tree: + """The reduced tree of `cylinders` below `prefix`: a leaf as soon as + the answer is settled, and a split only where it is not.""" + if any(prefix.startswith(cylinder) for cylinder in cylinders): + return Tree(self.IN) # inside one of them already + if not any(cylinder.startswith(prefix) for cylinder in cylinders): + return Tree(self.OUT) # none of them reaches here + left = self._reduce(cylinders, prefix + self.OUT) + right = self._reduce(cylinders, prefix + self.IN) + if left.left is None and right.left is None and \ + left.label == right.label: + return Tree(left.label) # a split that decides nothing + return Tree(self.SPLIT, left, right) + + # ---------------- the automata ---------------- + @classmethod + def _enc(cls, letters) -> int: + return encode_symbol(tuple(letters), cls.LETTERS) + + @classmethod + def _universe(cls) -> SparseTreeAutomaton: + """The reduced decision trees — one per clopen set. A split whose two + sides are the same constant is the one thing forbidden; it would give + that constant a second encoding.""" + out, in_, split, dead = 0, 1, 2, 3 + bot = 4 + exc = [(bot, bot, (cls.OUT,), out), (bot, bot, (cls.IN,), in_)] + exc += [(left, right, (cls.SPLIT,), split) + for left, right in itertools.product((out, in_, split), + repeat=2) + if (left, right) not in ((out, out), (in_, in_))] + return SparseTreeAutomaton( + 4, dead, + [e[0] for e in exc], [e[1] for e in exc], + [cls._enc(e[2]) for e in exc], [e[3] for e in exc], + [True, True, True, False], 1, set(cls.LETTERS)) + + @classmethod + def _order(cls) -> SparseTreeAutomaton: + """Containment. Because the trees are reduced, a subtree is constant + exactly when it is a leaf, so a leaf on either side settles the whole + cylinder and nothing below it is ever consulted.""" + ok, bad, bot = 0, 1, 2 + + def verdict(one: str, other: str, below_left: int, + below_right: int) -> int: + if one == cls.PAD or other == cls.PAD: + return ok # settled by a leaf further up + if one == cls.OUT or other == cls.IN: + return ok # x is empty here, or y is full + if one == cls.IN or other == cls.OUT: + return bad # x has a point that y misses + return ok if below_left == ok == below_right else bad + + exc = [(left, right, (one, other), + verdict(one, other, ok if left == bot else left, + ok if right == bot else right)) + for left in (ok, bad, bot) for right in (ok, bad, bot) + for one in sorted(cls.LETTERS) for other in sorted(cls.LETTERS)] + exc = [e for e in exc if e[3] == ok] # the default is a failure + + return SparseTreeAutomaton( + 2, bad, + [e[0] for e in exc], [e[1] for e in exc], + [cls._enc(e[2]) for e in exc], [e[3] for e in exc], + [True, False], 2, set(cls.LETTERS)) diff --git a/tests/test_tree_algebra.py b/tests/test_tree_algebra.py new file mode 100644 index 0000000..e9de8c7 --- /dev/null +++ b/tests/test_tree_algebra.py @@ -0,0 +1,189 @@ +"""The countable atomless boolean algebra. + +The oracle is Python's set algebra. A clopen subset of Cantor space is decided +by finitely many bits of a point, so restricting to points of a fixed depth +turns every element of the sample space into an ordinary finite set of binary +strings, and meet, join, complement and containment become set operations on +those. +""" +import itertools + +import pytest + +from autstr.algebra import FiniteBooleanAlgebras +from autstr.sparse_tree_automata import Tree +from autstr.tree_algebra import AtomlessBooleanAlgebra + +#: deep enough to separate every element of SPACE below +DEPTH = 4 + +#: clopen sets, written as the cylinders they contain +SPACE = [set(), {''}, {'0'}, {'1'}, {'00'}, {'01'}, {'0', '10'}, + {'00', '11'}, {'000', '01', '1'}, {'10'}, {'0', '11'}] + + +@pytest.fixture(scope="module") +def algebra(): + return AtomlessBooleanAlgebra() + + +def points(clopen, depth: int = DEPTH): + """The points of the given depth the clopen set contains — its extension, + as a plain finite set.""" + return {word for word in map(''.join, itertools.product('01', repeat=depth)) + if any(word.startswith(cylinder) for cylinder in clopen)} + + +EVERYTHING = points({''}) + + +class TestCodec: + def test_roundtrip(self, algebra): + for clopen in SPACE: + assert points(algebra.decode(algebra.encode(clopen))) == \ + points(clopen), clopen + + def test_the_empty_set_and_the_whole_space(self, algebra): + assert algebra.encode(set()) == Tree('0') + assert algebra.encode({''}) == Tree('1') + assert algebra.decode(Tree('0')) == frozenset() + assert algebra.decode(Tree('1')) == frozenset(['']) + + def test_any_covering_set_is_canonicalized(self, algebra): + # the two halves are the whole space, and a split that decides nothing + # collapses + assert algebra.encode({'0', '1'}) == algebra.encode({''}) + assert algebra.encode({'110', '111'}) == algebra.encode({'11'}) + assert algebra.decode(algebra.encode({'0', '1'})) == frozenset(['']) + + def test_a_redundant_cylinder_is_absorbed(self, algebra): + assert algebra.encode({'0', '01'}) == algebra.encode({'0'}) + + def test_a_non_binary_string_is_rejected(self, algebra): + with pytest.raises(ValueError, match="cylinder"): + algebra.encode({'02'}) + + +class TestUniverse: + """One tree per clopen set: the reduced ones.""" + + def test_accepts_every_encoding(self, algebra): + for clopen in SPACE: + assert algebra.automata['U'].accepts(algebra.encode(clopen)) + + def test_rejects_a_split_that_decides_nothing(self, algebra): + # both sides out, and both sides in: each is a second encoding of a + # constant, so neither is an element + assert not algebra.automata['U'].accepts( + Tree('n', Tree('0'), Tree('0'))) + assert not algebra.automata['U'].accepts( + Tree('n', Tree('1'), Tree('1'))) + assert algebra.automata['U'].accepts(Tree('n', Tree('0'), Tree('1'))) + + +class TestOperationsAgainstSetAlgebra: + def test_containment(self, algebra): + order = algebra.automata['Leq'] + for x, y in itertools.product(SPACE, repeat=2): + assert order.accepts(algebra.encode(x), algebra.encode(y)) == \ + (points(x) <= points(y)), (x, y) + + @pytest.mark.parametrize("name,operation", + [('Meet', lambda a, b: a & b), + ('Join', lambda a, b: a | b)]) + def test_meet_and_join(self, algebra, name, operation): + algebra.materialize() + relation = algebra.automata[name] + for x, y, z in itertools.product(SPACE, repeat=3): + want = operation(points(x), points(y)) == points(z) + assert relation.accepts(*map(algebra.encode, (x, y, z))) == want, \ + (name, x, y, z) + + def test_complement(self, algebra): + algebra.materialize() + relation = algebra.automata['Compl'] + for x, y in itertools.product(SPACE, repeat=2): + want = (EVERYTHING - points(x)) == points(y) + assert relation.accepts(algebra.encode(x), + algebra.encode(y)) == want, (x, y) + + +class TestTermSurface: + """Meet, join and complement are terms — ``*``, ``+`` and unary ``-`` — + because ``&``, ``|`` and ``~`` already mean the connectives of the logic.""" + + def test_operations_as_terms(self, algebra): + x, y = algebra.symbolic().vars('x y') + meet = (x * y).eq({'00'}).evaluate() + assert meet.contains(x={'0'}, y={'00', '11'}) + assert not meet.contains(x={'0'}, y={'1'}) + + join = (x + y).eq({''}).evaluate() + assert join.contains(x={'0'}, y={'1'}) + assert not join.contains(x={'0'}, y={'00'}) + + complement = (-x).eq(y).evaluate() + assert complement.contains(x={'0'}, y={'1'}) + assert not complement.contains(x={'0'}, y={'0'}) + + def test_the_order_as_a_method(self, algebra): + x, y = algebra.symbolic().vars('x y') + order = x.leq(y).evaluate() + assert order.contains(x={'00'}, y={'0'}) + assert not order.contains(x={'0'}, y={'00'}) + + @pytest.mark.parametrize("law", ['idempotent', 'commutative', 'absorption', + 'distributive', 'involution', 'de morgan']) + def test_the_axioms_hold(self, algebra, law): + x, y, z = algebra.symbolic().vars('x y z') + laws = { + 'idempotent': (x * x).eq(x).all('x'), + 'commutative': (x * y).eq(y * x).all('x y'), + 'absorption': (x * (x + y)).eq(x).all('x y'), + 'distributive': (x * (y + z)).eq((x * y) + (x * z)).all('x y z'), + 'involution': (-(-x)).eq(x).all('x'), + 'de morgan': (-(x * y)).eq((-x) + (-y)).all('x y'), + } + assert laws[law].check() + + +class TestAtomless: + """The property the algebra is named for, and the one that pins it down: + a countable atomless boolean algebra is unique up to isomorphism.""" + + def test_it_is_atomless(self, algebra): + assert algebra.is_atomless() + + def test_the_atom_relation_is_empty(self, algebra): + assert not algebra.check('exists x.(Atom(x))') + + def test_the_same_sentence_separates_it_from_the_finite_algebras( + self, algebra): + """`Atom` is in both signatures, so one sentence tells them apart — + every finite boolean algebra has an atom and this one has none.""" + finite = FiniteBooleanAlgebras() + assert finite.check('exists x.(Atom(x))', 4) + assert not algebra.check('exists x.(Atom(x))') + + def test_it_has_a_bottom_and_a_top(self, algebra): + assert algebra.check('exists x.(all y.(Leq(x,y)))') + assert algebra.check('exists x.(all y.(Leq(y,x)))') + + def test_every_element_has_a_complement(self, algebra): + assert algebra.check('all x.(exists y.(Compl(x,y)))') + + def test_the_order_is_a_partial_order(self, algebra): + assert algebra.check('all x.(Leq(x,x))') + assert algebra.check( + 'all x.(all y.(all z.((Leq(x,y) & Leq(y,z)) -> Leq(x,z))))') + assert algebra.check( + 'all x.(all y.((Leq(x,y) & Leq(y,x)) -> Eq(x,y)))') + + +class TestConstruction: + def test_repr(self, algebra): + assert repr(algebra) == "" + + def test_the_signature_matches_the_finite_algebras(self, algebra): + assert sorted(algebra.get_relation_symbols()) == \ + ['Atom', 'Compl', 'Eq', 'Join', 'Leq', 'Meet', 'U'] From bade4faa5fbd52275d35c2f8a14acd89b408d7a3 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 12 Aug 2026 22:28:39 +0200 Subject: [PATCH 45/76] Let a constant be a set or a map An element codec may accept any Python value, and for some structures the natural one is unhashable: a clopen set is a set of cylinders, an ordinal a map from exponent to coefficient. Constants sit in the dictionaries the compiler keys by subterm, so writing one into a formula raised TypeError -- reachable from the atomless boolean algebra and the tree ordinals alike. `Const` now hashes a canonical stand-in. Equal values give equal stand-ins, which is what the contract needs; unequal values may collide, which costs nothing, since equality still compares the values themselves. Co-Authored-By: Claude Opus 5 --- autstr/symbolic/expr.py | 30 ++++++++++++++++++++++++++++++ tests/test_symbolic.py | 27 +++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/autstr/symbolic/expr.py b/autstr/symbolic/expr.py index 969cccd..ae3ed38 100644 --- a/autstr/symbolic/expr.py +++ b/autstr/symbolic/expr.py @@ -15,6 +15,7 @@ from __future__ import annotations import math +from collections.abc import Mapping from typing import Any, Iterable, List, Sequence, Tuple, Union @@ -173,6 +174,31 @@ def __str__(self): return self.name +def _hashable(value): + """A hashable stand-in for a Python value. + + An element codec may accept any Python value, and for some structures the + natural one is a set or a map — a clopen set is a set of cylinders, an + ordinal a map from exponent to coefficient. Those are unhashable, but a + constant has to sit in the dictionaries the compiler keys by subterm, so + they get a canonical stand-in instead. Equal values give equal stand-ins, + which is what the hash/equality contract needs; unequal values may collide, + which costs nothing, since equality itself still compares the values. + """ + if isinstance(value, (set, frozenset)): + return 'set', tuple(sorted(map(_hashable, value), key=repr)) + if isinstance(value, Mapping): + return 'map', tuple(sorted(((_hashable(k), _hashable(v)) + for k, v in value.items()), key=repr)) + if isinstance(value, (list, tuple)): + return 'seq', tuple(map(_hashable, value)) + try: + hash(value) + except TypeError: + return 'repr', repr(value) + return value + + class Const(Term): """A Python value, encoded through the signature's codec.""" @@ -182,6 +208,10 @@ def __init__(self, ctx, value): super().__init__(ctx, (value,)) object.__setattr__(self, 'value', value) + def __hash__(self): + return hash((type(self).__name__, id(self.ctx), + _hashable(self.value))) + def variables(self): return [] diff --git a/tests/test_symbolic.py b/tests/test_symbolic.py index 096ba40..4f0abe1 100644 --- a/tests/test_symbolic.py +++ b/tests/test_symbolic.py @@ -514,3 +514,30 @@ def test_a_signature_carries_its_codec(): codec = FunctionCodec(_encode, _decode) assert order_signature({'Lt'}, codec=codec).codec is codec assert graph_signature({'E'}, codec=codec).codec is codec + + +# ---------------------------------------------------------------------- +# constants whose Python value is not hashable +# ---------------------------------------------------------------------- + +def test_a_constant_may_be_a_set_or_a_map(S): + """An element codec may accept any Python value, and for some structures + the natural one is a set (a clopen set of cylinders) or a map (an ordinal + as exponent to coefficient). Constants sit in the dictionaries the compiler + keys by subterm, so they must hash even then.""" + for value in [{1, 2}, {'a': 1, 'b': 2}, [1, 2], frozenset({3})]: + assert isinstance(hash(S.term(value)), int) + + +def test_equal_unhashable_constants_agree(S): + # equal values must land in the same bucket, whatever order they were + # written in + assert S.term({'a': 1, 'b': 2}) == S.term({'b': 2, 'a': 1}) + assert hash(S.term({'a': 1, 'b': 2})) == hash(S.term({'b': 2, 'a': 1})) + assert S.term({1, 2}) == S.term({2, 1}) + assert hash(S.term({1, 2})) == hash(S.term({2, 1})) + + +def test_distinct_unhashable_constants_stay_distinct(S): + assert S.term({1, 2}) != S.term({1, 3}) + assert S.term({'a': 1}) != S.term({'a': 2}) From d8cbb7afa22ec326ad1378917d01b9bfde4b08bc Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 12 Aug 2026 22:28:39 +0200 Subject: [PATCH 46/76] List the atomless boolean algebra in the README Co-Authored-By: Claude Opus 5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index aec87e9..5df1f71 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ batch: | package | structures | |---------|------------| | `autstr.arithmetic`, `autstr.buildin` | Presburger and Büchi arithmetic (ℤ, +, <, \|₂), Skolem arithmetic (ℕ, ·), the MSO0 finite-powerset structure | -| `autstr.algebra` | the localizations **ℤ[1/p]**, finite **Boolean algebras** | +| `autstr.algebra`, `autstr.tree_algebra` | the localizations **ℤ[1/p]**, finite **Boolean algebras**, and the countable **atomless Boolean algebra** | | `autstr.infinite_graphs`, `autstr.ordinals`, `autstr.turing` | the **integer grid** ℤⁿ, the **regular tree** T_k, the **ordinals** below ω^ω and — on the tree engine — below ω^(ω^ω), **Turing-machine configuration graphs** | **Classes** — one automaton for a whole family, indexed by advice: From 59525ff4f9b4c549ce38c16df37fd9db9c7af0dc Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 12 Aug 2026 23:39:19 +0200 Subject: [PATCH 47/76] partial_tree_automaton, the tree counterpart of partial_dfa Authoring a bottom-up tree automaton meant writing the exception arrays by hand, four parallel lists of numbers, and every hand-built automaton in the package spends most of its table saying "reject". The string engine has taken a partial table since `partial_dfa`; this is the same for trees, with a child given as a state name or None when it is absent, and everything unlisted going to a rejecting sink. The reason it matters more here than for strings is the alphabet: a convolution symbol is a tuple, so spelling out the rejecting transitions costs a factor of |Sigma|^k on top of a table over pairs of child states. Co-Authored-By: Claude Opus 5 --- autstr/utils/tree_automata_tools.py | 55 ++++++++++++++++++++ tests/test_tree_tools.py | 81 ++++++++++++++++++++++++++++- 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/autstr/utils/tree_automata_tools.py b/autstr/utils/tree_automata_tools.py index 21a62e6..c1778c6 100644 --- a/autstr/utils/tree_automata_tools.py +++ b/autstr/utils/tree_automata_tools.py @@ -587,6 +587,61 @@ def iterate_trees(sta: SparseTreeAutomaton, max_entries: int = 10 ** 7): yield from accepted +def partial_tree_automaton(base_alphabet, symbol_arity: int, + transitions: Dict[tuple, str], + final: Set[str]) -> SparseTreeAutomaton: + """A bottom-up tree automaton from a *partial* transition table — the tree + counterpart of `autstr.utils.automata_tools.partial_dfa`. + + Everything the table does not list goes to a rejecting sink, which is what + an automaton authored by hand almost always wants: a convolution alphabet + is a product, so spelling out "reject" for every combination of child + states and symbols would swamp the few transitions that carry the meaning. + + :param base_alphabet: the base alphabet, padding symbol included. + :param symbol_arity: the number of tapes. + :param transitions: ``{(left, right, symbol tuple): target}``, where a + child is a state name or None for an absent child. States are numbered + in order of first appearance. + :param final: the accepting states. + :return: a minimized `SparseTreeAutomaton`. + """ + base = frozenset(base_alphabet) + states: Dict[str, int] = {} + + def index(name: str) -> int: + if name not in states: + states[name] = len(states) + return states[name] + + for (left, right, symbol), target in transitions.items(): + if len(symbol) != symbol_arity: + raise ValueError( + f"{symbol!r} is not a symbol of {symbol_arity} tape(s)") + for name in (left, right, target): + if name is not None: + index(name) + for name in final: + index(name) + + sink = len(states) # the implicit rejecting state + absent = sink + 1 # SparseTreeAutomaton's BOT + rows = sorted((absent if left is None else states[left], + absent if right is None else states[right], + encode_symbol(symbol, base), states[target]) + for (left, right, symbol), target in transitions.items()) + + is_accepting = np.zeros(sink + 1, dtype=bool) + for name in final: + is_accepting[states[name]] = True + + return minimize(SparseTreeAutomaton( + sink + 1, sink, + [row[0] for row in rows], [row[1] for row in rows], + [row[2] for row in rows], [row[3] for row in rows], + is_accepting, symbol_arity, set(base_alphabet))) + + def tree_automaton(tree, base_alphabet, symbol_arity: int = 1 ) -> SparseTreeAutomaton: """Automaton accepting exactly the given tree. Subtrees are hash-consed diff --git a/tests/test_tree_tools.py b/tests/test_tree_tools.py index ab5a177..8743034 100644 --- a/tests/test_tree_tools.py +++ b/tests/test_tree_tools.py @@ -7,7 +7,8 @@ from autstr.sparse_tree_automata import SparseTreeAutomaton, Tree, tree_to_arrays from autstr.utils.misc import encode_symbol from autstr.utils.tree_automata_tools import ( - attach_padding, equivalent, expand, fold_tapes, minimize, project, + attach_padding, equivalent, expand, fold_tapes, minimize, + partial_tree_automaton, project, ) from test_tree_automata import RefDTA, random_sta, random_tree @@ -434,3 +435,81 @@ def test_arity_must_be_divisible(self): source = random_sta_arity(rng, 3, 2) with pytest.raises(ValueError, match="multiple"): fold_tapes(source, 2) + + +class TestPartialTreeAutomaton: + """Authoring a bottom-up automaton from a partial table: everything the + table omits must reject, and everything it lists must fire — including the + rows whose children are absent, which is how leaves are written.""" + + ALPHABET = {'*', 'a', 'b'} + + def counting(self): + """Trees whose every node is labelled 'a', with at least one node + having two children.""" + table = {(None, None, ('a',)): 'thin', + ('thin', None, ('a',)): 'thin', + (None, 'thin', ('a',)): 'thin'} + for left in ('thin', 'wide'): + for right in ('thin', 'wide'): + table[(left, right, ('a',))] = 'wide' + for child in ('thin', 'wide'): + table[('wide', None, ('a',))] = 'wide' + table[(None, 'wide', ('a',))] = 'wide' + return partial_tree_automaton(self.ALPHABET, 1, table, {'wide'}) + + def test_accepts_what_the_table_describes(self): + automaton = self.counting() + assert automaton.accepts(Tree('a', Tree('a'), Tree('a'))) + assert automaton.accepts(Tree('a', Tree('a', Tree('a'), Tree('a')))) + assert not automaton.accepts(Tree('a')) + assert not automaton.accepts(Tree('a', Tree('a'))) + assert not automaton.accepts(Tree('a', None, Tree('a'))) + + def test_an_unlisted_symbol_sinks(self): + automaton = self.counting() + assert not automaton.accepts(Tree('a', Tree('b'), Tree('a'))) + assert not automaton.accepts(Tree('b', Tree('a'), Tree('a'))) + + def test_against_a_dictionary_oracle(self): + """A random partial table, run against a plain recursive evaluation of + the same table.""" + rng = random.Random(11) + names = ['s0', 's1', 's2'] + letters = sorted(self.ALPHABET) + for trial in range(20): + table = {} + for _ in range(rng.randint(3, 12)): + key = (rng.choice([None] + names), rng.choice([None] + names), + (rng.choice(letters),)) + table[key] = rng.choice(names) + final = set(rng.sample(names, rng.randint(1, 2))) + automaton = partial_tree_automaton(self.ALPHABET, 1, table, final) + + def state(tree): + left = state(tree.left) if tree.left is not None else None + right = state(tree.right) if tree.right is not None else None + if left == 'sink' or right == 'sink': + return 'sink' + return table.get((left, right, (tree.label,)), 'sink') + + for _ in range(30): + tree = random_labelled_tree(rng, letters) + assert automaton.accepts(tree) == (state(tree) in final), \ + (trial, tree) + + def test_the_symbol_must_match_the_arity(self): + with pytest.raises(ValueError, match="tape"): + partial_tree_automaton(self.ALPHABET, 2, {(None, None, ('a',)): 's'}, + {'s'}) + + +def random_labelled_tree(rng, letters, size=6): + """A small random tree over the given labels.""" + if size <= 1 or rng.random() < 0.3: + return Tree(rng.choice(letters)) + left = random_labelled_tree(rng, letters, size - 1 - rng.randint(0, 2)) \ + if rng.random() < 0.8 else None + right = random_labelled_tree(rng, letters, size - 1 - rng.randint(0, 2)) \ + if rng.random() < 0.6 else None + return Tree(rng.choice(letters), left, right) From 9d9b27a531500cdc9f258b0bb086adae58dd8e62 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 12 Aug 2026 23:39:40 +0200 Subject: [PATCH 48/76] Level 2 collapsible pushdown graphs, on the tree engine A collapsible stack is a stack of stacks in which every letter carries a link to the part of the stack that lay below it when the letter was pushed, and `collapse` throws the stack back there in one step. That is what puts these systems above ordinary higher-order pushdown systems -- they are the operational counterpart of higher-order recursion schemes -- and it is also why the tree engine is the only route to them: their MSO theory is undecidable, so the infinite-tree machinery that serves ordinary pushdown graphs has nothing to offer, while Kartzow's finite-tree encoding gives the whole first-order theory. Broadbent's result makes it tight: at level 3 even FO becomes undecidable. The encoding is Kartzow's. Consecutive words of a stack share long prefixes, since cloning is the only way to make new ones, so the words are laid into one tree: blocks of a blockline hang off each other to the right, and the blockline a block induces hangs below it to the left. Links are not stored -- a level 1 link points to the preceding letter, and a level 2 link on a node points to the substack of the width that node's position already counts -- which is what makes the encoding a bijection, and so the structure presentable without a quotient, exactly as the atomless boolean algebra needed reduced trees. Each operation is then a bounded rewrite at the end of the tree's last path, the one that takes every right child it can: clone hangs a separator on the last node push hangs a letter under it, except that a level 1 link can make the topmost word agree with its neighbour again, and the two blocks merge back into one pop 1 drops that letter, or moves the separator that ended the word up past the letter being dropped pop 2 drops the last separator and the letters below it collapse cuts everything from the last separator the descent passed BEFORE the topmost letter -- which is why the automaton has to carry, through the deleted region, both the letter its descent ends at and whether it got there without taking a right child Ten automata, none above nine states. The step relation is not authored at all: a rule is the intersection of its two control-state predicates, its topmost- symbol guard and its operation, and a label's relation is the union over its rules. The domain is every configuration, not only those reachable from the initial one. Reachability is tree-automatic too (Kartzow, Prop. 5.1) -- so FO with reachability is decidable here, the very question `autstr.turing` cannot answer -- but that construction is exponential in the number of control states and takes up the bulk of the paper, so it is documented rather than built. Checked against the stacks themselves: the encoding reproduces the paper's worked figure and round-trips; the domain automaton accepts every encoding and, enumerated smallest-first, accepts nothing else; each operation relation was compared with the oracle over some 1.35 million accept/reject decisions, and the edge relation with the system's own step relation. Co-Authored-By: Claude Opus 5 --- autstr/collapsible.py | 1011 +++++++++++++++++++++++++++++++++++++ tests/test_collapsible.py | 384 ++++++++++++++ 2 files changed, 1395 insertions(+) create mode 100644 autstr/collapsible.py create mode 100644 tests/test_collapsible.py diff --git a/autstr/collapsible.py b/autstr/collapsible.py new file mode 100644 index 0000000..b5428c9 --- /dev/null +++ b/autstr/collapsible.py @@ -0,0 +1,1011 @@ +"""Level 2 collapsible pushdown systems and their configuration graphs. + +A collapsible pushdown stack of level 2 is a stack of stacks in which every +letter additionally carries a *collapse link* to some part of the stack lying +below it — a record of what the stack looked like when the letter was pushed. +The ``collapse`` operation throws the stack back to that recorded point in one +step, which is what makes these systems strictly more expressive than ordinary +higher-order pushdown systems: they are the operational counterpart of +higher-order recursion schemes. + +**Why the tree engine.** Kartzow (2010) proved that level 2 collapsible +pushdown graphs are *tree*-automatic, and that is the only automatic route to +them: their MSO theory is undecidable, so the infinite-tree machinery that +serves ordinary pushdown graphs (Muller-Schupp, Caucal) has nothing to offer +here, while a finite-tree presentation gives the whole first-order theory. The +tradeoff is exactly the opposite of the one for ordinary pushdown graphs, whose +point is decidable MSO. It is also tight: Broadbent showed that at level 3 even +first-order model checking becomes undecidable. + +**The encoding.** A stack ``w_1 : w_2 : … : w_n`` is a list of words, and +consecutive words share long prefixes, because that is the only way ``clone_2`` +can make new ones. So the words are laid into one tree: a *block* is a maximal +run of consecutive words sharing their first two letters, blocks of a blockline +hang off each other as right children (``1``-successors), and the blockline a +block induces — the same words with their shared first letter removed — hangs +below it as a left child (``0``-successor). Every initial left-closed path of +the tree is then one word of the stack. Collapse links are *not* stored: a +level 1 link always points to the preceding letter, and a level 2 link on a +node ``d`` points to the substack of width ``|{d' a right child : d' ≤ d}|``, +which the position of ``d`` already determines. That is what makes the encoding +a bijection between configurations and a regular set of trees, and so the whole +structure tree-automatic without a quotient. + + >>> system = Level2CPS( # Hague et al.'s example + ... transitions=[('0', None, 'Cl', '1', 'clone'), + ... ('1', None, 'A', '0', 'push a 2'), + ... ('1', None, "A'", '2', 'push a 2'), + ... ('2', 'a', 'P', '2', 'pop 1'), + ... ('2', 'a', 'Co', '0', 'collapse')]) + >>> graph = system.configuration_graph() + >>> graph.is_deterministic() # two rules fire in state 1 + False + >>> graph.check('exists x.(not exists y.(E(x,y)))') # some are stuck + True + +**Reachability, and the contrast with Turing machines.** For a level 2 +collapsible pushdown graph the reachability relation is itself tree-automatic +(Kartzow, Prop. 5.1), so first-order logic *with* reachability is decidable +here — the very question that `autstr.turing` cannot answer, since for a +configuration graph of a Turing machine reachability is the halting problem. +That construction is not implemented: it is exponential in the number of +control states and takes up the bulk of the paper, whereas everything below is +elementary. This module presents the one-step relation, over *all* +configurations rather than only those reachable from an initial one — the same +reading Kartzow's result takes, and the only one available without the +reachability construction. + +References: + +* A. Kartzow, *Collapsible Pushdown Graphs of Level 2 are Tree-Automatic*, + Logical Methods in Computer Science 9(1), 2013 (STACS 2010). +* M. Hague, A. S. Murawski, C.-H. L. Ong, O. Serre, *Collapsible Pushdown + Automata and Recursion Schemes*, LICS 2008. +* C. Broadbent, *The Limits of Decidability for First Order Logic on CPDA + Graphs*, STACS 2012. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable, List, Optional, Sequence, Tuple + +from autstr.infinite_graphs import InfiniteGraph +from autstr.sparse_tree_automata import Tree +from autstr.utils.tree_automata_tools import ( + attach_padding, expand, minimize, partial_tree_automaton, +) + +#: the padding symbol of the tree convolutions; it must sort before every +#: other letter of the alphabet +PAD = '*' + +#: the label of a separator node — a node that splits one block from the next, +#: written ``ε`` in the literature because it repeats no letter +SEP = '.' + +#: the default bottom-of-stack symbol +BOTTOM = '⊥' + + +@dataclass(frozen=True) +class Letter: + """A stack letter: a symbol plus its collapse link. + + :param symbol: the stack symbol. + :param level: the level of the collapse link, 1 or 2. + :param link: at level 2, the width of the substack the link points to; a + level 1 link always points to the preceding letter, so it carries no + value of its own. + """ + symbol: str + level: int = 1 + link: Optional[int] = None + + def label(self) -> str: + """The tree label of this letter — the symbol and the link level, the + only parts the encoding stores.""" + return f'{self.symbol}:{self.level}' + + def __repr__(self): + return self.symbol if self.level == 1 \ + else f'{self.symbol}[{self.link}]' + + +@dataclass(frozen=True) +class Stack: + """A level 2 collapsible stack: a nonempty list of nonempty words. + + The operations return ``None`` where they are undefined — popping the last + letter of a word, popping the last word, or collapsing on a link that + points nowhere — rather than raising, so that a configuration with no + successor is an ordinary answer rather than an error. + """ + words: Tuple[Tuple[Letter, ...], ...] + + @property + def width(self) -> int: + """The number of words — the ``n`` of ``w_1 : … : w_n``.""" + return len(self.words) + + def top(self) -> Letter: + """The topmost letter: the last letter of the last word.""" + return self.words[-1][-1] + + def clone(self) -> 'Stack': + """``clone_2``: duplicate the topmost word, links and all.""" + return Stack(self.words + (self.words[-1],)) + + def push(self, symbol: str, level: int = 1) -> 'Stack': + """``push_{symbol,level}``: write a letter onto the topmost word. A + level 2 link points at the stack below the topmost word, whose width is + recorded; a level 1 link needs no record.""" + link = None if level == 1 else self.width - 1 + letter = Letter(symbol, level, link) + return Stack(self.words[:-1] + (self.words[-1] + (letter,),)) + + def pop1(self) -> Optional['Stack']: + """``pop_1``: drop the topmost letter, if the topmost word has one to + spare.""" + if len(self.words[-1]) < 2: + return None + return Stack(self.words[:-1] + (self.words[-1][:-1],)) + + def pop2(self) -> Optional['Stack']: + """``pop_2``: drop the topmost word, if it is not the only one.""" + if self.width < 2: + return None + return Stack(self.words[:-1]) + + def collapse(self) -> Optional['Stack']: + """``collapse``: jump to the stack the topmost letter's link points to. + + At level 1 that is the preceding letter, so the operation coincides + with `pop1`; at level 2 it is the substack of the recorded width, so it + is a whole run of `pop2` at once. + """ + top = self.top() + if top.level == 1: + return self.pop1() + return Stack(self.words[:top.link]) if top.link else None + + def apply(self, operation: 'Operation') -> Optional['Stack']: + """The stack this operation produces, or None where it is undefined.""" + if operation.kind == 'clone': + return self.clone() + if operation.kind == 'push': + return self.push(operation.symbol, operation.level) + if operation.kind == 'pop': + return self.pop1() if operation.level == 1 else self.pop2() + return self.collapse() + + def __repr__(self): + return ' : '.join(''.join(repr(letter) for letter in word) + for word in self.words) + + +@dataclass(frozen=True) +class Configuration: + """A configuration: a control state and a stack.""" + state: str + stack: Stack + + def __repr__(self): + return f'{self.state} {self.stack!r}' + + +@dataclass(frozen=True) +class Operation: + """A stack operation, as it appears in a transition rule. + + :param kind: ``'clone'``, ``'push'``, ``'pop'`` or ``'collapse'``. + :param symbol: the symbol a push writes. + :param level: the level of a push's link, or of a pop. + """ + kind: str + symbol: Optional[str] = None + level: Optional[int] = None + + @staticmethod + def parse(spec) -> 'Operation': + """An operation from its spelling: ``'clone'``, ``'push a'``, + ``'push a 2'``, ``'pop 1'``, ``'pop 2'`` or ``'collapse'``.""" + if isinstance(spec, Operation): + return spec + parts = str(spec).split() + if parts == ['clone'] or parts == ['clone', '2']: + return Operation('clone') + if parts == ['collapse']: + return Operation('collapse') + if len(parts) == 2 and parts[0] == 'pop' and parts[1] in ('1', '2'): + return Operation('pop', level=int(parts[1])) + if parts[:1] == ['push'] and len(parts) in (2, 3): + level = int(parts[2]) if len(parts) == 3 else 1 + if level in (1, 2): + return Operation('push', symbol=parts[1], level=level) + raise ValueError( + f"{spec!r} is no stack operation; write one of " + f"'clone', 'push [1|2]', 'pop 1', 'pop 2', 'collapse'") + + def __repr__(self): + if self.kind == 'push': + return f'push {self.symbol} {self.level}' + if self.kind == 'pop': + return f'pop {self.level}' + return self.kind + + +def initial_stack(bottom: str = BOTTOM) -> Stack: + """The initial stack ``⊥_2``: one word holding the bottom symbol.""" + return Stack(((Letter(bottom),),)) + + +# ---------------------------------------------------------------------- +# the encoding: a configuration as a finite binary tree +# ---------------------------------------------------------------------- + +def encode_stack(stack: Stack) -> Tree: + """The tree of a stack: blocks of a blockline hang off each other to the + right, the blockline a block induces hangs below it to the left.""" + return _encode_blockline(list(stack.words), + stack.words[0][0].label()) + + +def _encode_blockline(words: List[Tuple[Letter, ...]], label: str) -> Tree: + """The encoding of a blockline — a list of words all beginning with the + same letter, the one `label` names. + + The first maximal block is the longest run of words that agree on their + first *two* letters; it becomes the left subtree, with its shared first + letter stripped, and whatever is left of the blockline becomes the right + subtree, which repeats no label. + """ + first = words[0] + if len(first) == 1: # a block that is one letter + if len(words) == 1: + return Tree(label) + return Tree(label, None, _encode_blockline(words[1:], SEP)) + + second = first[1] + size = 1 + while size < len(words) and len(words[size]) > 1 and \ + words[size][1] == second: + size += 1 + induced = _encode_blockline([word[1:] for word in words[:size]], + second.label()) + if size == len(words): + return Tree(label, induced, None) + return Tree(label, induced, _encode_blockline(words[size:], SEP)) + + +def decode_stack(tree: Tree) -> Stack: + """The stack a tree encodes. + + Every word ends where the descent to the left ends, and every node that is + a right child starts the next word. The collapse links are read off the + positions: a level 2 link points to the substack whose width is the number + of right children up to and including this node in the traversal. + """ + words: List[Tuple[Letter, ...]] = [] + separators = 0 # right children seen so far + + def visit(node: Tree, prefix: Tuple[Letter, ...], is_right: bool) -> None: + nonlocal separators + if is_right: + separators += 1 + here = prefix if node.label == SEP \ + else prefix + (_parse_label(node.label, separators),) + if node.left is None: + words.append(here) # the word ends here + else: + visit(node.left, here, False) + if node.right is not None: + visit(node.right, here, True) + + visit(tree, (), False) + return Stack(tuple(words)) + + +def _parse_label(label: str, separators: int) -> Letter: + """The letter a node label names, with its link restored from the number + of right children up to that node.""" + symbol, _, level = label.rpartition(':') + if level not in ('1', '2') or not symbol: + raise ValueError(f"{label!r} labels no stack letter") + return Letter(symbol, 1) if level == '1' \ + else Letter(symbol, 2, separators) + + +def encode_configuration(configuration: Configuration) -> Tree: + """The tree of a configuration: the state labels the root and the stack + hangs below it to the left.""" + return Tree(f'<{configuration.state}>', + encode_stack(configuration.stack), None) + + +def decode_configuration(tree: Tree) -> Configuration: + """The configuration a tree encodes.""" + if tree.left is None or tree.right is not None or \ + not (tree.label.startswith('<') and tree.label.endswith('>')): + raise ValueError(f"{tree.label!r} roots no configuration") + return Configuration(tree.label[1:-1], decode_stack(tree.left)) + + +# ---------------------------------------------------------------------- +# the automata +# ---------------------------------------------------------------------- + +class _Encoding: + """The tree alphabet of one system, and every automaton authored over it. + + The stack operations are tree rewrites, and each is small: an operation + touches the *topmost* word, which lives on the path that takes every right + child it can and a left child otherwise — the last path of the traversal. + So each automaton reads a convolution of two configuration trees, finds a + bounded rewrite at the end of that path, and checks that the two trees + agree everywhere else. Carrying a finished rewrite up to the root is what + pins it to the topmost word: nothing may follow it in the traversal. + """ + + #: the two trees agree on this subtree + SAME = 'same' + #: the rewrite is done, and nothing in this subtree follows it + DONE = 'done' + #: the whole convolution is a step + ACCEPT = 'accept' + + def __init__(self, states: Sequence[str], symbols: Sequence[str], + bottom: str) -> None: + self.bottom_label = f'{bottom}:1' + #: the labels of letter nodes: the bottom symbol carries a level 1 + #: link and never occurs anywhere but at the bottom + self.letters = [self.bottom_label] + [ + f'{symbol}:{level}' for symbol in symbols if symbol != bottom + for level in (1, 2)] + self.states = [f'<{state}>' for state in states] + #: everything that can label a node of a stack tree + self.nodes = self.letters + [SEP] + self.alphabet = set(self.nodes) | set(self.states) | {PAD} + + # -- the domain ---------------------------------------------------- + def universe(self): + """The encoding trees. + + A configuration tree is a state over a bottom node; below that, left + children are letters and right children are separators. The one + further condition is Kartzow's: no node may have ``(σ,1)`` both as its + left child and as its right child's left child. Two adjacent blocks + would then begin with the same letter — a level 1 link is fixed by its + position, so those blocks are really one — and the encoding would stop + being injective. Level 2 links carry their own value, so the same + label may legitimately occur twice. + """ + table = {} + marks = ['-'] + [label.rpartition(':')[0] for label in self.letters + if label.endswith(':1')] + # the bottom symbol labels the bottom node and nothing else, so it is + # never anybody's left child + lefts = [None] + [f'letter {label}' for label in self.letters + if label != self.bottom_label] + rights = [None] + [f'sep {mark}' for mark in marks] + + def mark_of(left): + """The symbol this node's left child announces, when the left + child carries a level 1 link.""" + if left is None: + return '-' + label = left.partition(' ')[2] + return label.rpartition(':')[0] if label.endswith(':1') else '-' + + for label in self.nodes: + for left in lefts: + for right in rights: + if right is not None and \ + mark_of(left) == right.partition(' ')[2] != '-': + continue # the two blocks are one block + if label == SEP: + target = f'sep {mark_of(left)}' + elif label == self.bottom_label: + target = 'bottom' + else: + target = f'letter {label}' + table[(left, right, (label,))] = target + for state in self.states: + table[('bottom', None, (state,))] = self.ACCEPT + return self._build(1, table) + + def equality(self): + """``x = y``: the same tree twice.""" + table = self._agreement({}) + for state in self.states: + table[(self.SAME, None, (state, state))] = self.ACCEPT + return self._build(2, table) + + # -- the topmost letter -------------------------------------------- + def top_letter(self, accepted): + """The configurations whose topmost letter has one of the given + labels. + + The topmost letter sits at the end of the traversal's last path, so it + is read off bottom-up: a node inherits the letter its own descent ends + at — through the right child if it has one, else the left — and falls + back to its own label when that descent finds none. + """ + table = {} + options = [None] + [f'top {label}' for label in self.letters + ['-']] + for label in self.nodes: + for left in options: + for right in options: + top, _ = self._descent(label, self._top_of(left), + self._top_of(right)) + table[(left, right, (label,))] = f'top {top}' + for label in accepted: + for state in self.states: + table[(f'top {label}', None, (state,))] = self.ACCEPT + return self._build(1, table) + + @staticmethod + def _top_of(state): + """The letter a `top`/`gone` state names, or None for an absent + child.""" + return None if state is None else state.partition(' ')[2].split()[0] + + @staticmethod + def _descent(label, left_top, right_top, left_pure=True): + """Where the descent from a node ends, and whether it got there + without ever taking a right child. + + The descent takes the right child when there is one, so a letter found + below a right child is not reached purely by going left — which is + exactly the distinction ``collapse`` needs, since a link points at the + *last* separator the descent passed on its way down. + """ + own = label if label != SEP else '-' + if right_top is not None: + return (right_top, False) if right_top != '-' else (own, True) + if left_top is not None: + return (left_top, left_pure) if left_top != '-' else (own, True) + return own, True + + # -- one stack operation at a time --------------------------------- + def clone(self): + """``clone_2``: the topmost word is duplicated. Its copy shares all of + it, so the copy contributes no letter — just a separator, hung as a + right child on the very last node of the traversal.""" + table = self._skeleton() + table[(None, None, (PAD, SEP))] = 'new' + for label in self.nodes: + table[(None, 'new', (label, label))] = self.DONE + return self._build(2, table) + + def push(self, symbol: str, level: int): + """``push_{symbol,level}``: one letter is written onto the topmost + word, as a left child of the last node. + + When the letter carries a level 1 link this can make the topmost word + agree again with its neighbour — the two blocks the last separator + divides begin with the same letter, so they merge back into one. The + separator then disappears and the topmost word joins the block to its + left, at the far end of that block's chain of separators. Both trees + are produced; the merging case is the one whose plain reading is not + an encoding tree at all, so the domain sorts them out. + """ + written = f'{symbol}:{level}' + table = self._skeleton() + table[(None, None, (PAD, written))] = 'new' + for label in self.nodes: + table[('new', None, (label, label))] = self.DONE + plain = self._build(2, table) + if level == 2: + return plain + + table = self._skeleton() + table[(None, None, (PAD, SEP))] = 'new' # the block joins here + table[(None, None, (SEP, PAD))] = 'gone' # the separator goes + for label in self.nodes: + spine = 'chain =' if label == written else 'chain !' + for left in (None, self.SAME): + table[(left, 'new', (label, label))] = spine + for below in ('chain =', 'chain !'): + table[(left, below, (label, label))] = spine + table[('chain =', 'gone', (label, label))] = self.DONE + return minimize(plain.union(self._build(2, table))) + + def pop1(self): + """``pop_1``: the topmost letter goes. + + Either it is a node of its own — the last node of the traversal — and + simply disappears, or the topmost word ends at a separator, in which + case the letter is shared with the word below and what moves is the + separator: it climbs to just above the letter that was dropped. + """ + table = self._skeleton() + for label in self.letters: + if label != self.bottom_label: # the bottom never pops + table[(None, None, (label, PAD))] = 'gone leaf' + for label in self.nodes: + table[('gone leaf', None, (label, label))] = self.DONE + + table[(None, None, (SEP, PAD))] = 'gone' + table[(None, None, (PAD, SEP))] = 'new' + for below in ('gone', 'separators'): + for left in (None, self.SAME): + table[(left, below, (SEP, SEP))] = 'separators' + for label in self.letters: + table[(left, below, (label, label))] = 'letter' + for label in self.nodes: + table[('letter', 'new', (label, label))] = self.DONE + return self._build(2, table) + + def pop2(self): + """``pop_2``: the topmost word goes, and with it every node that no + other word passes through — the last separator of the tree and the + letters hanging below it.""" + table = self._skeleton() + table[(None, None, (SEP, PAD))] = 'gone' + for label in self.letters: # the word's own letters + table[(None, None, (label, PAD))] = 'gone chain' + table[('gone chain', None, (label, PAD))] = 'gone chain' + table[('gone chain', None, (SEP, PAD))] = 'gone' + for label in self.nodes: + for left in (None, self.SAME): + table[(left, 'gone', (label, label))] = self.DONE + return self._build(2, table) + + def collapse2(self): + """``collapse`` on a level 2 link: the stack is cut back to the width + the link records. + + That width counts the separators up to the topmost letter, so the cut + falls exactly at the last separator the descent passed *before* + reaching that letter — and everything from there on goes. Which + separator that is has to be read off the deleted part itself, so the + automaton carries, through the region only the first tree has, both + the letter its descent ends at and whether it got there without taking + a right child. + """ + table = self._skeleton() + region = [None] + [f'gone {label} {pure}' + for label in self.letters + ['-'] + for pure in ('left', 'right')] + for label in self.nodes: + for left in region: + for right in region: + top, pure = self._descent( + label, self._top_of(left), self._top_of(right), + left is None or left.endswith('left')) + table[(left, right, (label, PAD))] = \ + f'gone {top} {"left" if pure else "right"}' + for label in self.letters: + if not label.endswith(':2'): + continue # a level 1 link is a pop_1 + for node in self.nodes: + for left in (None, self.SAME): + table[(left, f'gone {label} left', (node, node))] = \ + self.DONE + return self._build(2, table) + + # -- shared pieces -------------------------------------------------- + def _skeleton(self): + """The transitions every operation shares: the two trees agree + wherever the rewrite is not, and a finished rewrite climbs to the + root with nothing after it.""" + return self._climb(self._agreement({})) + + def _agreement(self, table): + for label in self.nodes: + for left in (None, self.SAME): + for right in (None, self.SAME): + table[(left, right, (label, label))] = self.SAME + return table + + def _climb(self, table): + """Carry a finished rewrite to the root. Coming up from a right child + anything may sit to the left, but coming up from a left child there + must be no right child at all: otherwise the rewrite would not have + been at the end of the traversal, and so not on the topmost word.""" + for label in self.nodes: + table[(self.DONE, None, (label, label))] = self.DONE + table[(None, self.DONE, (label, label))] = self.DONE + table[(self.SAME, self.DONE, (label, label))] = self.DONE + for source in self.states: + for target in self.states: + table[(self.DONE, None, (source, target))] = self.ACCEPT + return table + + def _build(self, arity, table): + return partial_tree_automaton(self.alphabet, arity, table, + {self.ACCEPT}) + + def lift(self, automaton, position: int, arity: int = 2): + """A predicate on one configuration, read on one tape of a pair. The + padding closure comes first: the other tape's tree may reach deeper + than this one's.""" + return minimize(expand(minimize(attach_padding(automaton, PAD)), + arity, [position])) + + +# ---------------------------------------------------------------------- +# the system +# ---------------------------------------------------------------------- + +@dataclass(frozen=True) +class Rule: + """One transition: in state `state`, with `symbol` on top of the stack, + read `label`, go to `target` and apply `operation`. A `symbol` of None + matches any topmost symbol.""" + state: str + symbol: Optional[str] + label: str + target: str + operation: Operation + + def applies_to(self, configuration: Configuration) -> bool: + """Whether this rule's state and stack guard match.""" + return self.state == configuration.state and ( + self.symbol is None or + self.symbol == configuration.stack.top().symbol) + + +class Level2CPS: + """A collapsible pushdown system of level 2. + + :param transitions: the rules, as ``(state, symbol, label, target, + operation)`` tuples. The symbol may be None or ``'-'`` to match any + topmost symbol; the operation is a `Operation` or its spelling, one of + ``'clone'``, ``'push [1|2]'``, ``'pop 1'``, ``'pop 2'``, + ``'collapse'``. + :param bottom: the bottom-of-stack symbol, which no push may write. + :param initial_state: the control state of the initial configuration; + the first rule's state when omitted. + :param states: further control states, if some appear in no rule. + :param symbols: further stack symbols, likewise. + """ + + #: characters the encoding uses to build tree labels + RESERVED = ':<>' + + def __init__(self, transitions: Iterable[Sequence], + bottom: str = BOTTOM, initial_state: Optional[str] = None, + states: Iterable[str] = (), + symbols: Iterable[str] = ()) -> None: + self.bottom = bottom + self.rules = [self._rule(transition) for transition in transitions] + + found_states = {rule.state for rule in self.rules} + found_states |= {rule.target for rule in self.rules} + found_symbols = {bottom} + for rule in self.rules: + if rule.symbol is not None: + found_symbols.add(rule.symbol) + if rule.operation.kind == 'push': + found_symbols.add(rule.operation.symbol) + + self.states = sorted(found_states | set(states)) + self.symbols = sorted(found_symbols | set(symbols)) + self.labels = sorted({rule.label for rule in self.rules}) + self.initial_state = initial_state if initial_state is not None else ( + self.rules[0].state if self.rules else + (self.states[0] if self.states else 'q')) + + if bottom in {rule.operation.symbol for rule in self.rules}: + raise ValueError( + f"the bottom symbol {bottom!r} cannot be pushed; it is what " + f"marks the bottom of a stack") + if self.initial_state not in self.states: + self.states = sorted(set(self.states) | {self.initial_state}) + # only states and stack symbols become tree labels; an edge label is + # never encoded, so anything non-empty will do for one + for name in self.states + self.symbols: + self._check(name) + if not all(self.labels): + raise ValueError("an edge label may not be empty") + + def _check(self, name: str) -> None: + if not name: + raise ValueError("a state or stack symbol may not be empty") + if any(character in name for character in self.RESERVED): + raise ValueError( + f"{name!r} uses one of the reserved characters " + f"{self.RESERVED!r}, which the tree labels are built from") + if min(name) <= PAD: + raise ValueError( + f"{name!r} would not sort after the padding symbol {PAD!r}, " + f"which the tree convolutions rely on") + + @staticmethod + def _rule(transition) -> Rule: + if isinstance(transition, Rule): + return transition + state, symbol, label, target, operation = transition + return Rule(state, None if symbol in (None, '-') else symbol, + label, target, Operation.parse(operation)) + + # -- running the system -------------------------------------------- + def initial(self) -> Configuration: + """The initial configuration: the initial state over ``⊥_2``.""" + return Configuration(self.initial_state, initial_stack(self.bottom)) + + def step(self, configuration: Configuration + ) -> List[Tuple[str, Configuration]]: + """Every successor of a configuration, as ``(label, configuration)`` + pairs — the Python oracle the automata are checked against, and the + obvious way to run the system. A collapsible pushdown system is + nondeterministic, so there may be several, or none.""" + successors = [] + for rule in self.rules: + if not rule.applies_to(configuration): + continue + stack = configuration.stack.apply(rule.operation) + if stack is not None: + successors.append((rule.label, + Configuration(rule.target, stack))) + return successors + + def reachable(self, bound: int = 6) -> List[Configuration]: + """The configurations reachable from the initial one in at most + `bound` steps. + + The *unbounded* reachability relation is tree-automatic too (Kartzow, + Prop. 5.1) but is not built here, so this is a search rather than a + decision procedure — useful for seeing a system run, and for checking + the graph against small examples. + """ + seen, frontier = [self.initial()], [self.initial()] + for _ in range(bound): + frontier = [successor for configuration in frontier + for _, successor in self.step(configuration) + if successor not in seen] + for configuration in frontier: + if configuration not in seen: + seen.append(configuration) + return seen + + # -- the configuration graph --------------------------------------- + def configuration_graph(self, **kwargs) -> 'Level2CPG': + """The graph of all configurations under one step of the system.""" + return Level2CPG(self, **kwargs) + + # -- element codec -------------------------------------------------- + @staticmethod + def encode(configuration: Configuration) -> Tree: + """The tree encoding a configuration.""" + return encode_configuration(configuration) + + @staticmethod + def decode(tree: Tree) -> Configuration: + """The configuration a tree encodes.""" + return decode_configuration(tree) + + def __repr__(self): + return (f"") + + +def _names(prefix: str, tokens: Sequence[str]) -> List[str]: + """Relation names for a family, one per token and in the same order: the + token itself where it makes a readable symbol, its position otherwise — + all of them or none, so that the names of one family stay uniform. + + The result is a list rather than a dictionary because two tokens may well + be equal, and then it is their positions that tell them apart. + """ + names = [f'{prefix}{token}' for token in tokens] + if len(set(names)) == len(names) and \ + all(token.isascii() and token.isalnum() for token in tokens): + return names + return [f'{prefix}{index}' for index in range(len(tokens))] + + +class Level2CPG: + """The configuration graph of a `Level2CPS`, as a tree-automatic + structure. + + Vertices are *all* configurations — every control state over every level 2 + collapsible stack — and there is a ``γ``-labelled edge wherever one rule + of the system takes one configuration to another. The domain is regular + because the encoding is a bijection onto a regular set of trees; the edges + are regular because every stack operation is a bounded rewrite at the end + of the tree's last path. + + The presentation carries ``E`` (a step under any label), ``Edge…`` (one + per label of the system), ``Eq``, and — built on first use — the stack + operations ``Clone``, ``Push…``, ``Pop1``, ``Pop2``, ``Collapse`` as + relations of their own, the control-state predicates ``State…``, and the + predicates ``Top…``, ``Level1``, ``Level2`` for the topmost letter. + + :param system: the `Level2CPS`. + :param max_states: optional cap on the subset determinizations inside + projection. + """ + + def __init__(self, system: Level2CPS, + max_states: Optional[int] = None) -> None: + from autstr.symbolic import FunctionCodec + from autstr.tree_presentations import TreeAutomaticPresentation + self.system = system + encoding = _Encoding(system.states, system.symbols, system.bottom) + self.encoding = encoding + + self.state_names = dict(zip(system.states, + _names('State', system.states))) + # the bottom symbol is spelled ⊥ by convention, which makes no + # readable relation name, so it is the one symbol named for its role + self.symbol_names = dict(zip(system.symbols, _names( + 'Top', ['Bottom' if symbol == system.bottom else symbol + for symbol in system.symbols]))) + self.label_names = dict(zip(system.labels, + _names('Edge', system.labels))) + pushes = sorted({(rule.operation.symbol, rule.operation.level) + for rule in system.rules + if rule.operation.kind == 'push'}) + self.push_names = dict(zip(pushes, _names( + 'Push', [f'{symbol}{level}' for symbol, level in pushes]))) + self._check_names() + + edges = self._edges() + presentation = TreeAutomaticPresentation( + {'U': encoding.universe(), 'Eq': encoding.equality(), + 'E': edges[None], + **{self.label_names[label]: edges[label] + for label in system.labels}}, + padding_symbol=PAD, max_states=max_states) + # the rest is worth having by name, but no query has asked for it yet + presentation._declare_deferred({ + **{name: (lambda state=state: self._state_automaton(state)) + for state, name in self.state_names.items()}, + **{name: (lambda symbol=symbol: encoding.top_letter( + [f'{symbol}:{level}' for level in (1, 2)])) + for symbol, name in self.symbol_names.items()}, + **{f'Level{level}': (lambda level=level: encoding.top_letter( + [label for label in encoding.letters + if label.endswith(f':{level}')])) for level in (1, 2)}, + 'Clone': encoding.clone, + 'Pop1': encoding.pop1, + 'Pop2': encoding.pop2, + 'Collapse': self._collapse, + **{self.push_names[symbol, level]: + (lambda symbol=symbol, level=level: + encoding.push(symbol, level)) + for symbol, level in pushes}, + }) + self.presentation = presentation + self.graph = InfiniteGraph( + presentation, edge='E', directed=True, + codec=FunctionCodec(system.encode, system.decode)) + + #: the relation names that do not depend on the system + FIXED = ('U', 'Eq', 'E', 'Clone', 'Pop1', 'Pop2', 'Collapse', + 'Level1', 'Level2') + + def _check_names(self) -> None: + """No two relations may end up with the same name: the presentation + holds them in a dictionary, so a clash would quietly drop one instead + of failing.""" + names = (list(self.FIXED) + list(self.state_names.values()) + + list(self.symbol_names.values()) + + list(self.label_names.values()) + + list(self.push_names.values())) + clashes = sorted({name for name in names if names.count(name) > 1}) + if clashes: + raise ValueError( + f"the states, symbols and labels of this system name the same " + f"relation twice ({', '.join(clashes)}); rename one of them") + + # -- the automata of this system ----------------------------------- + def _state_automaton(self, state: str): + """The configurations whose control state is `state`.""" + encoding = self.encoding + table = {} + for label in encoding.nodes: + for left in (None, 'stack'): + for right in (None, 'stack'): + table[(left, right, (label,))] = 'stack' + table[('stack', None, (f'<{state}>',))] = encoding.ACCEPT + return partial_tree_automaton(encoding.alphabet, 1, table, + {encoding.ACCEPT}) + + def _collapse(self): + """``collapse``, at either link level: on a level 2 link the stack is + cut back to the recorded width, and on a level 1 link — which always + points at the preceding letter — it is exactly ``pop_1``.""" + encoding = self.encoding + level1 = encoding.top_letter([label for label in encoding.letters + if label.endswith(':1')]) + return minimize(encoding.collapse2().union(minimize( + encoding.lift(level1, 0).intersection( + minimize(attach_padding(encoding.pop1(), PAD)))))) + + def _operation(self, operation: Operation): + """The relation of one stack operation.""" + encoding = self.encoding + if operation.kind == 'clone': + return encoding.clone() + if operation.kind == 'push': + return encoding.push(operation.symbol, operation.level) + if operation.kind == 'pop': + return encoding.pop1() if operation.level == 1 else encoding.pop2() + return self._collapse() + + def _edges(self): + """One step, per label and in total. + + A rule is the conjunction of four conditions — the control state + before, the topmost symbol, the control state after, and the stack + operation — so it is built by intersecting four automata rather than + authored as one. The label's relation is the union over its rules. + """ + encoding = self.encoding + operations, predicates = {}, {} + + def operation(key): + if key not in operations: + operations[key] = minimize(attach_padding( + self._operation(key), PAD)) + return operations[key] + + def predicate(kind, key, position): + if (kind, key, position) not in predicates: + automaton = self._state_automaton(key) if kind == 'state' \ + else encoding.top_letter([f'{key}:{level}' + for level in (1, 2)]) + predicates[kind, key, position] = encoding.lift( + automaton, position) + return predicates[kind, key, position] + + per_label = {label: [] for label in self.system.labels} + for rule in self.system.rules: + step = operation(rule.operation) + step = minimize(step.intersection( + predicate('state', rule.state, 0))) + step = minimize(step.intersection( + predicate('state', rule.target, 1))) + if rule.symbol is not None: + step = minimize(step.intersection( + predicate('symbol', rule.symbol, 0))) + per_label[rule.label].append(step) + + edges = {label: _union(pieces) or _empty(encoding) + for label, pieces in per_label.items()} + edges[None] = _union(list(edges.values())) or _empty(encoding) + return edges + + # -- interface ------------------------------------------------------ + def symbolic(self, signature=None): + """A symbolic interface to the graph; write one step as ``x.adj(y)`` + and configurations as `Configuration` values.""" + return self.graph.symbolic(signature) + + def check(self, phi) -> bool: + return self.graph.check(phi) + + def evaluate(self, phi): + return self.graph.evaluate(phi) + + def get_relation_symbols(self): + return self.presentation.get_relation_symbols() + + def is_deterministic(self) -> bool: + """Whether every configuration has at most one successor — first-order, + and so decidable, even though reachability is not built here.""" + return self.presentation.check( + 'all x.(all y.(all z.((E(x,y) & E(x,z)) -> Eq(y,z))))') + + def __repr__(self): + return (f"") + + +def _union(automata): + """The union of a list of automata, or None if there is none.""" + if not automata: + return None + result = automata[0] + for automaton in automata[1:]: + result = minimize(result.union(automaton)) + return result + + +def _empty(encoding: _Encoding): + """The empty binary relation.""" + from autstr.tree_presentations import tree_zero + return tree_zero(2, encoding.alphabet) diff --git a/tests/test_collapsible.py b/tests/test_collapsible.py new file mode 100644 index 0000000..b7270d8 --- /dev/null +++ b/tests/test_collapsible.py @@ -0,0 +1,384 @@ +"""Level 2 collapsible pushdown systems and their configuration graphs. + +The oracle is the Python implementation of the stacks themselves: a stack is a +list of words of letters carrying links, and every operation on it is a few +lines of tuple slicing. The encoding, the domain automaton and each operation +relation are checked against it — on the configurations of an actual run, and +on stacks from a random walk through the operations, which reach shapes no +short run does. +""" +import itertools +import random + +import pytest + +from autstr.collapsible import ( + Configuration, Letter, Level2CPS, Operation, PAD, SEP, Stack, + decode_configuration, decode_stack, encode_configuration, encode_stack, + initial_stack, +) +from autstr.sparse_tree_automata import Tree, convolve_trees +from autstr.utils.misc import decode_symbol +from autstr.utils.tree_automata_tools import iterate_trees + +#: every operation of the level 2 signature, over a two-symbol alphabet +OPERATIONS = ([Operation('clone')] + + [Operation('push', symbol, level) + for symbol in ('a', 'b') for level in (1, 2)] + + [Operation('pop', level=1), Operation('pop', level=2), + Operation('collapse')]) + + +def walk(seed, steps=30): + """Stacks from a random walk through the operations, restarting whenever + the walk runs into an undefined one.""" + rng, stack, seen = random.Random(seed), initial_stack(), [] + for _ in range(steps): + seen.append(stack) + stack = stack.apply(rng.choice(OPERATIONS)) or initial_stack() + return seen + + +@pytest.fixture(scope="module") +def sample(): + """A few hundred stacks, deep and wide enough to exercise the encoding.""" + return [stack for seed in range(40) for stack in walk(seed)] + + +@pytest.fixture(scope="module") +def system(): + """The level 2 collapsible pushdown system of Hague et al., which Kartzow + draws as his running example: clone, push a linked to the stack below, + then pop or collapse.""" + return Level2CPS(transitions=[ + ('0', None, 'Cl', '1', 'clone'), + ('1', None, 'A', '0', 'push a 2'), + ('1', None, 'B', '2', 'push a 2'), + ('2', 'a', 'P', '2', 'pop 1'), + ('2', 'a', 'Co', '0', 'collapse'), + ]) + + +@pytest.fixture(scope="module") +def graph(system): + return system.configuration_graph() + + +@pytest.fixture(scope="module") +def operations(): + """A system whose rules mention every operation, over both stack symbols, + so that its graph presents them all.""" + return Level2CPS(transitions=[ + ('q', None, 'c', 'q', 'clone'), + ('q', None, 'p1', 'q', 'push a 1'), + ('q', None, 'p2', 'q', 'push a 2'), + ('q', None, 'o1', 'q', 'pop 1'), + ('q', None, 'o2', 'q', 'pop 2'), + ('q', None, 'co', 'q', 'collapse'), + ], symbols=('b',)).configuration_graph() + + +class TestStacks: + """The oracle itself, against the definitions.""" + + def test_the_initial_stack(self): + assert initial_stack().width == 1 + assert initial_stack().top() == Letter('⊥', 1) + + def test_clone_copies_links_too(self): + stack = initial_stack().push('a', 2).clone() + assert stack.words[0] == stack.words[1] + # the copy still points where the original did, which is what makes + # collapse jump further than a pop + assert stack.top() == Letter('a', 2, 0) + + def test_a_level_2_link_records_the_width_below(self): + stack = initial_stack().clone().clone().push('a', 2) + assert stack.top().link == 2 + assert stack.collapse().width == 2 + + def test_collapse_on_a_level_1_link_is_pop_1(self): + stack = initial_stack().push('a').push('b') + assert stack.collapse() == stack.pop1() + + def test_the_undefined_operations(self): + assert initial_stack().pop1() is None # nothing above the bottom + assert initial_stack().pop2() is None # only one word + assert initial_stack().push('a', 2).collapse() is None # link of 0 + + def test_collapse_undoes_a_whole_run_of_pops(self): + stack = initial_stack().clone().push('a', 2).clone().clone() + assert stack.width == 4 + # the letter was pushed onto a stack of width 2, and every clone since + # carried its link along, so one collapse undoes all three words + assert stack.collapse().width == 1 + + +class TestEncoding: + """The bijection between configurations and encoding trees.""" + + def test_the_paper_s_figure(self): + """Kartzow's Figure 2: a stack of five words and its encoding.""" + bottom, first, later = Letter('⊥'), Letter('a', 2, 0), Letter('a', 2, 2) + stack = Stack(( + (bottom, first, Letter('b', 2, 0)), + (bottom, first, Letter('b', 2, 0), Letter('c', 2, 1)), + (bottom, later, Letter('c')), + (bottom, later, Letter('d', 2, 3), Letter('e')), + (bottom, later), + )) + assert encode_stack(stack) == Tree( + '⊥:1', + Tree('a:2', Tree('b:2', None, Tree(SEP, Tree('c:2')))), + Tree(SEP, Tree('a:2', Tree('c:1'), + Tree(SEP, Tree('d:2', Tree('e:1')), + Tree(SEP))))) + assert decode_stack(encode_stack(stack)) == stack + + def test_the_initial_stack_is_a_single_node(self): + assert encode_stack(initial_stack()) == Tree('⊥:1') + assert encode_stack(initial_stack().clone()) == \ + Tree('⊥:1', None, Tree(SEP)) + + def test_round_trip(self, sample): + for stack in sample: + assert decode_stack(encode_stack(stack)) == stack + + def test_distinct_stacks_get_distinct_trees(self, sample): + seen = {} + for stack in sample: + key = repr(encode_stack(stack)) + assert seen.setdefault(key, stack) == stack + + def test_two_stacks_that_differ_only_in_a_link(self): + """The links are not stored, but they are still visible: the position + of a letter in the tree is what says where its link points.""" + cloned = initial_stack().push('a', 2).clone() # both links 0 + pushed = initial_stack().push('a', 2).clone().pop1().push('a', 2) + assert cloned.words[1][1] != pushed.words[1][1] + assert encode_stack(cloned) != encode_stack(pushed) + + def test_a_configuration_carries_its_state(self): + configuration = Configuration('q', initial_stack()) + assert encode_configuration(configuration).label == '' + assert decode_configuration( + encode_configuration(configuration)) == configuration + + +class TestDomain: + """The domain automaton accepts exactly the encoding trees.""" + + def test_accepts_every_encoding(self, operations, sample): + universe = operations.presentation.automata['U'] + for stack in sample: + assert universe.accepts( + encode_configuration(Configuration('q', stack))) + + def test_accepts_nothing_but_encodings(self, operations): + """The other direction, and the one that makes this a presentation + rather than a superset: enumerate what the automaton accepts, smallest + trees first, and decode each back. Every accepted tree has to be the + encoding of the configuration it decodes to.""" + alphabet = frozenset(operations.encoding.alphabet) + + def relabel(tree): + """Enumeration yields symbol codes; put the labels back.""" + return Tree(decode_symbol(tree.label, 1, alphabet)[0], + relabel(tree.left) if tree.left is not None else None, + relabel(tree.right) if tree.right is not None else None) + + # the authored automaton, not the padding-saturated one the + # presentation stores: a region of pure padding encodes nothing + trees = iterate_trees(operations.encoding.universe()) + for tree in map(relabel, itertools.islice(trees, 2000)): + assert encode_configuration(decode_configuration(tree)) == tree + + @pytest.mark.parametrize("tree", [ + Tree(''), # no stack at all + Tree('', Tree('a:1')), # no bottom symbol + Tree('⊥:1', Tree('a:1')), # no control state + Tree('', Tree('⊥:1'), Tree(SEP)), # a word beside the stack + Tree('', Tree('⊥:1', Tree('⊥:1'))), # two bottoms + Tree('', Tree('⊥:1', None, Tree('a:1'))), # a letter as separator + ]) + def test_rejects_malformed_trees(self, operations, tree): + assert not operations.presentation.automata['U'].accepts(tree) + + def test_adjacent_blocks_may_not_begin_with_the_same_level_1_letter( + self, operations): + """Two neighbouring blocks whose words agree on the next letter are + really one block, so that tree encodes nothing — with a level 1 link, + where the position fixes the link. With a level 2 link the two letters + differ in where they point, so the same shape is an encoding.""" + universe = operations.presentation.automata['U'] + assert not universe.accepts( + Tree('', Tree('⊥:1', Tree('a:1'), Tree(SEP, Tree('a:1'))))) + assert universe.accepts( + Tree('', Tree('⊥:1', Tree('a:2'), Tree(SEP, Tree('a:2'))))) + + +class TestOperations: + """Each operation relation, against the stacks themselves.""" + + #: relation name -> the operation it should present + RELATIONS = {'Clone': Operation('clone'), + 'Pop1': Operation('pop', level=1), + 'Pop2': Operation('pop', level=2), + 'Collapse': Operation('collapse'), + 'Pusha1': Operation('push', 'a', 1), + 'Pusha2': Operation('push', 'a', 2)} + + @pytest.mark.parametrize("name", sorted(RELATIONS)) + def test_against_the_oracle(self, operations, name, sample): + operation = self.RELATIONS[name] + automaton = operations.presentation.relation(name) + alphabet = frozenset(operations.encoding.alphabet) + for stack in sample[:120]: + expected = stack.apply(operation) + # the result, the other operations' results, and the stack itself + candidates = [other.apply(op) for other in [stack] + for op in OPERATIONS] + [stack] + for candidate in candidates: + if candidate is None: + continue + convolution = convolve_trees( + [encode_configuration(Configuration('q', stack)), + encode_configuration(Configuration('q', candidate))], + alphabet, PAD) + assert automaton.accepts(convolution) == \ + (candidate == expected), (name, stack, candidate) + + def test_collapse_is_more_than_the_pops(self, operations): + """The point of the level: some collapse is neither a pop_1 nor a + single pop_2, because it undoes a whole run of clones at once.""" + assert operations.check('exists x.(exists y.(Collapse(x,y) & ' + '(not Pop1(x,y)) & (not Pop2(x,y))))') + + def test_collapse_on_a_level_1_link_is_pop_1(self, operations): + assert operations.check( + 'all x.(all y.((Level1(x) & Collapse(x,y)) -> Pop1(x,y)))') + assert operations.check( + 'all x.(all y.((Level1(x) & Pop1(x,y)) -> Collapse(x,y)))') + + def test_the_operations_are_functions(self, operations): + """Each operation has at most one result — the system is what is + nondeterministic, not the stack machinery.""" + for name in ('Clone', 'Pop1', 'Pop2', 'Collapse', 'Pusha2'): + assert operations.check( + f'all x.(all y.(all z.(({name}(x,y) & {name}(x,z))' + f' -> Eq(y,z))))'), name + + def test_clone_and_a_push_never_agree(self, operations): + assert not operations.check('exists x.(exists y.(Clone(x,y) ' + '& Pusha2(x,y)))') + + +class TestConfigurationGraph: + """The graph of the system, against running it.""" + + def test_edges_agree_with_the_step_relation(self, system, graph): + configurations = system.reachable(bound=7) + assert len(configurations) > 10 + alphabet = frozenset(graph.encoding.alphabet) + edges = graph.presentation.automata['E'] + for source in configurations: + successors = {target for _, target in system.step(source)} + for candidate in configurations: + convolution = convolve_trees( + [encode_configuration(source), + encode_configuration(candidate)], alphabet, PAD) + assert edges.accepts(convolution) == \ + (candidate in successors), (source, candidate) + + def test_the_labelled_edges_split_the_edge_relation(self, system, graph): + names = [graph.label_names[label] for label in system.labels] + assert graph.check( + 'all x.(all y.(E(x,y) <-> (' + + ' | '.join(f'{name}(x,y)' for name in names) + ')))') + + def test_the_system_branches(self, graph): + """Two rules apply in state 1, so the graph is not deterministic — + a first-order question, and so decidable here.""" + assert not graph.is_deterministic() + + def test_some_configuration_is_stuck(self, graph): + """Over all configurations, not only the reachable ones: in state 2 + with the bottom symbol on top, no rule applies.""" + assert graph.check('exists x.(not exists y.(E(x,y)))') + assert graph.check( + 'all x.((State2(x) & TopBottom(x)) -> (not exists y.(E(x,y))))') + + def test_a_control_state_is_definable(self, graph): + assert graph.check('all x.(State0(x) | State1(x) | State2(x))') + assert not graph.check('exists x.(State0(x) & State1(x))') + + def test_evaluate_gives_back_the_step_relation(self, graph, system): + initial = system.initial() + successors = graph.evaluate('E(x,y)') + assert successors.accepts(convolve_trees( + [encode_configuration(initial), + encode_configuration(system.step(initial)[0][1])], + frozenset(graph.encoding.alphabet), PAD)) + + def test_symbolic_interface(self, graph, system): + initial = system.initial() + x = graph.symbolic().var("x") + assert x.adj(system.step(initial)[0][1]).check() + # something does step into the initial configuration — a collapse all + # the way back — but nothing steps into itself: every operation moves + assert x.adj(initial).check() + assert not (x.eq(initial) & x.adj(initial)).check() + + +class TestSystem: + """The system's own bookkeeping.""" + + def test_operations_parse(self): + assert Operation.parse('push a') == Operation('push', 'a', 1) + assert Operation.parse('push a 2') == Operation('push', 'a', 2) + assert Operation.parse('pop 2') == Operation('pop', level=2) + assert Operation.parse('clone') == Operation('clone') + with pytest.raises(ValueError, match="no stack operation"): + Operation.parse('push a 3') + with pytest.raises(ValueError, match="no stack operation"): + Operation.parse('collapse 2') + + def test_a_dash_matches_any_symbol(self): + system = Level2CPS([('q', '-', 'x', 'q', 'clone')]) + assert system.rules[0].symbol is None + assert system.step(Configuration('q', initial_stack())) + + def test_the_bottom_symbol_cannot_be_pushed(self): + with pytest.raises(ValueError, match="bottom"): + Level2CPS([('q', None, 'x', 'q', 'push ⊥ 1')]) + + def test_reserved_characters_are_rejected(self): + with pytest.raises(ValueError, match="reserved"): + Level2CPS([('q', None, 'x', 'q', 'push a:1 1')]) + + def test_relations_are_named_after_the_system(self, graph): + assert graph.symbol_names == {'⊥': 'TopBottom', 'a': 'Topa'} + assert graph.label_names['Cl'] == 'EdgeCl' + assert graph.push_names == {('a', 2): 'Pusha2'} + + def test_names_fall_back_to_positions(self): + """Symbols that would name the same relation — here a stack symbol + literally called Bottom, beside the bottom symbol itself — are told + apart by position, rather than one of them quietly replacing the + other.""" + graph = Level2CPS([('q', None, 'x', 'q', 'clone')], + symbols=('Bottom',)).configuration_graph() + assert sorted(graph.symbol_names.values()) == ['Top0', 'Top1'] + + def test_the_run_of_the_paper_s_example(self, system): + """The first steps of Kartzow's Figure 1: clone, push, clone, push.""" + configuration = system.initial() + for label in ('Cl', 'A', 'Cl', 'A'): + step = [target for read, target in system.step(configuration) + if read == label] + assert len(step) == 1 + configuration = step[0] + assert configuration.state == '0' + # the paper draws this stack as ⊥ : ⊥a : ⊥aa; the links are what the + # drawing leaves out, and each a points below the word it was pushed on + assert repr(configuration.stack) == '⊥ : ⊥a[1] : ⊥a[1]a[2]' From 57e3c60719cfa0e2a7857ded5406693844ba7fde Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Wed, 12 Aug 2026 23:45:37 +0200 Subject: [PATCH 49/76] List the collapsible pushdown graphs in the README Co-Authored-By: Claude Opus 5 --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 5df1f71..91f9592 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,7 @@ batch: | `autstr.arithmetic`, `autstr.buildin` | Presburger and Büchi arithmetic (ℤ, +, <, \|₂), Skolem arithmetic (ℕ, ·), the MSO0 finite-powerset structure | | `autstr.algebra`, `autstr.tree_algebra` | the localizations **ℤ[1/p]**, finite **Boolean algebras**, and the countable **atomless Boolean algebra** | | `autstr.infinite_graphs`, `autstr.ordinals`, `autstr.turing` | the **integer grid** ℤⁿ, the **regular tree** T_k, the **ordinals** below ω^ω and — on the tree engine — below ω^(ω^ω), **Turing-machine configuration graphs** | +| `autstr.collapsible` | **level 2 collapsible pushdown graphs** — tree-automatic by Kartzow's encoding, and the one automatic route to them, since their MSO theory is undecidable | **Classes** — one automaton for a whole family, indexed by advice: From 7521c09909f84042e9813c772b27aed9977a087f Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Thu, 13 Aug 2026 11:29:03 +0200 Subject: [PATCH 50/76] A linear order and a domain-containment relation, on trees Both are small and both are needed by the tree quotient: `tree_order` compares two trees at the lexicographically least position where they differ, an absent position counting as larger, and `domain_within` says that one tree's positions lie inside another's fringed by a few levels -- at depth 0, plain domain containment. `tree_order` is a linear order but, unlike shortlex on words, NOT well-founded: growing a tree at the position where two differ makes it smaller, so a descending chain never stops. A test pins that down, since it is the whole reason the tree quotient cannot take the least element of a class. Co-Authored-By: Claude Opus 5 --- autstr/utils/tree_automata_tools.py | 112 ++++++++++++++++++++++++++++ tests/test_tree_tools.py | 105 +++++++++++++++++++++++++- 2 files changed, 214 insertions(+), 3 deletions(-) diff --git a/autstr/utils/tree_automata_tools.py b/autstr/utils/tree_automata_tools.py index c1778c6..c4f8526 100644 --- a/autstr/utils/tree_automata_tools.py +++ b/autstr/utils/tree_automata_tools.py @@ -319,6 +319,118 @@ def members(option: int) -> np.ndarray: pair_nodes=np.array(nodes, dtype=np.int64)) +# ==================================================================== +# Orders and domains +# ==================================================================== + +def tree_order(base_alphabet, padding_symbol, strict: bool = False + ) -> SparseTreeAutomaton: + """The binary automaton for ``x <= y``: compare the two trees at the + lexicographically least position where they differ, an *absent* position + counting as larger than any letter. + + Positions are ordered as the traversal visits them — a node, then its left + subtree, then its right — so the verdict of a node is its own letters', or, + where those agree, its left subtree's, or, where that is equal too, its + right subtree's. + + This is a linear order on trees, and it is **not well-founded**: growing a + tree at the position where two differ makes it *smaller*, so an infinite + descending chain is easy to write down and a set of trees need have no + least member. That is the whole reason a quotient over trees cannot pick + the least element of a class the way the string engine's shortlex order + does; see `autstr.interpretations`. + + :param strict: accept ``x < y`` rather than ``x <= y``. + """ + letters = sorted(base_alphabet) + frozen = frozenset(base_alphabet) + equal, less, greater = 0, 1, 2 + + def verdict(left, right, a, b): + if a == padding_symbol and b == padding_symbol: + return equal # a region of pure padding + if a == padding_symbol: # x stops here, so x is larger + return greater + if b == padding_symbol: + return less + if a != b: + return less if a < b else greater + return left if left != equal else right # this node agrees: descend + + rows = [(left, right, (a, b), + verdict(equal if left == 3 else left, + equal if right == 3 else right, a, b)) + for left in (equal, less, greater, 3) # 3 is the absent child + for right in (equal, less, greater, 3) + for a in letters for b in letters] + + return minimize(SparseTreeAutomaton( + 3, equal, + [row[0] for row in rows], [row[1] for row in rows], + [encode_symbol(row[2], frozen) for row in rows], + [row[3] for row in rows], + [not strict, True, False], 2, set(base_alphabet))) + + +def domain_within(base_alphabet, padding_symbol, depth: int = 0 + ) -> SparseTreeAutomaton: + """The binary automaton for ``dom(x) ⊆ dom(y)·{1,2}^{≤depth}``: every + position of `x` lies at most `depth` levels below a position of `y`. + + At ``depth = 0`` this is plain domain containment. Deeper, it is the + "fringe" relation the tree quotient construction needs: a description of an + equivalence class is a member that reaches only so far past the class's + shadow. + + A tree's domain is prefix-closed, so `y`'s positions form a region hanging + from the root, and every position of `x` outside it is a fixed number of + levels below where that region stops. The automaton therefore carries, up + from the leaves, how far the deepest still-uncovered position of `x` is. + """ + if depth < 0: + raise ValueError("depth must be >= 0") + letters = sorted(base_alphabet) + #: nothing of x is waiting to be covered, and y reaches into this subtree + covered = 'covered' + #: nothing of x is waiting, and neither is there any y here + empty = 'empty' + pending = [f'pending {i}' for i in range(depth + 1)] + + def waiting(state): + """How far below this subtree's root its deepest uncovered position + is, or None when there is none.""" + return pending.index(state) if state in pending else None + + table = {} + for a in letters: + for b in letters: + for left in [None, covered, empty] + pending: + for right in [None, covered, empty] + pending: + below = [waiting(child) for child in (left, right) + if child is not None] + below = [i + 1 for i in below if i is not None] + if b != padding_symbol: # y is here: it covers below + if any(i > depth for i in below): + continue # too far below the region + target = covered + else: + if covered in (left, right): + continue # y's positions are not + # prefix-closed, so this is no convolution of trees + if a != padding_symbol: + below.append(0) # x is here and uncovered + if not below: + target = empty + elif max(below) > depth: + continue # no position above can cover + else: + target = pending[max(below)] + table[(left, right, (a, b))] = target + + return partial_tree_automaton(base_alphabet, 2, table, {covered, empty}) + + # ==================================================================== # Padding # ==================================================================== diff --git a/tests/test_tree_tools.py b/tests/test_tree_tools.py index 8743034..c6cbe26 100644 --- a/tests/test_tree_tools.py +++ b/tests/test_tree_tools.py @@ -4,11 +4,13 @@ import pytest from autstr.utils.tree_automata_tools import k_deeper_automaton -from autstr.sparse_tree_automata import SparseTreeAutomaton, Tree, tree_to_arrays +from autstr.sparse_tree_automata import ( + SparseTreeAutomaton, Tree, convolve_trees, tree_to_arrays, +) from autstr.utils.misc import encode_symbol from autstr.utils.tree_automata_tools import ( - attach_padding, equivalent, expand, fold_tapes, minimize, - partial_tree_automaton, project, + attach_padding, domain_within, equivalent, expand, fold_tapes, minimize, + partial_tree_automaton, project, tree_order, ) from test_tree_automata import RefDTA, random_sta, random_tree @@ -513,3 +515,100 @@ def random_labelled_tree(rng, letters, size=6): right = random_labelled_tree(rng, letters, size - 1 - rng.randint(0, 2)) \ if rng.random() < 0.6 else None return Tree(rng.choice(letters), left, right) + + +class TestTreeOrder: + """The tree-automatic linear order: compare at the lexicographically least + differing position, an absent position counting as larger.""" + + ALPHABET = {'*', 'a', 'b'} + + @staticmethod + def positions(tree, prefix=''): + out = {prefix: tree.label} + if tree.left is not None: + out.update(TestTreeOrder.positions(tree.left, prefix + '1')) + if tree.right is not None: + out.update(TestTreeOrder.positions(tree.right, prefix + '2')) + return out + + @staticmethod + def less(x, y): + """The definition, on trees.""" + px, py = TestTreeOrder.positions(x), TestTreeOrder.positions(y) + differing = [p for p in set(px) | set(py) if px.get(p) != py.get(p)] + if not differing: + return False + p = min(differing) # lexicographic on {1,2}* is plain string + return p not in py or (p in px and px[p] < py[p]) + + def test_against_the_definition(self): + rng = random.Random(2027) + trees = [random_labelled_tree(rng, ['a', 'b']) for _ in range(60)] + strict = tree_order(self.ALPHABET, '*', strict=True) + weak = tree_order(self.ALPHABET, '*') + for x in trees: + for y in trees[:25]: + convolution = convolve_trees([x, y], frozenset(self.ALPHABET), + '*') + want = self.less(x, y) + assert strict.accepts(convolution) == want, (x, y) + assert weak.accepts(convolution) == ( + want or self.positions(x) == self.positions(y)) + + def test_it_is_linear(self): + rng = random.Random(5) + trees = [random_labelled_tree(rng, ['a', 'b']) for _ in range(30)] + for x in trees: + for y in trees: + same = self.positions(x) == self.positions(y) + assert self.less(x, y) + self.less(y, x) + same == 1, (x, y) + + def test_it_is_not_well_founded(self): + """Growing a tree at the position where two differ makes it smaller, + so a descending chain never stops — which is the whole reason the tree + quotient cannot take the least element of a class.""" + chain = [Tree('a')] + for _ in range(5): + chain.append(Tree('a', chain[-1])) + strict = tree_order(self.ALPHABET, '*', strict=True) + for smaller, larger in zip(chain[1:], chain): + assert self.less(smaller, larger) + assert strict.accepts(convolve_trees( + [smaller, larger], frozenset(self.ALPHABET), '*')) + + +class TestDomainWithin: + """``dom(x)`` inside ``dom(y)`` fringed by a few levels.""" + + ALPHABET = {'*', 'a', 'b'} + + @staticmethod + def within(x, y, depth): + dy = set(TestTreeOrder.positions(y)) + return all(any(p[:len(p) - k] in dy for k in range(depth + 1)) + for p in TestTreeOrder.positions(x)) + + @pytest.mark.parametrize("depth", [0, 1, 2, 3]) + def test_against_the_definition(self, depth): + rng = random.Random(90 + depth) + trees = [random_labelled_tree(rng, ['a', 'b']) for _ in range(50)] + automaton = domain_within(self.ALPHABET, '*', depth) + for x in trees: + for y in trees[:20]: + convolution = convolve_trees([x, y], frozenset(self.ALPHABET), + '*') + assert automaton.accepts(convolution) == \ + self.within(x, y, depth), (depth, x, y) + + def test_depth_zero_is_containment(self): + automaton = domain_within(self.ALPHABET, '*') + convolution = convolve_trees( + [Tree('a'), Tree('b', Tree('a'))], frozenset(self.ALPHABET), '*') + assert automaton.accepts(convolution) + assert not automaton.accepts(convolve_trees( + [Tree('b', Tree('a')), Tree('a')], frozenset(self.ALPHABET), '*')) + + def test_a_negative_depth_is_refused(self): + with pytest.raises(ValueError, match="depth"): + domain_within(self.ALPHABET, '*', -1) From b72093efccd68f5cccf057e0a6c224a7c5430ea1 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Thu, 13 Aug 2026 11:29:18 +0200 Subject: [PATCH 51/76] Quotient interpretations over the tree engine Elements as classes of a definable equivalence now work over trees as well as over words. `_Engine` carries a `representatives` function instead of a well-order, so both engines share `_quotient` and differ only in how a class names one of its members. Over words that member is the shortlex-least one, and shortlex is a well-order so it exists. Over trees nothing is: the automatic order (compare at the lexicographically least differing position) makes a tree SMALLER when it grows there, so a class need have no least element. Kuske & Weidner's answer (MFCS 2011) is to minimize over a finite part of the class instead -- the shadow of a class is the set of positions all its members have, a description is a member reaching at most |A_~| levels past it, and every class has finitely many descriptions and at least one, because below the shadow a subtree can be pumped down to the height of the equivalence automaton without leaving the class. The paper computes the shadow exactly, which takes both halves of a regularity argument, and the second half -- recognizing that a set of positions CONTAINS the shadow -- is a bespoke 2^Q construction encoding an unbounded existential that no first-order formula expresses. It is not needed. Writing x is a description of [y] == x ~ y & exists n. (all s. y ~ s -> dom(n) c= dom(s)) & dom(x) c= dom(n).{1,2}^{<=k} uses only the easy half, and since both occurrences of n are monotone the existential settles on the shadow by itself. What is left is one formula over four small automata, read on a scratch presentation whose universe is every tree -- the sets of positions are a second sort, so neither sort can be the universe. The exponential blowup they prove unavoidable lives in that inner `all s`, a projection followed by a complement; `max_states` on the source turns a blowup into a clear error. Expect the representatives themselves to be large trees: the order prefers a tree that grows, so the least description is as full as the bound allows -- the automaton stays small, the elements it accepts do not. Checked by the specification itself, in the engine: every class has a representative and no class has two. Plus the two ends -- quotienting Skolem arithmetic by equality returns it unchanged (language-equal U and M), and quotienting trees over {a,b} by "same root letter" leaves exactly the two trees that can be written down by hand, with the relation between them intact. Co-Authored-By: Claude Opus 5 --- autstr/interpretations.py | 199 ++++++++++++++++++----------- tests/test_tree_interpretations.py | 181 ++++++++++++++++++++++---- 2 files changed, 281 insertions(+), 99 deletions(-) diff --git a/autstr/interpretations.py b/autstr/interpretations.py index 70703b1..e93322d 100644 --- a/autstr/interpretations.py +++ b/autstr/interpretations.py @@ -22,20 +22,26 @@ symbol-width overhead, intrinsic to representing tuples. Quotient interpretations (elements as classes of a definable equivalence) are -supported at every dimension: pass ``quotient=ε``, and the universe is -restricted to the shortlex-least representative of each class. +supported at every dimension and on either engine: pass ``quotient=ε``, and the +universe is restricted to one representative per class. **Both engines.** The source may be an `AutomaticPresentation` or a `TreeAutomaticPresentation`, and the result is a presentation of the same kind; the orchestration is identical because both engines encode a convolution letter the same way. Over trees an element of a k-dimensional interpretation is a k-tuple of trees, which *is* one tree over k-tuples — the same fold, since the -tree convolution already overlays the shapes. Quotients are the exception: over -trees the representatives exist and are regular, but reaching them takes a -different construction from the string engine's "least element of the class", -because no tree-automatic order is well-founded. Name the representatives with -a formula and restrict the domain instead; `_quotient` says why in full, with -the reference for building them properly. +tree convolution already overlays the shapes. + +Only the choice of representative differs. Over words it is the shortlex-least +element of the class, and shortlex is a well-order, so that element exists. +Over trees no automatic order is well-founded — growing a tree at the position +where two differ makes it *smaller* — so a class need have no least element at +all, and the representative is instead the least *description*: a member that +reaches only so far past the positions the whole class shares. That is Kuske +and Weidner's construction; `_tree_representatives` gives it in full, including +why the expensive half of their proof is not needed here. It is the one part of +this module that can blow up — provably so — and it is worth passing +``max_states`` to the source presentation before asking for a large one. """ from __future__ import annotations @@ -51,6 +57,7 @@ #: structure and drops from the result _EQUIV = '_Equiv' _LE = '_Le' +_REP = '_Rep' @dataclass(frozen=True) class _Engine: @@ -67,9 +74,9 @@ class _Engine: permute_tapes: Callable fold_tapes: Callable presentation: Callable - #: a well-order on encodings, or None where the engine has none — the - #: quotient construction needs one to pick class representatives - well_order: Optional[Callable] + #: given the interpreted presentation carrying the equivalence as + #: `_EQUIV`, the automaton of one representative per class + representatives: Callable def _engine_for(source) -> _Engine: @@ -83,7 +90,7 @@ def _engine_for(source) -> _Engine: presentation=lambda automata, padding: TreeAutomaticPresentation( automata, padding_symbol=padding, max_states=source.max_states), - well_order=None) + representatives=_tree_representatives) from autstr.utils import automata_tools as words return _Engine( @@ -92,7 +99,7 @@ def _engine_for(source) -> _Engine: fold_tapes=words.fold_tapes, presentation=lambda automata, padding: AutomaticPresentation( automata, padding_symbol=padding), - well_order=words.shortlex_order) + representatives=_string_representatives) Formula = Union[str, logic.Expression] @@ -122,12 +129,13 @@ def interpret(source, domain: RelationSpec, :param dimension: k — elements are k-tuples of source elements. :param quotient: an equivalence formula ε(x̄, ȳ) (a binary relation over elements, so ``2 · dimension`` free variables). When given, elements - are its equivalence classes: the universe is restricted to the - shortlex-least representative of each class, and the relations are read - on those representatives. The caller must ensure ε really is an - equivalence and that every relation is ε-invariant. **String engine - only so far** — see `_quotient` for what the tree engine would need, - and for what to write instead meanwhile. + are its equivalence classes: the universe is restricted to one + representative of each class, and the relations are read on those + representatives. The caller must ensure ε really is an equivalence and + that every relation is ε-invariant. Over words the representative is + the shortlex-least member of the class; over trees it is the least + description, which is the same idea made to work without a well-order, + and which may cost exponentially many states (`_tree_representatives`). :return: a fresh presentation of the same kind as `source`. For k > 1 its alphabet is the source alphabet's k-fold product. """ @@ -162,68 +170,115 @@ def _quotient(source, domain: RelationSpec, equivalence: RelationSpec, engine: _Engine): """Interpret with a quotient, in two stages: first the plain (k-dim) interpretation carrying the equivalence as a relation, then a - one-dimensional interpretation restricting the universe to the - shortlex-least representative of each class. - - The representative predicate is first-order over the equivalence and a - shortlex well-order on the element encoding — ``x`` is canonical iff it is - ``<=`` every element equivalent to it — so the whole thing stays inside the - engine. - - That last step is what the tree engine does not have. It is *not* that - representatives fail to exist: every tree-automatic equivalence has a - regular complete system of representatives (Colcombet & Löding 2007), and - one is computable in polynomial space with ``2^O(|A|)`` states (Kuske & - Weidner, *Size and computation of injective tree automatic presentations*, - MFCS 2011, Thm. 3.1). What fails is *this* way of getting them. - - Taking the least element of a class needs a well-founded order, and the - tree-automatic order does not qualify. There is a tree-automatic linear - order — compare at the lexicographically least position where two trees - differ, counting an absent position as larger — but growing a tree at that - position makes it *smaller*, so an infinite descending chain is easy to - write down and a class need have no least element. Shortlex avoids this - over words only because the convolution aligns positions, which makes - length the primary key for free; a tree convolution aligns shapes instead, - and comparing two trees' sizes is not a finite-state property at all. - - Kuske and Weidner get round it by not minimizing over the whole class. - Their *shadow* of a class is the set of positions present in every member, - and a *description* is a member whose subtrees below the shadow's frontier - are no taller than the equivalence automaton has states. That set is - non-empty and finite for every class, so its least element under the linear - order exists — and the exponential price is unavoidable: they also show a - structure where every automaton recognizing a complete system of - representatives has exponentially many states. - - Until that is built, a caller who wants a quotient over trees names the - representatives instead: pick a formula ρ(x̄) true of exactly one element - per class and interpret with ``domain=δ ∧ ρ``, which is the same - construction with the choice made explicit. + one-dimensional interpretation restricting the universe to one + representative per class. + + Both engines end up here; they differ only in how a class is made to name + exactly one of its members, which is `_string_representatives` or + `_tree_representatives`. """ - if engine.well_order is None: - raise NotImplementedError( - f"the {engine.name} engine does not build quotient representatives " - f"yet: they exist and are regular, but reaching them needs the " - f"shadow construction of Kuske & Weidner (MFCS 2011) rather than " - f"the least element of a class, since no tree-automatic order is " - f"well-founded. Restrict the domain to a definable set of " - f"representatives instead (see the documentation of this " - f"construction)") - if _EQUIV in relations or _LE in relations: - raise ValueError(f"{_EQUIV!r} and {_LE!r} are reserved for the " - f"quotient construction") + reserved = (_EQUIV, _LE, _REP) + clash = [name for name in reserved if name in relations] + if clash: + raise ValueError(f"{', '.join(map(repr, clash))} " + f"{'is' if len(clash) == 1 else 'are'} reserved for " + f"the quotient construction") raw = interpret(source, domain, {**relations, _EQUIV: equivalence}, dimension) - raw.update(**{_LE: engine.well_order(raw.sigma, raw.padding_symbol)}) + raw.update(**{_REP: engine.representatives(raw)}) - representative = f'all y.({_EQUIV}(x,y) -> {_LE}(x,y))' specs: Dict[str, RelationSpec] = {} for name in relations: arity = raw.relation(name).symbol_arity args = [f'v{i}' for i in range(arity)] specs[name] = (f'{name}({",".join(args)})', args) - return interpret(raw, (representative, ['x']), specs) + return interpret(raw, (f'{_REP}(x)', ['x']), specs) + + +def _string_representatives(raw): + """The shortlex-least element of each class. + + Shortlex is a well-order, so a class has exactly one least member, and + being least is first-order over the equivalence and the order — which + keeps the whole construction inside the engine. + """ + from autstr.utils import automata_tools as words + raw.update(**{_LE: words.shortlex_order(raw.sigma, raw.padding_symbol)}) + return raw.evaluate(f'all y.({_EQUIV}(x,y) -> {_LE}(x,y))') + + +def _tree_representatives(raw): + """The least *description* of each class — Kuske and Weidner's + construction (*Size and computation of injective tree automatic + presentations*, MFCS 2011, §3). + + Least element of the class will not do here. There is a tree-automatic + linear order (`autstr.utils.tree_automata_tools.tree_order`: compare at the + lexicographically least position where two trees differ, an absent + position counting as larger), but it is not well-founded — growing a tree + at that position makes it smaller — so a class need have no least member. + Shortlex escapes this over words only because the convolution aligns + positions, which makes length the primary key for free; a tree convolution + aligns shapes instead, and comparing two trees' sizes is not a finite-state + property at all. + + So minimize over a finite part of the class instead. The *shadow* of a + class is the set of positions its members all have, and a *description* is + a member that reaches at most ``|A_∼|`` levels past it. Every class has + finitely many descriptions and at least one — below the shadow a subtree + can be pumped down to the height of the equivalence automaton without + leaving the class — so the least description exists, and picking it is the + choice this makes. + + The shadow itself never has to be computed. A description is a member `u` + for which *some* set of positions `n` is contained in every member of the + class and has `u` within ``|A_∼|`` levels of it; both halves grow with `n`, + so the existential quantifier settles on the shadow by itself, and the + hard half of the paper's argument — recognizing that a set of positions + *contains* the shadow — is never needed. What is left is one formula:: + + u is a description of [t] ≡ u ~ t ∧ ∃n. (∀s. t ~ s → dom(n) ⊆ dom(s)) + ∧ dom(u) ⊆ dom(n)·{1,2}^{≤k} + + read over a scratch presentation whose universe is *every* tree, since `n` + ranges over sets of positions rather than over elements. The exponential + blowup Kuske and Weidner prove unavoidable lives in that ``∀s`` — it is a + projection followed by a complement, and the subset construction inside it + is the ``2^Q`` of their Lemma 3.4. + + Expect the representatives themselves to be *large* trees, and much larger + than the smallest member of their class: the order prefers a tree that + grows, so the least description is as full as the description bound allows. + The automaton stays small; the elements it accepts do not. + """ + from autstr.tree_presentations import TreeAutomaticPresentation, tree_one + from autstr.utils import tree_automata_tools as trees + + equivalence = raw.relation(_EQUIV) + alphabet, padding = raw.base_alphabet, raw.padding_symbol + # how far past the shadow a description may reach: the pumping bound is + # the number of states of the equivalence automaton + depth = equivalence.num_states + + scratch = TreeAutomaticPresentation( + {'U': tree_one(1, alphabet), + # the elements are one sort among the trees, the sets of positions + # another, so neither is the universe and nothing is restricted + 'Dom': raw.automata['U'], + 'Eqv': equivalence, + 'Sub': trees.domain_within(alphabet, padding), + 'Frg': trees.domain_within(alphabet, padding, depth), + 'Lt': trees.tree_order(alphabet, padding, strict=True)}, + padding_symbol=padding, enforce_consistency=False, + max_states=raw.max_states) + # `Desc(x,y)`: x is a description of y's class. The variables are named so + # that their sorted order — which is the tape order `evaluate` returns, and + # so the argument order of the installed relation — puts the description + # first. + scratch.update(Desc='Eqv(x,y) & exists n.(' + '(all s.(Eqv(y,s) -> Sub(n,s))) & Frg(x,n))') + return scratch.evaluate( + 'Dom(x) & Desc(x,x) & (not exists u.(Desc(u,x) & Lt(u,x)))') def _fold_formula(source, spec: RelationSpec, k: int, engine: _Engine): diff --git a/tests/test_tree_interpretations.py b/tests/test_tree_interpretations.py index 53d774e..789a9b6 100644 --- a/tests/test_tree_interpretations.py +++ b/tests/test_tree_interpretations.py @@ -1,8 +1,10 @@ """First-order interpretations over the tree engine. -The source is Skolem arithmetic, (N_{>0}, ·) — multiplication is not +The source is mostly Skolem arithmetic, (N_{>0}, ·) — multiplication is not string-automatic, so these interpretations exist only over trees. Python's own -arithmetic is the oracle throughout. +arithmetic is the oracle throughout. The quotient tests add a purpose-built +structure whose classes are large and whose representatives can be written +down by hand. An element of a k-dimensional interpretation is a k-tuple of trees, which *is* one tree over k-tuples, since a tree convolution already overlays the shapes. @@ -15,9 +17,13 @@ import pytest from autstr.buildin.tree_presentations import SkolemArithmetic -from autstr.interpretations import interpret +from autstr.interpretations import _EQUIV, _tree_representatives, interpret from autstr.sparse_tree_automata import Tree, convolve_trees from autstr.tree_presentations import TreeAutomaticPresentation +from autstr.utils.tree_automata_tools import ( + attach_padding, equivalent, minimize, partial_tree_automaton, + tree_automaton, tree_order, +) PAD = SkolemArithmetic.PAD LETTERS = SkolemArithmetic.LETTERS @@ -136,32 +142,153 @@ def test_a_sparse_domain_does_not_blow_up(self, skolem): skolem.automata['M'].num_states -class TestQuotientsAreNotBuiltForTreesYet: - """Representatives exist — every tree-automatic equivalence has a regular - complete system of them — but not by the string engine's route of taking - the least element of a class, since no tree-automatic order is - well-founded. Reaching them needs the shadow construction of Kuske & - Weidner (MFCS 2011), which is not built here, so the construction refuses - rather than quietly picking a non-representative. +#: a two-letter alphabet for the purpose-built structure below +MARKS = ['a', 'b'] +MARK_ALPHABET = set(MARKS) | {PAD} + + +def any_tree(): + """Every tree over ``{a, b}``.""" + return partial_tree_automaton(MARK_ALPHABET, 1, { + (left, right, (letter,)): 'ok' for letter in MARKS + for left in (None, 'ok') for right in (None, 'ok')}, {'ok'}) + + +def root_pairs(accepted): + """Pairs of trees whose root letters are one of the given pairs — the + rest of the two trees is free, which is what makes the classes big.""" + states = [f'{a}{b}' for a in MARKS for b in MARKS] + ['past'] + table = {} + for left in [None] + states: + for right in [None] + states: + for a in MARKS: + for b in MARKS: + table[(left, right, (a, b))] = f'{a}{b}' + # below the root one tree may stop before the other + table[(left, right, (a, PAD))] = 'past' + table[(left, right, (PAD, a))] = 'past' + return partial_tree_automaton(MARK_ALPHABET, 2, table, set(accepted)) + + +def complete_tree(depth, root='a', filler='a'): + """The complete binary tree of the given depth.""" + def build(level): + return None if level > depth else \ + Tree(filler, build(level + 1), build(level + 1)) + return Tree(root, build(1), build(1)) + + +def one_per_class(raw, representatives): + """The specification of a complete system of representatives, checked in + the engine itself: every class has a representative, and no class has two. + + Quantifiers here range over the *elements*, so this says exactly what the + construction promises, whatever the classes happen to be. + """ + scratch = TreeAutomaticPresentation( + {'U': raw.automata['U'], 'Eqv': raw.relation(_EQUIV), + 'Rep': representatives, + 'Le': tree_order(raw.base_alphabet, raw.padding_symbol)}, + padding_symbol=raw.padding_symbol) + return (scratch.check('all x.(exists y.(Eqv(x,y) & Rep(y)))'), + # antisymmetry of a linear order is equality + scratch.check('all x.(all y.((Rep(x) & Rep(y) & Eqv(x,y))' + ' -> (Le(x,y) & Le(y,x))))')) + + +class TestQuotients: + """Elements as classes of a definable equivalence. + + Over words the representative is the shortlex-least element of a class. + Over trees no automatic order is well-founded, so a class need have no + least element, and the representative is instead the least *description* — + Kuske & Weidner's construction. These check that what comes out really is + one element per class, and that it is the one the construction promises. """ - def test_the_quotient_is_refused_with_an_explanation(self, skolem): - with pytest.raises(NotImplementedError, match="well-founded"): - interpret(skolem, domain=('Eq(x,x)', ['x']), - relations={'M': ('M(x,y,z)', ['x', 'y', 'z'])}, - quotient=('Eq(x,y)', ['x', 'y'])) - - def test_naming_the_representatives_is_the_way_round_it(self, skolem): - """The same construction with the choice made explicit: restrict the - domain to one element per class. Here the classes are "same square", - and the representative is the square itself.""" - representatives = interpret( - skolem, domain=('exists r.(M(r,r,x))', ['x']), - relations={'M': ('M(x,y,z)', ['x', 'y', 'z'])}) - assert representatives.automata['U'].accepts( - SkolemArithmetic.encode(9)) - assert not representatives.automata['U'].accepts( - SkolemArithmetic.encode(3)) + @pytest.fixture(scope="class") + def marked(self): + """Trees over ``{a, b}``, with "same root letter" as the equivalence + and one edge between the two classes.""" + return TreeAutomaticPresentation( + {'U': any_tree(), 'Same': root_pairs({'aa', 'bb'}), + 'Edge': root_pairs({'ab'})}, padding_symbol=PAD) + + def test_quotienting_by_equality_gives_the_structure_back(self, skolem): + """Every class is a singleton, so its only description is its one + member — the strongest check that nothing is lost on the way.""" + same = interpret(skolem, domain=('Eq(x,x)', ['x']), + relations={'M': ('M(x,y,z)', ['x', 'y', 'z'])}, + quotient=('Eq(x,y)', ['x', 'y'])) + assert equivalent(same.automata['U'], skolem.automata['U']) + assert equivalent(same.automata['M'], skolem.automata['M']) + assert same.automata['M'].accepts(*[SkolemArithmetic.encode(n) + for n in (5, 7, 35)]) + + def test_a_class_becomes_one_element(self, marked): + quotient = interpret(marked, 'U(x)', {'Edge': 'Edge(x,y)'}, + quotient='Same(x,y)') + raw = interpret(marked, 'U(x)', + {'Edge': 'Edge(x,y)', _EQUIV: 'Same(x,y)'}) + depth = raw.relation(_EQUIV).num_states + + # every class shares only the root, so a description is any tree of + # depth at most the bound — and the least of those, in an order where + # growing a tree makes it smaller, is the full one with least letters + universe = quotient.automata['U'] + for root in MARKS: + assert universe.accepts(complete_tree(depth, root)) + assert not universe.accepts(Tree('a')) + assert not universe.accepts(complete_tree(depth - 1)) + assert not universe.accepts(complete_tree(depth, 'a', 'b')) + + # and those two trees are the whole universe + only_those = minimize( + attach_padding(tree_automaton(complete_tree(depth, 'a'), + MARK_ALPHABET), PAD).union( + attach_padding(tree_automaton(complete_tree(depth, 'b'), + MARK_ALPHABET), PAD))) + assert equivalent(universe, only_those) + + def test_the_relations_come_along(self, marked): + quotient = interpret(marked, 'U(x)', {'Edge': 'Edge(x,y)'}, + quotient='Same(x,y)') + assert quotient.check('exists x.(exists y.(Edge(x,y)))') + assert quotient.check('all x.(all y.(Edge(x,y) -> (not Edge(y,x))))') + assert not quotient.check('exists x.(Edge(x,x))') + + @pytest.mark.parametrize("case", ['equality', 'root letter']) + def test_exactly_one_representative_per_class(self, skolem, marked, case): + if case == 'equality': + raw = interpret(skolem, domain=('Eq(x,x)', ['x']), + relations={_EQUIV: ('Eq(x,y)', ['x', 'y'])}) + else: + raw = interpret(marked, 'U(x)', {_EQUIV: 'Same(x,y)'}) + exists, unique = one_per_class(raw, _tree_representatives(raw)) + assert exists and unique + + def test_a_two_dimensional_quotient(self, skolem): + """Elements are pairs, and the equivalence forgets the second + coordinate — so the quotient is the first coordinate's structure.""" + pairs = interpret( + skolem, + domain=('Eq(x0,x0) & Eq(x1,x1)', ['x0', 'x1']), + relations={'First': ('Eq(x0,y0) & Eq(x1,x1) & Eq(y1,y1)', + ['x0', 'x1', 'y0', 'y1'])}, + dimension=2, + quotient=('Eq(x0,y0) & Eq(x1,x1) & Eq(y1,y1)', + ['x0', 'x1', 'y0', 'y1'])) + # one element per first coordinate, and First is then equality on them + assert pairs.check('all x.(all y.(First(x,y) -> First(y,x)))') + assert pairs.check('all x.(First(x,x))') + assert pairs.check('exists x.(exists y.(not First(x,y)))') + + def test_the_scaffolding_names_are_reserved(self, skolem): + for name in ('_Equiv', '_Le', '_Rep'): + with pytest.raises(ValueError, match="reserved"): + interpret(skolem, domain=('Eq(x,x)', ['x']), + relations={name: ('Eq(x,y)', ['x', 'y'])}, + quotient=('Eq(x,y)', ['x', 'y'])) class TestValidationCarriesOverToTrees: From 395f181efb3bfa8d77254d3e8e802a0ad643f5e0 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Thu, 13 Aug 2026 11:29:19 +0200 Subject: [PATCH 52/76] Note the tree quotient in the README Co-Authored-By: Claude Opus 5 --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 91f9592..bf6159a 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,8 @@ Four capabilities cut across all of these: interpreted presentation: a domain formula, a formula per relation, elements as k-tuples, optionally quotiented by a definable equivalence. It is how the ordinals are built — three formulas over Büchi arithmetic, no automaton - authored. + authored. Quotients work over trees too, where no order is well-founded and + the representative of a class is its least *description* (Kuske & Weidner). - **Implicit evaluation** (`autstr.implicit`) — `check_implicit` / `evaluate_implicit` decide formulas and compute satisfying sets *without building a query automaton*, reaching members whose automata are far too large From 27962ba0ef027bd3533301d1eaeb2ac3aa63cac7 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Thu, 13 Aug 2026 11:55:37 +0200 Subject: [PATCH 53/76] Say what the two conditions on n actually do The docstring claimed "both halves grow with n", which reads as a contradiction: if n lies inside every member of the class then it lies inside the description too, so how can the description reach past it? The two conditions sandwich the description rather than competing -- the first is a lower bound the description meets for free by being a member, the second is the upper bound, and it is the upper bound that cuts an infinite class down to finitely many candidates. The existential still lands on the shadow, for the reason now spelled out: the first condition admits exactly the sets below the shadow, and the second only gets weaker as the set grows, so the best one is the largest admissible one. Co-Authored-By: Claude Opus 5 --- autstr/interpretations.py | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/autstr/interpretations.py b/autstr/interpretations.py index e93322d..69e1b0b 100644 --- a/autstr/interpretations.py +++ b/autstr/interpretations.py @@ -231,20 +231,30 @@ class is the set of positions its members all have, and a *description* is choice this makes. The shadow itself never has to be computed. A description is a member `u` - for which *some* set of positions `n` is contained in every member of the - class and has `u` within ``|A_∼|`` levels of it; both halves grow with `n`, - so the existential quantifier settles on the shadow by itself, and the - hard half of the paper's argument — recognizing that a set of positions - *contains* the shadow — is never needed. What is left is one formula:: + for which *some* set of positions `n` lies inside every member of the class + and holds `u` within ``|A_∼|`` levels of itself:: u is a description of [t] ≡ u ~ t ∧ ∃n. (∀s. t ~ s → dom(n) ⊆ dom(s)) ∧ dom(u) ⊆ dom(n)·{1,2}^{≤k} - read over a scratch presentation whose universe is *every* tree, since `n` - ranges over sets of positions rather than over elements. The exponential - blowup Kuske and Weidner prove unavoidable lives in that ``∀s`` — it is a - projection followed by a complement, and the subset construction inside it - is the ``2^Q`` of their Lemma 3.4. + The two conditions on `n` sandwich `u` rather than pulling against each + other. The first makes `n` a *lower* bound on the whole class, so — `u` + being a member — ``dom(n) ⊆ dom(u)`` already holds; the second is the + *upper* bound, and is what cuts an infinite class down to a finite set of + candidates, which is the whole point. + + So the existential settles on the shadow by itself: the first condition + admits exactly the `n` below the shadow, and the second only gets weaker as + `n` grows, so the best `n` is the largest admissible one — the shadow. That + is why the hard half of the paper's argument, recognizing that a set of + positions *contains* the shadow, is never needed: nothing here ever asks + `n` to be at least the shadow. + + The formula is read over a scratch presentation whose universe is *every* + tree, since `n` ranges over sets of positions rather than over elements. + The exponential blowup Kuske and Weidner prove unavoidable lives in that + ``∀s`` — it is a projection followed by a complement, and the subset + construction inside it is the ``2^Q`` of their Lemma 3.4. Expect the representatives themselves to be *large* trees, and much larger than the smallest member of their class: the order prefers a tree that From 868ffcf52497c4f30099d4d5d4559b488b3869a8 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Thu, 13 Aug 2026 12:33:45 +0200 Subject: [PATCH 54/76] An oracle for returns, loops and 1-loops Kartzow's reachability construction decomposes every run into returns (a run from a stack to the one below it), loops (a run from a stack back to itself) and 1-loops, and everything downstream is a claim about which of these exist. This searches for them directly, by a bounded walk over configurations that prunes whatever each definition forbids -- the ground truth the computed sets will be checked against. Being bounded it under-approximates, which is the right direction: what it finds really is there, so every pair it exhibits must show up in a construction that claims to find them all. Two conventions worth naming, both forced by how the decomposition uses these sets. Asking for returns and loops of a WORD rather than of a stack means zeroing the level 2 links, which also makes them unusable -- exactly what the definition of a return demands, since a return may not use the links stored in the topmost word. And the run of length zero is a loop, so ExLoop and ExHLoop contain every (q,q); that is what makes "one operation followed by some loop" cover the bare operation too. Co-Authored-By: Claude Opus 5 --- tests/test_collapsible_reach.py | 227 ++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 tests/test_collapsible_reach.py diff --git a/tests/test_collapsible_reach.py b/tests/test_collapsible_reach.py new file mode 100644 index 0000000..cf6a8ff --- /dev/null +++ b/tests/test_collapsible_reach.py @@ -0,0 +1,227 @@ +"""Returns, loops and 1-loops of a level 2 collapsible pushdown system. + +Kartzow's reachability construction rests on a decomposition of every run into +*returns* (a run from a stack to the one below it), *loops* (a run from a stack +back to itself) and *1-loops*, and on the fact that which of these exist +depends only on the stack's **topmost word**. This module's oracle is the +definitions themselves, searched by hand: a bounded breadth-first walk over +configurations that prunes whatever the definition forbids. + +Being bounded, the oracle is an under-approximation — what it finds really is +there, and what it misses needs a longer run than the bound allows. That is the +right direction for checking a construction that claims to find *everything*: +every pair the oracle exhibits must appear in the computed set. +""" +from collections import deque + +from autstr.collapsible import Configuration, Letter, Level2CPS, Stack + + +def zeroed(word): + """The word with every level 2 link set to 0 — Kartzow's ``w↓₀``. + + Returns and loops are asked of a word rather than of a stack, and a link + into the stack below has no meaning once the stack is forgotten. Zeroing + them also makes those links unusable, which is exactly what the definition + of a return demands: it may not use the links stored in the topmost word. + """ + return tuple(letter if letter.level == 1 else Letter(letter.symbol, 2, 0) + for letter in word) + + +def search(system, start, allowed, goal, bound=2000, size=6, + empty_run=False): + """The states in which `goal` is reached from `start`. + + A breadth-first walk over configurations. `allowed` prunes the ones the + definition forbids as *intermediate* stacks — the goal is still recognized + when it is reached — and `size` caps the stacks so the walk terminates. + + :param empty_run: whether the run of length zero counts, i.e. whether + `start` itself is reported when it already satisfies `goal`. + """ + seen, frontier, reached = {start}, deque([start]), set() + if empty_run and goal(start): + reached.add(start.state) + steps = 0 + while frontier and steps < bound: + configuration = frontier.popleft() + steps += 1 + for _, successor in system.step(configuration): + if goal(successor): + reached.add(successor.state) + if successor in seen or not allowed(successor): + continue + stack = successor.stack + if stack.width > size or max(map(len, stack.words)) > size: + continue + seen.add(successor) + frontier.append(successor) + return reached + + +def ex_ret(system, word): + """``ExRet(w)``: the pairs (q, q') with a return from ``(q, w:w)`` to + ``(q', w)``. + + A return may not visit a substack of its target before the last step, and + every substack of the one-word stack ``w`` has width 1 — so the run must + stay at width 2 or more until it arrives. + """ + word = zeroed(word) + target = Stack((word,)) + return {(state, reached) + for state in system.states + for reached in search( + system, Configuration(state, Stack((word, word))), + allowed=lambda c: c.stack.width >= 2, + goal=lambda c: c.stack == target)} + + +def ex_loop(system, word, kind='any'): + """``ExLoop(w)``, ``ExHLoop(w)``, ``ExLLoop(w)``: the pairs (q, q') with a + loop from ``(q, w)`` back to ``(q', w)``. + + A loop may drop below its own topmost word only through letters carrying + level 1 links: reaching ``pop_1^k`` of the stack is allowed only when the k + letters it removed all had level 1 links. A *high* loop never drops at all; + a *low* loop drops on its first step and returns on its last. + + The run of length zero is a loop, so ``ExLoop`` and ``ExHLoop`` contain + every ``(q, q)``; a low loop has to take its two steps, so ``ExLLoop`` need + not. That convention is what makes "one operation followed by some loop" + cover the bare operation as well. + """ + word = zeroed(word) + stack = Stack((word,)) + droppable = {word[:len(word) - k] + for k in range(len(word) + 1) + if all(letter.level == 1 for letter in word[len(word) - k:])} + + def allowed(configuration): + current = configuration.stack + if current.width > 1: + return True # above the word: unconstrained + if kind == 'high' and current.words[0] != word: + return False # a high loop never drops + return current.words[0] in droppable or len(current.words[0]) > len(word) + + pairs = set() + for state in system.states: + start = Configuration(state, stack) + if kind == 'low': + # a low loop steps down to pop_1(s) first and comes back last + below = stack.pop1() + if below is None: + continue + starts = [target for _, target in system.step(start) + if target.stack == below] + reached = set() + for first in starts: + # the part between the two steps may itself be empty + for last in search(system, first, allowed, + goal=lambda c: c.stack == below, + empty_run=True): + # ... and the final step climbs back onto the word + reached |= {target.state for _, target + in system.step(Configuration(last, below)) + if target.stack == stack} + pairs |= {(state, other) for other in reached} + else: + pairs |= {(state, other) for other in search( + system, start, allowed, goal=lambda c: c.stack == stack, + empty_run=True)} + return pairs + + +def ex_one_loop(system, word): + """``ExOneLoop(w)``: the pairs (q, q') with a 1-loop from ``(q, w)`` to + ``(q', t:w)`` for some 2-word t — the stack grows underneath, and the + topmost word comes back exactly as it was.""" + word = zeroed(word) + return {(state, reached) + for state in system.states + for reached in search( + system, Configuration(state, Stack((word,))), + allowed=lambda c: True, + goal=lambda c: c.stack.width > 1 and c.stack.words[-1] == word)} + + +# ---------------------------------------------------------------------- +# the oracle against systems whose runs can be counted by hand +# ---------------------------------------------------------------------- + +BOTTOM = (Letter('⊥'),) + + +class TestReturns: + def test_one_rule_that_pops(self): + """The only return is the single pop_2 step.""" + system = Level2CPS([('p', None, 'o', 'q', 'pop 2')]) + assert ex_ret(system, BOTTOM) == {('p', 'q')} + + def test_cloning_alone_never_returns(self): + system = Level2CPS([('p', None, 'c', 'p', 'clone')]) + assert ex_ret(system, BOTTOM) == set() + + def test_a_collapse_of_a_freshly_pushed_letter_returns(self): + """Push a letter linked to the stack below, then collapse it: the + stack drops back by one word, which is a return.""" + system = Level2CPS([('p', None, 'a', 'r', 'push a 2'), + ('r', 'a', 'c', 'q', 'collapse')]) + assert ex_ret(system, BOTTOM) == {('p', 'q')} + + def test_a_return_may_take_a_detour_upwards(self): + """Clone first, pop twice: still a return, since the run never dips + below the target before the end. The two single pops are returns of + their own, from the states they start in.""" + system = Level2CPS([('p', None, 'c', 'r', 'clone'), + ('r', None, 'o', 's', 'pop 2'), + ('s', None, 'o', 'q', 'pop 2')]) + assert ex_ret(system, BOTTOM) == {('p', 'q'), ('r', 's'), ('s', 'q')} + + def test_a_run_that_dips_below_the_target_is_no_return(self): + """Popping to width 1 and cloning back is not a return: it visits the + target's own substack on the way.""" + system = Level2CPS([('p', None, 'o', 'r', 'pop 2'), + ('r', None, 'c', 'q', 'clone')]) + assert ('p', 'q') not in ex_ret(system, BOTTOM) + assert ex_ret(system, BOTTOM) == {('p', 'r')} + + +class TestLoops: + def test_a_loop_that_never_leaves_the_word(self): + """Clone and pop back: a high loop, since it never drops a letter.""" + system = Level2CPS([('p', None, 'c', 'r', 'clone'), + ('r', None, 'o', 'q', 'pop 2')]) + assert ('p', 'q') in ex_loop(system, BOTTOM) + assert ('p', 'q') in ex_loop(system, BOTTOM, kind='high') + + def test_a_loop_that_drops_a_level_1_letter_and_writes_it_back(self): + word = (Letter('⊥'), Letter('a')) + system = Level2CPS([('p', 'a', 'o', 'r', 'pop 1'), + ('r', None, 'w', 'q', 'push a 1')]) + assert ('p', 'q') in ex_loop(system, word) + assert ('p', 'q') in ex_loop(system, word, kind='low') + assert ('p', 'q') not in ex_loop(system, word, kind='high') + + def test_a_level_2_letter_may_not_be_dropped(self): + """The same run, but the letter carries a level 2 link — then passing + below it is not a loop at all.""" + word = (Letter('⊥'), Letter('a', 2, 0)) + system = Level2CPS([('p', 'a', 'o', 'r', 'pop 1'), + ('r', None, 'w', 'q', 'push a 2')]) + assert ('p', 'q') not in ex_loop(system, word) + assert ex_loop(system, word) == {(q, q) for q in system.states} + + +class TestOneLoops: + def test_the_stack_grows_underneath(self): + """Clone: the topmost word is back where it started, with a copy of + itself underneath.""" + system = Level2CPS([('p', None, 'c', 'q', 'clone')]) + assert ('p', 'q') in ex_one_loop(system, BOTTOM) + + def test_popping_is_no_one_loop(self): + system = Level2CPS([('p', None, 'o', 'q', 'pop 2')]) + assert ex_one_loop(system, BOTTOM) == set() From 938965820049584884293bc2466f4b872d2d7916 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Thu, 13 Aug 2026 13:21:21 +0200 Subject: [PATCH 55/76] Returns and loops, by saturation The summary of a word -- between which control states it has a return, a high loop, a low loop and a 1-loop -- is what Kartzow's reachability construction is built on, since which of those exist depends only on a stack's topmost word. This computes them. The paper's own effectiveness argument routes through mu-calculus model checking on collapsible pushdown graphs, which is a decision procedure of its own and no use to us. It is not needed. The decomposition lemmas the paper proves are already a closed system of rules saying how each kind of run is built out of shorter ones, and the least fixpoint of those rules is the summary. The rules reach for the summaries of LONGER words -- pushing a letter and dropping it again is how a run stays where it is -- so it is one simultaneous fixpoint rather than an induction on length, taken over words up to a bound that is raised until the summaries stop moving. Two rules were not obvious and both came from the oracle disagreeing: - a return may end with a pop AFTER pushing letters (the paper's condition is that the word is a PREFIX of the topmost word, not equal to it), and since pushing does not change the width, a return of the longer word lands exactly where a return of this one has to; - a 1-loop may grow the stack above, by pushing a letter and growing under the longer word before dropping it again, or below, by dropping this letter and growing under the word beneath -- not only by cloning the word itself. Checked against the oracle on 400 random systems x 5 words x 5 sets: nothing the search finds is missing from the computed summaries. That sweep also found two bugs in the ORACLE, which are fixed here -- a high loop may push and pop above the word freely and must only never drop below it, and a low loop may not drop a level 2 letter at all, since the definition of a loop forbids passing pop_1 except through level 1 links. Co-Authored-By: Claude Opus 5 --- autstr/collapsible_reach.py | 316 ++++++++++++++++++++++++++++++++ tests/test_collapsible_reach.py | 217 ++++++++++++++++++---- 2 files changed, 494 insertions(+), 39 deletions(-) create mode 100644 autstr/collapsible_reach.py diff --git a/autstr/collapsible_reach.py b/autstr/collapsible_reach.py new file mode 100644 index 0000000..f4bb8d8 --- /dev/null +++ b/autstr/collapsible_reach.py @@ -0,0 +1,316 @@ +"""Returns and loops of a level 2 collapsible pushdown system. + +Reachability in a collapsible pushdown graph is built out of four kinds of run +(Kartzow 2013, §4), all of them concerned with what a stack can do *before* it +drops below where it started: + +* a **return** takes a stack to the one below it, +* a **loop** takes a stack back to itself, and splits into a **high loop**, + which never drops a letter, and a **low loop**, which drops exactly one and + writes it back, +* a **1-loop** takes a stack to one with the same topmost word and more words + underneath. + +The pivotal fact is that which of these exist depends only on the stack's +**topmost word** — not on anything below it. So each word `w` has a *summary*: +four relations on the control states, saying between which states a return, a +high loop, a low loop and a 1-loop of `w` exist. Kartzow's Prop. 4.20 then says +the summary of `wσ` is determined by the summary of `w` together with σ, which +makes the summaries the states of a finite automaton reading a word bottom to +top. + +**How this computes them.** The paper's own effectiveness argument routes +through µ-calculus model checking on collapsible pushdown graphs, which is a +decision procedure of its own. It is not needed: the decomposition lemmas the +paper proves are already a closed system of rules, each saying how one kind of +run is built from shorter ones — + + return = high loop, then a pop, or a drop into the word below + followed by a return of it, or a pushed level 2 letter + collapsed after a 1-loop + high loop = (push, loop of the longer word, pop) and (clone, return), + closed under composition + low loop = drop a level 1 letter, loop of the word below, write it back + 1-loop = loop, clone, loop, clone, … + loop = high loop, or high loop then low loop then high loop + +— and the least fixpoint of those rules is the summary. The rules refer to the +summaries of *longer* words, which is why this is one simultaneous fixpoint +over the whole table rather than an induction on word length. + +**Why all four together.** A level 2 letter pushed at width d carries a link to +width d−1, and a clone copies the letter *with its link*, so collapsing it from +the copy drops two words at once — overshooting the copy's own return. Such a +run is no composition of two returns, and what covers it is exactly a 1-loop: +push the letter, let the stack grow underneath while the topmost word comes +back, then collapse. Returns therefore need 1-loops, which need loops, which +need returns. + +Reference: A. Kartzow, *Collapsible Pushdown Graphs of Level 2 are +Tree-Automatic*, LMCS 9(1), 2013, §4. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, FrozenSet, Optional, Tuple + +from autstr.collapsible import Level2CPS + +#: a relation on control states +Relation = FrozenSet[Tuple[str, str]] + +#: a letter of a word, as the summaries see it: a symbol and a link level. The +#: link *value* is always zero here — a summary is asked of a word on its own, +#: where a link into the stack below means nothing. +Letter = Tuple[str, int] + + +def compose(left: Relation, right: Relation) -> Relation: + """The relational composition ``left ; right``.""" + if not left or not right: + return frozenset() + targets: Dict[str, set] = {} + for source, middle in right: + targets.setdefault(source, set()).add(middle) + return frozenset((source, target) + for source, middle in left + for target in targets.get(middle, ())) + + +def closure(relation: Relation, states) -> Relation: + """The reflexive transitive closure.""" + result = relation | frozenset((state, state) for state in states) + while True: + grown = result | compose(result, result) + if grown == result: + return result + result = grown + + +@dataclass(frozen=True) +class Summary: + """What the runs of one word are, between which control states. + + :param symbol: the word's topmost symbol. + :param level: the link level of its topmost letter. + :param ret: pairs (q, q') with a return — a run to the stack below. + :param hloop: pairs with a high loop — back to the same stack, never + dropping the topmost letter. + :param lloop: pairs with a low loop — dropping the topmost letter and + writing it back. + :param oneloop: pairs with a 1-loop — back to the same topmost word, with + more words underneath. + """ + symbol: Optional[str] = None + level: int = 0 + ret: Relation = frozenset() + hloop: Relation = frozenset() + lloop: Relation = frozenset() + oneloop: Relation = frozenset() + + @property + def loop(self) -> Relation: + """Every loop: a high loop, or a high loop, a low loop and a high loop + in sequence (Kartzow, Cor. 4.17).""" + return self.hloop | compose(compose(self.hloop, self.lloop), + self.hloop) + + def relations(self): + return self.ret, self.hloop, self.lloop, self.oneloop + + +#: the summary of the empty word: nothing below the bottom letter, so no run of +#: any kind, and no letter to drop onto +EMPTY = Summary() + + +class Summaries: + """The summaries of the words a system can build. + + The rules for a word's runs refer to the runs of *longer* words — pushing a + letter and dropping it again is how a run stays where it is — so this is + one simultaneous least fixpoint rather than an induction on length. The + fixpoint is taken over the words themselves, up to a length bound, with + everything longer treated as having no runs at all. + + That makes the result an **under-approximation**: every run it reports is + real, and one it misses would need a word longer than the bound to be + written down. The bound is raised until the summaries stop changing, which + is where the fixpoint has been reached — for the automaton of Kartzow's + Prop. 4.20 the summaries of long words repeat, so this terminates on the + systems it is meant for, and `converged` says whether it did. + + :param system: the collapsible pushdown system. + :param depth: how many letters above the bottom to unroll before raising + the bound; raised up to `limit` while the summaries keep growing. + :param limit: the longest word the fixpoint will consider. + """ + + def __init__(self, system: Level2CPS, depth: int = 3, + limit: int = 7) -> None: + self.system = system + self.states = list(system.states) + self.letters = [(symbol, level) for symbol in system.symbols + if symbol != system.bottom for level in (1, 2)] + self.bottom = (system.bottom, 1) + self._moves = {symbol: self._transitions(symbol) + for symbol in system.symbols} + self.converged = False + self.values: Dict[tuple, Summary] = {} + self.depth = depth + previous_bound = 0 + for bound in range(depth, limit + 1): + previous, self.values = self.values, self._fixpoint(bound) + self.depth = bound + # the longest words of a round are the ones whose own extensions + # were pinned empty, so they always improve when the bound rises; + # convergence is about the words below that edge + if previous and all( + previous[path] == self.values[path] for path in previous + if len(path) < previous_bound): + self.converged = True + break + previous_bound = bound + + # -- the transitions available on one topmost symbol ---------------- + def _transitions(self, symbol: str) -> Dict[object, Relation]: + """The moves of the system when `symbol` is on top, by operation.""" + moves: Dict[object, set] = {} + for rule in self.system.rules: + if rule.symbol is not None and rule.symbol != symbol: + continue + operation = rule.operation + if operation.kind == 'push': + key = ('push', operation.symbol, operation.level) + elif operation.kind == 'pop': + key = f'pop{operation.level}' + else: + key = operation.kind + moves.setdefault(key, set()).add((rule.state, rule.target)) + return {key: frozenset(pairs) for key, pairs in moves.items()} + + def moves(self, symbol: str, key) -> Relation: + return self._moves.get(symbol, {}).get(key, frozenset()) + + def drops(self, letter: Letter) -> Relation: + """The moves that remove the topmost letter: a pop of level 1, and a + collapse when the link is of level 1 — a level 1 link always points at + the preceding letter, so collapsing on one is popping it.""" + symbol, level = letter + dropped = self.moves(symbol, 'pop1') + if level == 1: + dropped |= self.moves(symbol, 'collapse') + return dropped + + # -- the fixpoint --------------------------------------------------- + def _paths(self, bound: int): + """Every word of at most `bound` letters above the bottom one.""" + paths, frontier = [(self.bottom,)], [(self.bottom,)] + for _ in range(bound): + frontier = [path + (letter,) for path in frontier + for letter in self.letters] + paths.extend(frontier) + return paths + + def _fixpoint(self, bound: int) -> Dict[tuple, Summary]: + """The least fixpoint of the rules over the words of that length. + + Every rule is monotone in the summaries it reads, so starting from "no + runs at all" and applying them until nothing changes climbs to the + least fixpoint — which is the true summary for every word whose runs + stay within the bound. + """ + values = {path: Summary(path[-1][0], path[-1][1]) + for path in self._paths(bound)} + while True: + grown = {} + for path, current in values.items(): + below = values[path[:-1]] if len(path) > 1 else EMPTY + grown[path] = self._solve(below, path, values, current) + if grown == values: + return values + values = grown + + def of_word(self, word) -> Summary: + """The summary of a word, given as its letters from the bottom up.""" + path = tuple((symbol, level) for symbol, level in word) + if path not in self.values: + raise ValueError( + f"the fixpoint was taken over words of up to {self.depth} " + f"letter(s) above the bottom, which {path} exceeds") + return self.values[path] + + def _solve(self, below: Summary, path: tuple, values: Dict[tuple, Summary], + current: Summary) -> Summary: + """One round of the rules for the word `path`. + + `below` is the summary of the word without its topmost letter, and + `values` supplies the longer words — a letter pushed on top of this one + makes a word one longer, and what that word's runs are is what says + whether the letter can be pushed and dropped again. + """ + symbol, level = path[-1] + drop = self.drops(path[-1]) # remove this letter + clone = self.moves(symbol, 'clone') + pop2 = self.moves(symbol, 'pop2') + pushes = [(key, pairs) for key, pairs in self._moves.get( + symbol, {}).items() if isinstance(key, tuple)] + longer = {key: values.get(path + ((key[1], key[2]),), EMPTY) + for key, _ in pushes} + + # a high loop: push a letter, loop on the longer word, and drop it + # again; or clone, and return the copy + high = compose(clone, current.ret) + for key, pairs in pushes: + high |= compose(compose(pairs, longer[key].loop), + self.drops((key[1], key[2]))) + high = closure(high, self.states) + + # a low loop drops this letter -- only a level 1 link may be dropped + # and written back as it was -- loops below, and writes it back + low = frozenset() + if level == 1 and below is not EMPTY: + low = compose(compose(drop, below.loop), + self.moves(below.symbol, ('push', symbol, 1))) + + # a 1-loop leaves the stack taller than it found it, with the topmost + # word back as it was. The growth may happen here, by cloning this + # word; above, by pushing a letter and growing under the longer word + # before dropping it again; or below, by dropping this letter and + # growing under the word beneath before writing it back. + loop = high | compose(compose(high, low), high) + grow = clone + for key, pairs in pushes: + grow |= compose(compose(pairs, longer[key].oneloop), + self.drops((key[1], key[2]))) + if level == 1 and below is not EMPTY: + grow |= compose(compose(drop, below.oneloop), + self.moves(below.symbol, ('push', symbol, 1))) + # at least one of those, with loops in between and around + once = compose(compose(loop, grow), loop) + one = compose(once, closure(once, self.states)) + + # a return ends by popping the word, by dropping this letter and + # returning the word below, or by collapsing a level 2 letter pushed + # on top after the stack has grown underneath it + end = pop2 | compose(drop, below.ret) + for key, pairs in pushes: + inner = longer[key] + # pushing does not change the width, so a return of the longer + # word lands exactly where a return of this one has to + end |= compose(pairs, inner.ret) + if key[2] != 2: + continue # only a level 2 link collapses + # the letter points one word down, so collapsing it ends the + # return from wherever the stack has grown to -- or from right + # here, if it never grew + end |= compose(compose(pairs, inner.loop | inner.oneloop), + self.moves(key[1], 'collapse')) + ret = compose(high, end) + + return Summary(symbol, level, ret, high, low, one) + + def __repr__(self): + return (f"") diff --git a/tests/test_collapsible_reach.py b/tests/test_collapsible_reach.py index cf6a8ff..a64aad0 100644 --- a/tests/test_collapsible_reach.py +++ b/tests/test_collapsible_reach.py @@ -12,9 +12,13 @@ right direction for checking a construction that claims to find *everything*: every pair the oracle exhibits must appear in the computed set. """ +import random from collections import deque +import pytest + from autstr.collapsible import Configuration, Letter, Level2CPS, Stack +from autstr.collapsible_reach import Summaries def zeroed(word): @@ -94,56 +98,68 @@ def ex_loop(system, word, kind='any'): """ word = zeroed(word) stack = Stack((word,)) - droppable = {word[:len(word) - k] - for k in range(len(word) + 1) - if all(letter.level == 1 for letter in word[len(word) - k:])} - - def allowed(configuration): - current = configuration.stack - if current.width > 1: - return True # above the word: unconstrained - if kind == 'high' and current.words[0] != word: - return False # a high loop never drops - return current.words[0] in droppable or len(current.words[0]) > len(word) - - pairs = set() - for state in system.states: - start = Configuration(state, stack) - if kind == 'low': - # a low loop steps down to pop_1(s) first and comes back last - below = stack.pop1() - if below is None: - continue - starts = [target for _, target in system.step(start) - if target.stack == below] - reached = set() - for first in starts: - # the part between the two steps may itself be empty - for last in search(system, first, allowed, + + def staying(base, high): + """A loop of `base` may push and pop above it freely; what it may not + do is drop below, except through letters carrying level 1 links — and + a high loop may not drop at all.""" + def allowed(configuration): + current = configuration.stack + if current.width > 1: + return True # above the word: unconstrained + here = current.words[0] + if len(here) >= len(base): + return True # the word itself, or longer + return not high and all(letter.level == 1 + for letter in base[len(here):]) + return allowed + + if kind == 'low': + # a low loop steps down to pop_1(s), loops there, and writes the + # letter back — so the letter has to be one that may be dropped + below = stack.pop1() + if below is None or word[-1].level != 1: + return set() + pairs = set() + for state in system.states: + for _, first in system.step(Configuration(state, stack)): + if first.stack != below: + continue + # the part between the two steps is a loop of the word below, + # and may itself be empty + for last in search(system, first, + staying(below.words[0], False), goal=lambda c: c.stack == below, empty_run=True): - # ... and the final step climbs back onto the word - reached |= {target.state for _, target - in system.step(Configuration(last, below)) - if target.stack == stack} - pairs |= {(state, other) for other in reached} - else: - pairs |= {(state, other) for other in search( - system, start, allowed, goal=lambda c: c.stack == stack, - empty_run=True)} - return pairs + pairs |= {(state, target.state) for _, target + in system.step(Configuration(last, below)) + if target.stack == stack} + return pairs + + allowed = staying(word, kind == 'high') + return {(state, other) + for state in system.states + for other in search(system, Configuration(state, stack), allowed, + goal=lambda c: c.stack == stack, + empty_run=True)} def ex_one_loop(system, word): """``ExOneLoop(w)``: the pairs (q, q') with a 1-loop from ``(q, w)`` to - ``(q', t:w)`` for some 2-word t — the stack grows underneath, and the - topmost word comes back exactly as it was.""" + ``(q', t:w)`` for some *stack* t — the stack grows underneath, and the + topmost word comes back exactly as it was. + + The definition also asks that wherever the run shortens the topmost word + it later returns; rather than track that, this forbids shortening it at + all, which is a run of the required shape either way. So this direction + under-approximates more than the search bound alone does. + """ word = zeroed(word) return {(state, reached) for state in system.states for reached in search( system, Configuration(state, Stack((word,))), - allowed=lambda c: True, + allowed=lambda c: len(c.stack.words[-1]) >= len(word), goal=lambda c: c.stack.width > 1 and c.stack.words[-1] == word)} @@ -225,3 +241,126 @@ def test_the_stack_grows_underneath(self): def test_popping_is_no_one_loop(self): system = Level2CPS([('p', None, 'o', 'q', 'pop 2')]) assert ex_one_loop(system, BOTTOM) == set() + + +# ---------------------------------------------------------------------- +# the computed summaries, against that oracle +# ---------------------------------------------------------------------- + +SYMBOLS = ('a', 'b') +OPERATIONS = ['clone', 'pop 1', 'pop 2', 'collapse', + 'push a 1', 'push a 2', 'push b 1', 'push b 2'] + +#: words to compare on, from the bare bottom letter to one of every kind +SAMPLE_WORDS = [ + (Letter('⊥'),), + (Letter('⊥'), Letter('a')), + (Letter('⊥'), Letter('a', 2, 0)), + (Letter('⊥'), Letter('b'), Letter('a', 2, 0)), +] + + +def as_path(word): + return [(letter.symbol, letter.level) for letter in word] + + +def oracle(system, word): + """Every set the oracle can find, as the computed summary spells them.""" + return {'ret': ex_ret(system, word), + 'hloop': ex_loop(system, word, kind='high'), + 'lloop': ex_loop(system, word, kind='low'), + 'loop': ex_loop(system, word), + 'oneloop': ex_one_loop(system, word)} + + +def computed(summaries, word): + summary = summaries.of_word(as_path(word)) + return {'ret': summary.ret, 'hloop': summary.hloop, + 'lloop': summary.lloop, 'loop': summary.loop, + 'oneloop': summary.oneloop} + + +def random_system(rng): + states = [str(index) for index in range(rng.randint(1, 3))] + return Level2CPS([(rng.choice(states), + rng.choice([None] + list(SYMBOLS) + ['⊥']), + f'g{index}', rng.choice(states), + rng.choice(OPERATIONS)) + for index in range(rng.randint(1, 6))], + symbols=SYMBOLS) + + +class TestSummaries: + """The computed summaries against the searched ones. + + The oracle under-approximates — its search is bounded, and it will not + chase the side condition on 1-loops — so what it finds must always appear + in the computed summary. On the small systems here the two agree exactly. + """ + + CASES = { + 'a single pop': [('p', None, 'o', 'q', 'pop 2')], + 'cloning only': [('p', None, 'c', 'p', 'clone')], + 'push then collapse': [('p', None, 'a', 'r', 'push a 2'), + ('r', 'a', 'c', 'q', 'collapse')], + 'clone then two pops': [('p', None, 'c', 'r', 'clone'), + ('r', None, 'o', 's', 'pop 2'), + ('s', None, 'o', 'q', 'pop 2')], + 'drop and rewrite': [('p', 'a', 'o', 'r', 'pop 1'), + ('r', None, 'w', 'q', 'push a 1')], + 'clone and pop back': [('p', None, 'c', 'r', 'clone'), + ('r', None, 'o', 'q', 'pop 2')], + } + + @pytest.mark.parametrize("case", sorted(CASES)) + def test_against_the_oracle(self, case): + system = Level2CPS(self.CASES[case], symbols=SYMBOLS) + summaries = Summaries(system, depth=3, limit=4) + for word in SAMPLE_WORDS: + assert computed(summaries, word) == oracle(system, word), word + + def test_against_the_oracle_on_random_systems(self): + rng = random.Random(2026) + for _ in range(25): + system = random_system(rng) + summaries = Summaries(system, depth=3, limit=4) + for word in SAMPLE_WORDS: + found, said = oracle(system, word), computed(summaries, word) + for name, pairs in found.items(): + assert pairs <= said[name], (name, word, system.rules) + + def test_raising_the_bound_only_finds_more(self): + """The fixpoint is taken over words up to a length, so a longer bound + can only turn up runs the shorter one had no room to write down.""" + rng = random.Random(7) + for _ in range(10): + system = random_system(rng) + shallow = Summaries(system, depth=2, limit=2) + deeper = Summaries(system, depth=4, limit=4) + for word in SAMPLE_WORDS[:3]: + for name, pairs in computed(shallow, word).items(): + assert pairs <= computed(deeper, word)[name], name + + def test_a_word_beyond_the_bound_is_refused(self): + system = Level2CPS(self.CASES['cloning only'], symbols=SYMBOLS) + summaries = Summaries(system, depth=2, limit=2) + with pytest.raises(ValueError, match="exceeds"): + summaries.of_word(as_path( + (Letter('⊥'), Letter('a'), Letter('a'), Letter('a')))) + + def test_the_summary_of_the_paper_s_example(self): + """Hague et al.'s system: from state 1 a cloned word can be popped + back, and the pushed letter's link is what lets state 2 collapse all + the way out.""" + system = Level2CPS([('0', None, 'Cl', '1', 'clone'), + ('1', None, 'A', '0', 'push a 2'), + ('1', None, 'B', '2', 'push a 2'), + ('2', 'a', 'P', '2', 'pop 1'), + ('2', 'a', 'Co', '0', 'collapse')]) + summaries = Summaries(system, depth=3, limit=4) + for word in SAMPLE_WORDS[:3]: + assert computed(summaries, word) == oracle(system, word), word + + def test_summaries_have_a_readable_repr(self): + system = Level2CPS(self.CASES['a single pop'], symbols=SYMBOLS) + assert 'Summaries' in repr(Summaries(system, depth=2, limit=2)) From ee9e78d68570e571076c52c7051b46b18dba7143 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Thu, 13 Aug 2026 13:27:49 +0200 Subject: [PATCH 56/76] restrict_alphabet, for reading a tree automaton over fewer letters The reachability construction annotates a configuration tree with the summary of the word read from the root down to each node, which is how a bottom-up automaton consults a top-down computation: the check is local, and the annotation is quantified away afterwards. But the annotation needs letters of its own, and once it is gone the automaton is left reading a wider alphabet than the structure it has to be installed in. This narrows it again. Every letter kept must be one the automaton already reads, and what it did on the letters being dropped goes away with them -- one memoized pass over the diagrams, narrowing the digit blocks rather than rebuilding any transition table. The string engine's `recode` covers the opposite direction, widening, where a fresh dead state has to absorb the new letters; here nothing is new, so nothing has to absorb. Co-Authored-By: Claude Opus 5 --- autstr/utils/tree_automata_tools.py | 39 ++++++++++++++++++++++++++++- tests/test_tree_tools.py | 34 ++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/autstr/utils/tree_automata_tools.py b/autstr/utils/tree_automata_tools.py index c4f8526..21b7523 100644 --- a/autstr/utils/tree_automata_tools.py +++ b/autstr/utils/tree_automata_tools.py @@ -48,7 +48,7 @@ import numpy as np -from autstr.mtbdd import NONE, ComputedTable, bits_of, var_tables +from autstr.mtbdd import NONE, ComputedTable, bits_of, num_bits, var_tables from autstr.sparse_tree_automata import SparseTreeAutomaton, Tree from autstr.utils.misc import encode_symbol @@ -699,6 +699,43 @@ def iterate_trees(sta: SparseTreeAutomaton, max_entries: int = 10 ** 7): yield from accepted +def restrict_alphabet(sta: SparseTreeAutomaton, base_alphabet + ) -> SparseTreeAutomaton: + """The same automaton, read over a smaller alphabet. + + Every letter of `base_alphabet` must be one the automaton already has, and + the automaton keeps its behaviour on exactly those; what it did on the + letters being dropped simply goes away. That is what a construction wants + once a scaffolding letter has served its purpose — an annotation the + automaton was built to read and a projection has since quantified away + leaves the alphabet wider than the structure it belongs to. + + The rewrite is one memoized pass over the diagrams: dropping letters + narrows the digit blocks rather than rebuilding any transition table. + """ + old_letters = sorted(sta.base_alphabet_frozen) + new_letters = sorted(frozenset(base_alphabet)) + index = {letter: position for position, letter in enumerate(old_letters)} + missing = [letter for letter in new_letters if letter not in index] + if missing: + raise ValueError( + f"the automaton has no letter {missing[0]!r}; an alphabet can only " + f"be restricted to letters it already reads") + + source = [index[letter] for letter in new_letters] + new_m = len(new_letters) + new_bits = num_bits(new_m) + store = sta.store + nodes = [store.map_letters(int(node), sta.symbol_arity, sta.m, sta.bits, + new_m, new_bits, source, sta.default_state) + for node in sta.pair_nodes.tolist()] + return SparseTreeAutomaton( + sta.num_states, sta.default_state, + is_accepting=sta.is_accepting, symbol_arity=sta.symbol_arity, + base_alphabet=set(new_letters), + pair_keys=sta.pair_keys, pair_nodes=np.array(nodes, dtype=np.int64)) + + def partial_tree_automaton(base_alphabet, symbol_arity: int, transitions: Dict[tuple, str], final: Set[str]) -> SparseTreeAutomaton: diff --git a/tests/test_tree_tools.py b/tests/test_tree_tools.py index c6cbe26..2f3b16f 100644 --- a/tests/test_tree_tools.py +++ b/tests/test_tree_tools.py @@ -10,7 +10,7 @@ from autstr.utils.misc import encode_symbol from autstr.utils.tree_automata_tools import ( attach_padding, domain_within, equivalent, expand, fold_tapes, minimize, - partial_tree_automaton, project, tree_order, + partial_tree_automaton, project, restrict_alphabet, tree_order, ) from test_tree_automata import RefDTA, random_sta, random_tree @@ -612,3 +612,35 @@ def test_depth_zero_is_containment(self): def test_a_negative_depth_is_refused(self): with pytest.raises(ValueError, match="depth"): domain_within(self.ALPHABET, '*', -1) + + +class TestRestrictAlphabet: + """Reading an automaton over fewer letters keeps what it does on the ones + that remain — which is what a construction needs once an annotation it was + built to read has been quantified away.""" + + WIDE = {'*', 'a', 'b', 'c'} + NARROW = {'*', 'a', 'b'} + + def automaton(self, rng, alphabet): + names = ['s0', 's1', 's2'] + table = {(rng.choice([None] + names), rng.choice([None] + names), + (rng.choice(sorted(alphabet)),)): rng.choice(names) + for _ in range(rng.randint(3, 14))} + return partial_tree_automaton(alphabet, 1, table, + set(rng.sample(names, 2))) + + def test_the_language_over_the_kept_letters_is_unchanged(self): + rng = random.Random(3) + sample = [random_labelled_tree(rng, ['a', 'b']) for _ in range(40)] + for _ in range(20): + wide = self.automaton(rng, self.WIDE) + narrow = restrict_alphabet(wide, self.NARROW) + assert narrow.base_alphabet_frozen == frozenset(self.NARROW) + for tree in sample: + assert narrow.accepts(tree) == wide.accepts(tree), tree + + def test_widening_is_refused(self): + rng = random.Random(4) + with pytest.raises(ValueError, match="no letter"): + restrict_alphabet(self.automaton(rng, self.NARROW), self.WIDE) From 039b0fba165133a87cad6f11d27a2bcd6795f376 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Thu, 13 Aug 2026 13:38:00 +0200 Subject: [PATCH 57/76] The summary annotation, on a tape of its own Kartzow's automata ask, at a node of an encoding tree, which returns and loops the stack that node stands for has -- and that stack's topmost word is the word read from the root DOWN to the node. A bottom-up automaton cannot read downwards, so the answer travels on a second tape, where the check is local: a node's annotation is its parent's extended by the node's own letter, and a separator carries its parent's along unchanged. A node's state is what its parent has to know about it, which is the annotation and the label, since the parent is where the two can be compared. Merging words into automaton states needed care. Two words with the same summary must have the same successors -- that is what makes the summaries the states of an automaton -- but the fixpoint is taken over words up to a bound, and a word at the edge has its own extensions pinned empty, so what it says about a letter is short of the truth. Comparing a word at the edge with an interior word then looks like an inconsistency in the theory when it is only an artifact of the bound. The criterion that works is not depth but growth: a summary can only grow as the bound rises, so one that agreed across two consecutive bounds has stopped growing and is the truth. Only those words are merged, and the merge is then checked against the fixpoint it came from -- walking the merged automaton must reproduce the summary of every stable word. Co-Authored-By: Claude Opus 5 --- autstr/collapsible_reach.py | 155 +++++++++++++++++++++++++++++++- tests/test_collapsible_reach.py | 87 +++++++++++++++++- 2 files changed, 239 insertions(+), 3 deletions(-) diff --git a/autstr/collapsible_reach.py b/autstr/collapsible_reach.py index f4bb8d8..28905e8 100644 --- a/autstr/collapsible_reach.py +++ b/autstr/collapsible_reach.py @@ -54,7 +54,9 @@ from dataclasses import dataclass from typing import Dict, FrozenSet, Optional, Tuple -from autstr.collapsible import Level2CPS +from autstr.collapsible import SEP, Level2CPS +from autstr.sparse_tree_automata import SparseTreeAutomaton, Tree +from autstr.utils.tree_automata_tools import partial_tree_automaton #: a relation on control states Relation = FrozenSet[Tuple[str, str]] @@ -159,9 +161,14 @@ def __init__(self, system: Level2CPS, depth: int = 3, self.values: Dict[tuple, Summary] = {} self.depth = depth previous_bound = 0 + #: the words whose summary stopped growing when the bound last rose — + #: a value can only grow, so agreeing twice means it is the truth + self.stable: set = set() for bound in range(depth, limit + 1): previous, self.values = self.values, self._fixpoint(bound) self.depth = bound + self.stable = {path for path in previous + if previous[path] == self.values[path]} # the longest words of a round are the ones whose own extensions # were pinned empty, so they always improve when the bound rises; # convergence is about the words below that edge @@ -231,6 +238,53 @@ def _fixpoint(self, bound: int) -> Dict[tuple, Summary]: return values values = grown + def transitions(self) -> Tuple[Summary, Dict[Tuple[Summary, Letter], + Summary]]: + """The summaries as an automaton: the summary of the bottom letter, + and a table saying how one letter extends a summary. + + This is the merge Kartzow's Prop. 4.20 licenses — words with the same + summary behave alike, so they are one state. Words at the edge of the + fixpoint are left out of the merge: their own extensions were pinned + empty, so what they say about a letter is not yet the truth. + """ + if not self.converged: + raise ValueError( + f"the summaries were still growing at {self.depth} letters, so " + f"words that look alike need not behave alike yet; raise the " + f"limit") + # a word near the edge has its own extensions pinned empty, so what + # it says about a letter is short of the truth; only the words whose + # summary has stopped growing may speak + table: Dict[Tuple[Summary, Letter], Summary] = {} + for path in sorted(self.stable, key=len): + summary = self.values[path] + for letter in self.letters: + longer = self.values.get(path + (letter,)) + if longer is None or path + (letter,) not in self.stable: + continue + seen = table.setdefault((summary, letter), longer) + if seen != longer: + raise ValueError( + f"two words with the same summary disagree about the " + f"letter {letter!r}, so the summaries do not determine " + f"the automaton; raise the limit") + start = self.values[(self.bottom,)] + + # the merge has to reproduce what the fixpoint computed + for path in self.stable: + summary = self.values[path] + walked = start + for letter in path[1:]: + walked = table.get((walked, letter)) + if walked is None: + break + if walked is not None and walked != summary: + raise ValueError( + f"the merged automaton disagrees with the summary of " + f"{path}; raise the limit") + return start, table + def of_word(self, word) -> Summary: """The summary of a word, given as its letters from the bottom up.""" path = tuple((symbol, level) for symbol, level in word) @@ -314,3 +368,102 @@ def __repr__(self): return (f"") + + +# ---------------------------------------------------------------------- +# reading a word down the tree +# ---------------------------------------------------------------------- + +class Annotation: + """The summary of the word from the root of an encoding tree to each node, + written on a tape of its own. + + Kartzow's automata ask, at a node `d`, which returns and loops the stack + that node stands for has — and that stack's topmost word is the word read + from the root *down* to `d`. A bottom-up automaton cannot read downwards, + so the answer is carried on a second tape, where the check becomes local: a + node's annotation is its parent's extended by the node's own letter, and a + separator carries its parent's along unchanged. + + The tape is scaffolding — the construction pushes it through a projection + and then drops it, which is what + `autstr.utils.tree_automata_tools.restrict_alphabet` is for. + + :param encoding: the tree alphabet of the system, from `autstr.collapsible`. + :param summaries: the summaries of that system's words. + """ + + #: the annotation of the root, where no letter has been read yet + START = '~start' + + def __init__(self, encoding, summaries: Summaries) -> None: + self.encoding = encoding + self.summaries = summaries + first, table = summaries.transitions() + self.names = {} + for summary in [first] + [value for _, value in sorted( + table.items(), key=lambda item: repr(item[0]))]: + self.names.setdefault(summary, f'~{len(self.names)}') + #: annotation letter -> letter -> annotation letter + self.table = {(self.names[summary], letter): self.names[value] + for (summary, letter), value in table.items()} + self.first = self.names[first] + self.letters = [self.START] + sorted(self.names.values()) + self.alphabet = set(encoding.alphabet) | set(self.letters) + + def extend(self, annotation: str, label: str) -> Optional[str]: + """The annotation a node carries, given its parent's and its own + label — or None where no encoding tree has that shape.""" + if label == SEP: + return annotation # a separator reads no letter + if label not in self.encoding.letters: + return None # a state labels only the root + symbol, _, level = label.rpartition(':') + if annotation == self.START: + return self.first if label == self.encoding.bottom_label else None + return self.table.get((annotation, (symbol, int(level)))) + + def of_tree(self, tree, annotation: Optional[str] = None) -> Tree: + """The annotation of a configuration tree, as a tree of its own — the + oracle the automaton is checked against.""" + annotation = self.START if annotation is None else annotation + here = annotation if tree.label in self.encoding.states \ + else self.extend(annotation, tree.label) + if here is None: + raise ValueError(f"{tree.label!r} cannot follow {annotation!r}") + return Tree(here, + self.of_tree(tree.left, here) if tree.left else None, + self.of_tree(tree.right, here) if tree.right else None) + + def automaton(self) -> SparseTreeAutomaton: + """Two tapes: a configuration tree, and its annotation. + + A node's state is what a parent has to know about it — the annotation + it carries and the label it carries — since the parent is where the + two can be compared. + """ + table = {} + pairs = [(label, annotation) for label in self.encoding.nodes + for annotation in self.names.values()] + options = [None] + [f'{annotation}|{label}' + for label, annotation in pairs] + + def fits(parent: str, child) -> bool: + """Whether a child may hang below a node annotated `parent`.""" + if child is None: + return True + annotation, _, label = child.partition('|') + return self.extend(parent, label) == annotation + + for label, annotation in pairs: + for left in options: + for right in options: + if fits(annotation, left) and fits(annotation, right): + table[(left, right, (label, annotation))] = \ + f'{annotation}|{label}' + # the root carries the state and reads no letter of its own + for state in self.encoding.states: + for left in options: + if fits(self.START, left): + table[(left, None, (state, self.START))] = 'accept' + return partial_tree_automaton(self.alphabet, 2, table, {'accept'}) diff --git a/tests/test_collapsible_reach.py b/tests/test_collapsible_reach.py index a64aad0..18a9845 100644 --- a/tests/test_collapsible_reach.py +++ b/tests/test_collapsible_reach.py @@ -17,8 +17,12 @@ import pytest -from autstr.collapsible import Configuration, Letter, Level2CPS, Stack -from autstr.collapsible_reach import Summaries +from autstr.collapsible import ( + Configuration, Letter, Level2CPS, PAD, Stack, _Encoding, + encode_configuration, +) +from autstr.collapsible_reach import Annotation, Summaries +from autstr.sparse_tree_automata import Tree, convolve_trees def zeroed(word): @@ -364,3 +368,82 @@ def test_the_summary_of_the_paper_s_example(self): def test_summaries_have_a_readable_repr(self): system = Level2CPS(self.CASES['a single pop'], symbols=SYMBOLS) assert 'Summaries' in repr(Summaries(system, depth=2, limit=2)) + + +class TestAnnotation: + """The summary annotation tape: a node carries the summary of the word + read from the root down to it, and the automaton checks that locally.""" + + @pytest.fixture(scope="class") + def setup(self): + system = Level2CPS([('0', None, 'Cl', '1', 'clone'), + ('1', None, 'A', '0', 'push a 2'), + ('1', None, 'B', '2', 'push a 1'), + ('2', 'a', 'P', '2', 'pop 1'), + ('2', 'a', 'Co', '0', 'collapse')]) + encoding = _Encoding(system.states, system.symbols, system.bottom) + annotation = Annotation(encoding, Summaries(system, depth=4, limit=6)) + return system, annotation, annotation.automaton() + + def convolution(self, annotation, tree, marks): + return convolve_trees([tree, marks], frozenset(annotation.alphabet), + PAD) + + def test_the_right_annotation_is_accepted(self, setup): + system, annotation, automaton = setup + for configuration in system.reachable(bound=6): + tree = encode_configuration(configuration) + marks = annotation.of_tree(tree) + assert automaton.accepts( + self.convolution(annotation, tree, marks)), configuration + + def test_the_annotation_follows_the_word_down_the_tree(self, setup): + _, annotation, _ = setup + # the root reads no letter; below it the annotations follow the + # summaries of ⊥, ⊥a, ... and a separator repeats its parent's + tree = encode_configuration(Configuration( + '0', Stack(((Letter('⊥'), Letter('a', 2, 0)), + (Letter('⊥'),))))) + marks = annotation.of_tree(tree) + assert marks.label == Annotation.START + assert marks.left.label == annotation.first + assert marks.left.right.label == marks.left.label # a separator + + def test_a_wrong_annotation_is_rejected(self, setup): + system, annotation, automaton = setup + others = [letter for letter in annotation.letters + if letter != Annotation.START] + for configuration in system.reachable(bound=5): + tree = encode_configuration(configuration) + marks = annotation.of_tree(tree) + if marks.left is None: + continue + for wrong in others: + if wrong == marks.left.label: + continue + spoiled = Tree(marks.label, + Tree(wrong, marks.left.left, marks.left.right), + marks.right) + assert not automaton.accepts( + self.convolution(annotation, tree, spoiled)) + + def test_an_annotation_of_the_wrong_shape_is_rejected(self, setup): + system, annotation, automaton = setup + configuration = system.reachable(bound=4)[-1] + tree = encode_configuration(configuration) + marks = annotation.of_tree(tree) + # one node short, and one node too many + assert not automaton.accepts( + self.convolution(annotation, tree, Tree(marks.label))) + assert not automaton.accepts(self.convolution( + annotation, tree, + Tree(marks.label, marks.left, Tree(annotation.first)))) + + def test_summaries_merge_into_an_automaton(self): + """Words that behave alike are one state — and while the fixpoint is + still moving, the merge is refused rather than guessed at.""" + system = Level2CPS([('0', None, 'c', '0', 'clone'), + ('0', None, 'p', '0', 'push a 2')]) + first, table = Summaries(system, depth=4, limit=6).transitions() + assert first.symbol == '⊥' + assert all(isinstance(key[1], tuple) for key in table) From 0472bc9f28c34bdc91dd5e8302a5b0206638acaf Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Thu, 13 Aug 2026 14:08:56 +0200 Subject: [PATCH 58/76] B: the topmost word loses letters The first of the four relations reachability decomposes into. Kartzow's characterisation makes the run a walk up the tree's last path -- a high loop, then one pop or level 1 collapse, repeated -- so every check is local if the guess at a letter carries the states the run is in BEFORE and AFTER dropping it. The state after one drop is the state before the next, which is what the parent compares; the high loop comes from the summary the annotation names, so a node needs nothing but its own four tapes. The shape had to be measured rather than guessed. Encoding pop_1^k(s) is encoding s with a tail of the last path deleted, and -- this is the part I got wrong twice -- with a separator added exactly when one is deleted, never otherwise and never more than one. Letting the separator appear freely made a CLONE look like a chain of pops, since cloning also just hangs a separator at the end; requiring it to pair with a deleted one is what tells the two apart. The last word's divergence from the word below it moves up, and that is the whole content of the added separator. The two scaffolding tapes come off afterwards: the annotation is pinned to the real one by intersecting with its own automaton, both are projected away, and the alphabet is narrowed back to the one configurations are written in. Checked against a brute-force search over arbitrary configurations -- not just reachable ones, since the relation is about all of them -- on 15 systems, 12 of them random, some 11000 pairs, no disagreements. Co-Authored-By: Claude Opus 5 --- autstr/collapsible_reach.py | 203 +++++++++++++++++++++++++++++++- tests/test_collapsible_reach.py | 127 +++++++++++++++++++- 2 files changed, 324 insertions(+), 6 deletions(-) diff --git a/autstr/collapsible_reach.py b/autstr/collapsible_reach.py index 28905e8..d5f9003 100644 --- a/autstr/collapsible_reach.py +++ b/autstr/collapsible_reach.py @@ -54,7 +54,7 @@ from dataclasses import dataclass from typing import Dict, FrozenSet, Optional, Tuple -from autstr.collapsible import SEP, Level2CPS +from autstr.collapsible import PAD, SEP, Level2CPS from autstr.sparse_tree_automata import SparseTreeAutomaton, Tree from autstr.utils.tree_automata_tools import partial_tree_automaton @@ -435,9 +435,13 @@ def of_tree(self, tree, annotation: Optional[str] = None) -> Tree: self.of_tree(tree.left, here) if tree.left else None, self.of_tree(tree.right, here) if tree.right else None) - def automaton(self) -> SparseTreeAutomaton: + def automaton(self, alphabet=None) -> SparseTreeAutomaton: """Two tapes: a configuration tree, and its annotation. + :param alphabet: the alphabet to build over, when a construction has + letters of its own beside these — an automaton can only be + combined with others that read the same one. + A node's state is what a parent has to know about it — the annotation it carries and the label it carries — since the parent is where the two can be compared. @@ -466,4 +470,197 @@ def fits(parent: str, child) -> bool: for left in options: if fits(self.START, left): table[(left, None, (state, self.START))] = 'accept' - return partial_tree_automaton(self.alphabet, 2, table, {'accept'}) + return partial_tree_automaton(alphabet or self.alphabet, 2, + table, {'accept'}) + + +# ---------------------------------------------------------------------- +# the relations Reach decomposes into +# ---------------------------------------------------------------------- + +class Relations: + """The relations whose composition is reachability. + + Kartzow's Remark 4.4 splits every run into four stretches: `A` drops whole + words, `B` drops letters from the topmost word, `C` pushes letters back on, + and `D` grows the stack again. All four are reflexive, so reachability is + their composition and needs no automaton of its own — + + Reach(x,y) ≡ ∃d ∃e ∃f. A(x,d) ∧ B(d,e) ∧ C(e,f) ∧ D(f,y) + + which is a formula the engine evaluates once the four are installed. + + Each is checked on four tapes — the two configurations, the summary + annotation of the first, and a guessed control state per node — and the + last two are projected away afterwards. + + :param system: the collapsible pushdown system. + :param summaries: its summaries; computed if not given. + """ + + #: a node no run visits, and so carries no guessed states + NONE = '@-' + + def __init__(self, system: Level2CPS, summaries: Optional[Summaries] = None + ) -> None: + from autstr.collapsible import _Encoding + self.system = system + self.encoding = _Encoding(system.states, system.symbols, system.bottom) + self.summaries = summaries or Summaries(system) + self.annotation = Annotation(self.encoding, self.summaries) + # a letter the run drops carries the states it is in before and after + # dropping it, which is what makes every check local + self.guesses = [self.NONE] + [ + f'@{before},{after}' for before in system.states + for after in system.states] + self.alphabet = set(self.annotation.alphabet) | set(self.guesses) + + def summary_of(self, annotation: str) -> Optional[Summary]: + """The summary an annotation letter stands for.""" + for summary, name in self.annotation.names.items(): + if name == annotation: + return summary + return None + + def drop_moves(self, symbol: str, level: int) -> Relation: + """The transitions that take the topmost letter off: a pop of level 1, + and a collapse when the link is of level 1.""" + moves = self.summaries.moves(symbol, 'pop1') + if level == 1: + moves |= self.summaries.moves(symbol, 'collapse') + return moves + + def guessed(self, guess: str) -> Optional[Tuple[str, str]]: + """The pair of states a guess letter carries, or None.""" + if guess == self.NONE: + return None + before, _, after = guess[1:].partition(',') + return before, after + + def dropping(self, annotation: str, label: str, guess: str) -> bool: + """Whether a letter may be dropped as the guess says: a high loop of + the word ending at it, then one transition that takes it off. + + The word is the one read from the root down to this node, which is + what the annotation names — so the check needs nothing but this node's + own four tapes. + """ + pair = self.guessed(guess) + summary = self.summary_of(annotation) + if pair is None or summary is None or label not in self.encoding.letters: + return False + symbol, _, level = label.rpartition(':') + return pair in compose(summary.hloop, + self.drop_moves(symbol, int(level))) + + def b(self) -> SparseTreeAutomaton: + """``B``: the topmost word loses letters, and nothing goes below what + is left. + + The two trees agree except along a tail of the first one's last path, + which is deleted; the second may gain a single separator where its own + last word now ends. Climbing that tail is the run: at each letter a + high loop and one drop, the state after one drop being the state before + the next. + """ + table = {} + annotations = list(self.annotation.names.values()) + marks = [(label, annotation) + for label in self.encoding.nodes for annotation in annotations] + pairs = [(before, after) for before in self.system.states + for after in self.system.states] + + # the two trees agree here and no run step happens + for label, annotation in marks: + for left in (None, 'same'): + for right in (None, 'same'): + table[(left, right, (label, label, annotation, self.NONE))] \ + = 'same' + # the separator the second tree gains, where its last word now ends. + # One is added exactly when one is deleted -- the last word\'s + # divergence from the word below it moves up -- and never more. + table[(None, None, (PAD, SEP, PAD, self.NONE))] = 'added' + + # below the deepest letter dropped, the old last separator goes + for annotation in annotations: + table[(None, None, (SEP, PAD, annotation, self.NONE))] = 'cut' + + # a letter the run drops: the deepest starts the chain, each one above + # continues it, and a deleted separator sets the chain\'s mark + for label, annotation in marks: + for guess in self.guesses: + if not self.dropping(annotation, label, guess): + continue + first, last = self.guessed(guess) + symbols = (label, PAD, annotation, guess) + table[(None, None, symbols)] = f'chain {first} {last}' + table[('cut', None, symbols)] = f'cutchain {first} {last}' + for start_state in self.system.states: + for kind in ('chain', 'cutchain'): + table[(f'{kind} {start_state} {first}', None, + symbols)] = f'{kind} {start_state} {last}' + if label != SEP: + continue + for first, last in pairs: + # a separator on the way marks the chain; a second one would + # mean two words ended there, which no chain of pops does + symbols = (SEP, PAD, annotation, self.NONE) + table[(f'chain {first} {last}', None, symbols)] = \ + f'cutchain {first} {last}' + table[(None, f'chain {first} {last}', symbols)] = \ + f'cutchain {first} {last}' + + # the chain stops at a node both trees keep. From there up nothing may + # follow it in either tree: coming from a right child anything already + # read may sit to the left, but coming from a left child there must be + # no right child at all. + for label, annotation in marks: + plain = (label, label, annotation, self.NONE) + for first, last in pairs: + for chain, kind in ((f'chain {first} {last}', 'done'), + (f'cutchain {first} {last}', 'cutdone')): + for below in (chain, f'{kind} {first} {last}'): + table[(below, None, plain)] = f'{kind} {first} {last}' + table[('same', below, plain)] = f'{kind} {first} {last}' + table[(None, below, plain)] = f'{kind} {first} {last}' + # the deleted separator is made good by the added one + for below in (f'cutchain {first} {last}', + f'cutdone {first} {last}'): + table[(below, 'added', plain)] = f'grown {first} {last}' + grown = f'grown {first} {last}' + table[(grown, None, plain)] = grown + table[('same', grown, plain)] = grown + table[(None, grown, plain)] = grown + + # the root: the states the run began and ended in are the two + # configurations\' own, and a run of no steps leaves the tree alone + for source in self.system.states: + for target in self.system.states: + labels = (f'<{source}>', f'<{target}>', Annotation.START, + self.NONE) + for kind in ('done', 'grown'): + table[(f'{kind} {source} {target}', None, labels)] = 'accept' + if source == target: + table[('same', None, labels)] = 'accept' + return partial_tree_automaton(self.alphabet, 4, table, {'accept'}) + + def without_scaffolding(self, checker: SparseTreeAutomaton + ) -> SparseTreeAutomaton: + """A four-tape checker as a relation on two configurations. + + The annotation is required to be the real one — that is what the + annotation automaton says — and then both it and the guess are + quantified away, leaving the alphabet to be narrowed back to the one + the configurations are written in. + """ + from autstr.utils.tree_automata_tools import ( + attach_padding, expand, minimize, project, restrict_alphabet, + ) + annotated = minimize(expand(minimize(attach_padding( + self.annotation.automaton(self.alphabet), PAD)), 4, [0, 2])) + result = minimize(minimize( + attach_padding(checker, PAD)).intersection(annotated)) + for tape in (3, 2): + result = minimize(attach_padding( + project(result, tape, PAD), PAD)) + return restrict_alphabet(result, self.encoding.alphabet) diff --git a/tests/test_collapsible_reach.py b/tests/test_collapsible_reach.py index 18a9845..eec4a0f 100644 --- a/tests/test_collapsible_reach.py +++ b/tests/test_collapsible_reach.py @@ -18,10 +18,10 @@ import pytest from autstr.collapsible import ( - Configuration, Letter, Level2CPS, PAD, Stack, _Encoding, - encode_configuration, + Configuration, Letter, Level2CPS, Operation, PAD, Stack, _Encoding, + encode_configuration, initial_stack, ) -from autstr.collapsible_reach import Annotation, Summaries +from autstr.collapsible_reach import Annotation, Relations, Summaries from autstr.sparse_tree_automata import Tree, convolve_trees @@ -447,3 +447,124 @@ def test_summaries_merge_into_an_automaton(self): first, table = Summaries(system, depth=4, limit=6).transitions() assert first.symbol == '⊥' assert all(isinstance(key[1], tuple) for key in table) + + +# ---------------------------------------------------------------------- +# the relations reachability decomposes into +# ---------------------------------------------------------------------- + +def substacks(stack): + """Every substack: pop the words, then the letters.""" + out, current = [], stack + while current is not None: + inner = current + while inner is not None: + out.append(inner) + inner = inner.pop1() + current = current.pop2() + return out + + +def drops_to(source, target): + """Is the target the source with letters taken off its topmost word?""" + current = source + while current is not None: + if current == target: + return True + current = current.pop1() + return False + + +def b_oracle(system, source, target, bound=3000): + """``B``: a run to a shorter topmost word that never dips below it.""" + if not drops_to(source.stack, target.stack): + return False + if source == target: + return True # the run of no steps + forbidden = set(substacks(target.stack)) + seen, frontier, steps = {source}, deque([source]), 0 + while frontier and steps < bound: + configuration = frontier.popleft() + steps += 1 + for _, successor in system.step(configuration): + if successor == target: + return True + if successor in seen or successor.stack in forbidden: + continue + if successor.stack.width > 4 or \ + max(map(len, successor.stack.words)) > 6: + continue + seen.add(successor) + frontier.append(successor) + return False + + +def walk_stacks(seed, count, width=3, height=4): + """Stacks from a random walk, kept small enough to search exhaustively.""" + rng, stack, out = random.Random(seed), initial_stack(), [] + operations = ([Operation('clone')] + + [Operation('push', symbol, level) + for symbol in SYMBOLS for level in (1, 2)] + + [Operation('pop', level=1), Operation('pop', level=2)]) + while len(out) < count: + stack = stack.apply(rng.choice(operations)) or initial_stack() + if stack.width <= width and max(map(len, stack.words)) <= height: + out.append(stack) + return out + + +class TestRelationB: + """``B`` — the topmost word loses letters, and the run never dips below + what is left. The two trees then differ along a tail of the first one's + last path, and the second may gain the one separator the first loses.""" + + CASES = { + 'pop and push back': [('0', None, 'p', '1', 'pop 1'), + ('1', None, 'u', '0', 'push a 1')], + 'a loop before each pop': [('0', None, 'c', '1', 'clone'), + ('1', None, 'o', '0', 'pop 2'), + ('0', 'a', 'p', '2', 'pop 1'), + ('2', None, 'u', '0', 'push b 1')], + } + + def relation(self, rules): + system = Level2CPS(rules, symbols=SYMBOLS) + relations = Relations(system) + return system, relations, relations.without_scaffolding(relations.b()) + + def holds(self, relations, relation, source, target): + return relation.accepts(convolve_trees( + [encode_configuration(source), encode_configuration(target)], + frozenset(relations.encoding.alphabet), PAD)) + + @pytest.mark.parametrize("case", sorted(CASES)) + def test_against_the_search(self, case): + system, relations, relation = self.relation(self.CASES[case]) + configurations = [Configuration(state, stack) + for stack in walk_stacks(7, 8) + for state in system.states] + for source in configurations: + for target in configurations: + assert self.holds(relations, relation, source, target) == \ + b_oracle(system, source, target), (source, target) + + def test_it_is_reflexive(self): + """Every configuration reaches itself by the run of no steps, so the + two trees are simply equal.""" + system, relations, relation = self.relation( + self.CASES['pop and push back']) + for stack in walk_stacks(3, 6): + for state in system.states: + configuration = Configuration(state, stack) + assert self.holds(relations, relation, configuration, + configuration) + + def test_dropping_a_word_is_not_dropping_letters(self): + """A pair whose widths differ is refused whatever the runs do: B moves + within the topmost word.""" + system, relations, relation = self.relation( + self.CASES['pop and push back']) + tall = Stack(((Letter('⊥'), Letter('a')), (Letter('⊥'), Letter('a')))) + assert not self.holds(relations, relation, + Configuration('0', tall), + Configuration('0', Stack((tall.words[0],)))) From 88b9e5757e4e1d9d2cf2afba07dc806cee49d581 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Thu, 13 Aug 2026 14:20:49 +0200 Subject: [PATCH 59/76] C: the topmost word gains letters B read backwards, and the same tree shape with the two configurations exchanged. What is not symmetric is the guard. A pop is guarded by the letter it takes off, so the node that loses it can check the move alone; a push is guarded by the letter already on top, which is the one ABOVE the letter written. So the chain carries each node's label up to its parent, and the push is checked there. That is still not enough, because the letter may go below a separator -- the last word of a stack whose last two words agree hangs under one -- and then the guarding symbol is not the parent's label either, but the nearest letter above it. The annotation already knows: a summary carries the topmost symbol of the word it names, so the guard comes from the annotation rather than from any label. The high loop belongs to the node naming its word, the push to the node above. Both directions of failure showed up on the way and each pointed at one of these: taking the pushed letter as its own guard accepted runs that do not exist, and reading the guard off the label rejected runs that do. Checked against the search on the same 15 systems as B, some 22000 pairs in all, no disagreements. Co-Authored-By: Claude Opus 5 --- autstr/collapsible_reach.py | 145 +++++++++++++++++++++++++++++++- tests/test_collapsible_reach.py | 88 +++++++++++++++++++ 2 files changed, 230 insertions(+), 3 deletions(-) diff --git a/autstr/collapsible_reach.py b/autstr/collapsible_reach.py index d5f9003..d9fbd74 100644 --- a/autstr/collapsible_reach.py +++ b/autstr/collapsible_reach.py @@ -644,23 +644,162 @@ def b(self) -> SparseTreeAutomaton: table[('same', None, labels)] = 'accept' return partial_tree_automaton(self.alphabet, 4, table, {'accept'}) - def without_scaffolding(self, checker: SparseTreeAutomaton - ) -> SparseTreeAutomaton: + def without_scaffolding(self, checker: SparseTreeAutomaton, + annotated: int = 0) -> SparseTreeAutomaton: """A four-tape checker as a relation on two configurations. The annotation is required to be the real one — that is what the annotation automaton says — and then both it and the guess are quantified away, leaving the alphabet to be narrowed back to the one the configurations are written in. + + :param annotated: which configuration the annotation belongs to. The + words a run passes through are those of the *longer* of the two, + which is the first tape where letters come off and the second + where they go on. """ from autstr.utils.tree_automata_tools import ( attach_padding, expand, minimize, project, restrict_alphabet, ) annotated = minimize(expand(minimize(attach_padding( - self.annotation.automaton(self.alphabet), PAD)), 4, [0, 2])) + self.annotation.automaton(self.alphabet), PAD)), 4, + [annotated, 2])) result = minimize(minimize( attach_padding(checker, PAD)).intersection(annotated)) for tape in (3, 2): result = minimize(attach_padding( project(result, tape, PAD), PAD)) return restrict_alphabet(result, self.encoding.alphabet) + + def looping(self, annotation: str, guess: str) -> bool: + """Whether the guess is a high loop of the word this node names.""" + pair = self.guessed(guess) + summary = self.summary_of(annotation) + return pair is not None and summary is not None and \ + pair in summary.hloop + + def push_moves(self, annotation: str, label: str) -> Relation: + """The transitions that write `label` on a stack whose topmost word is + the one `annotation` names. + + A pop is guarded by the letter it takes off, so a node can check it + alone; a push is guarded by the letter already on top, which is the one + *above* the letter written — and which is not this node's label when + the letter goes below a separator. The summary knows it either way: it + carries the topmost symbol of the word it names. + """ + summary = self.summary_of(annotation) + written, _, level = label.rpartition(':') + if summary is None or summary.symbol is None or not level.isdigit(): + return frozenset() + return self.summaries.moves(summary.symbol, + ('push', written, int(level))) + + def c(self) -> SparseTreeAutomaton: + """``C``: the topmost word gains letters, and the run never dips below + where it started. + + B read backwards, and the same tree shape with the two configurations + exchanged: the second one's last path is longer by a tail, and the + first carries the one separator the second loses. The run is a high + loop and a push at each letter gained — the loop belonging to the node + that names its word, the push to the node above, which is where the + letter it writes is still on top. + """ + table = {} + annotations = list(self.annotation.names.values()) + marks = [(label, annotation) + for label in self.encoding.nodes for annotation in annotations] + letters = [label for label in self.encoding.letters] + states = self.system.states + + def chain(kind, label, first, final): + return f'{kind}|{label}|{first}|{final}' + + for label, annotation in marks: + for left in (None, 'same'): + for right in (None, 'same'): + table[(left, right, (label, label, annotation, self.NONE))] \ + = 'same' + # the separator the first tree carries, where its last word ends, and + # the one the second loses + table[(None, None, (SEP, PAD, PAD, self.NONE))] = 'added' + for annotation in annotations: + table[(None, None, (PAD, SEP, annotation, self.NONE))] = 'cut' + + # a letter the run pushes. The node checks the high loop of its own + # word; the push that put the letter there is checked by its parent. + for label, annotation in marks: + if label not in letters: + continue + for guess in self.guesses: + if not self.looping(annotation, guess): + continue + first, last = self.guessed(guess) + symbols = (PAD, label, annotation, guess) + table[(None, None, symbols)] = chain('chain', label, first, last) + table[('cut', None, symbols)] = \ + chain('cutchain', label, first, last) + for below in letters: + moves = self.push_moves(annotation, below) + for entered, final in moves: + if entered != last: + continue + for kind in ('chain', 'cutchain'): + for deepest in states: + table[(chain(kind, below, final, deepest), None, + symbols)] = \ + chain(kind, label, first, deepest) + + # a separator the second tree loses, on the way down the tail + for annotation in annotations: + symbols = (PAD, SEP, annotation, self.NONE) + for label in letters: + for first in states: + for final in states: + table[(chain('chain', label, first, final), None, + symbols)] = chain('cutchain', label, first, final) + table[(None, chain('chain', label, first, final), + symbols)] = chain('cutchain', label, first, final) + + # where the tail begins: one more high loop, then the push that starts + # it -- both belonging to this node, which both trees keep + for label, annotation in marks: + summary = self.summary_of(annotation) + plain = (label, label, annotation, self.NONE) + for below in letters: + moves = self.push_moves(annotation, below) + for entered, final in moves: + for start in states: + if (start, entered) not in summary.hloop: + continue + for kind, done in (('chain', 'done'), + ('cutchain', 'cutdone')): + for deepest in states: + source = chain(kind, below, final, deepest) + target = f'{done} {start} {deepest}' + table[(source, None, plain)] = target + table[('same', source, plain)] = target + table[(None, source, plain)] = target + for first in states: + for final in states: + for kind in ('done', 'cutdone'): + below_state = f'{kind} {first} {final}' + table[(below_state, None, plain)] = below_state + table[('same', below_state, plain)] = below_state + table[(None, below_state, plain)] = below_state + grown = f'grown {first} {final}' + table[(f'cutdone {first} {final}', 'added', plain)] = grown + table[(grown, None, plain)] = grown + table[('same', grown, plain)] = grown + table[(None, grown, plain)] = grown + + for source in states: + for target in states: + labels = (f'<{source}>', f'<{target}>', Annotation.START, + self.NONE) + for kind in ('done', 'grown'): + table[(f'{kind} {source} {target}', None, labels)] = 'accept' + if source == target: + table[('same', None, labels)] = 'accept' + return partial_tree_automaton(self.alphabet, 4, table, {'accept'}) diff --git a/tests/test_collapsible_reach.py b/tests/test_collapsible_reach.py index eec4a0f..5892c5e 100644 --- a/tests/test_collapsible_reach.py +++ b/tests/test_collapsible_reach.py @@ -568,3 +568,91 @@ def test_dropping_a_word_is_not_dropping_letters(self): assert not self.holds(relations, relation, Configuration('0', tall), Configuration('0', Stack((tall.words[0],)))) + + +def c_oracle(system, source, target, bound=3000): + """``C``: a run to a longer topmost word that never dips below where it + started.""" + if not drops_to(target.stack, source.stack): + return False + if source == target: + return True + forbidden = {stack for stack in substacks(source.stack) + if stack != source.stack} + seen, frontier, steps = {source}, deque([source]), 0 + while frontier and steps < bound: + configuration = frontier.popleft() + steps += 1 + for _, successor in system.step(configuration): + if successor.stack in forbidden: + continue # never below where it started + if successor == target: + return True + if successor in seen: + continue + if successor.stack.width > 4 or \ + max(map(len, successor.stack.words)) > 6: + continue + seen.add(successor) + frontier.append(successor) + return False + + +class TestRelationC: + """``C`` — the topmost word gains letters, and the run never dips below + where it started. B read backwards, with one asymmetry that matters: a pop + is guarded by the letter it removes, but a push by the letter already on + top, which is the one *above* the letter written.""" + + CASES = { + 'push and pop back': [('0', None, 'u', '1', 'push a 1'), + ('1', None, 'p', '0', 'pop 1')], + 'a loop before each push': [('0', None, 'c', '1', 'clone'), + ('1', None, 'o', '0', 'pop 2'), + ('0', None, 'u', '2', 'push a 1'), + ('2', 'a', 'v', '0', 'push b 1')], + } + + def relation(self, rules): + system = Level2CPS(rules, symbols=SYMBOLS) + relations = Relations(system) + return system, relations, relations.without_scaffolding( + relations.c(), annotated=1) + + def holds(self, relations, relation, source, target): + return relation.accepts(convolve_trees( + [encode_configuration(source), encode_configuration(target)], + frozenset(relations.encoding.alphabet), PAD)) + + @pytest.mark.parametrize("case", sorted(CASES)) + def test_against_the_search(self, case): + system, relations, relation = self.relation(self.CASES[case]) + configurations = [Configuration(state, stack) + for stack in walk_stacks(7, 8) + for state in system.states] + for source in configurations: + for target in configurations: + assert self.holds(relations, relation, source, target) == \ + c_oracle(system, source, target), (source, target) + + def test_it_is_reflexive(self): + system, relations, relation = self.relation( + self.CASES['push and pop back']) + for stack in walk_stacks(3, 6): + for state in system.states: + configuration = Configuration(state, stack) + assert self.holds(relations, relation, configuration, + configuration) + + def test_a_letter_pushed_below_a_separator(self): + """The letter goes onto the last word, which the tree hangs under a + separator — so the symbol guarding the push is not the separator's but + the nearest letter above it, which the annotation knows.""" + system, relations, relation = self.relation( + self.CASES['push and pop back']) + word = (Letter('⊥'), Letter('a')) + twice = Stack((word, word)) + longer = Stack((word, word + (Letter('a'),))) + assert self.holds(relations, relation, + Configuration('0', twice), + Configuration('1', longer)) From 67684366ce354ad62bd84e2f4d3bd2d0f0da200d Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Thu, 13 Aug 2026 14:31:28 +0200 Subject: [PATCH 60/76] A, as far as returns take it The third relation, and the first that is not finished. Kartzow's decomposition allows a run of A three kinds of piece: a return, a 1-loop then a level 2 collapse, and a 1-loop then a pop that some later collapse closes off. This builds the returns. That turns out to be more than it sounds. A collapse that drops a SINGLE word is already inside a return -- the summaries count a pushed link collapsed after a 1-loop as one, which is the escape rule Phase A needed -- so what is missing is only the collapse that spans several words at once, which is what the other two pieces exist for. Measured: exact on systems that never collapse on a level 2 link, exact on one that collapses over a single word, and 38 pairs short on one that pushes a link, clones, and then collapses over all of it. Nothing is ever invented, in any of them, so the relation stays inside the truth and `collapses_on_links` says when it may fall short of it. The words dropped are the encoding's separators, and the run pops them from the last backwards -- so within a subtree the chain runs through the right child's separators, then the left child's, then the node itself, which is reverse traversal order. Co-Authored-By: Claude Opus 5 --- autstr/collapsible_reach.py | 84 +++++++++++++++++++++++++ tests/test_collapsible_reach.py | 108 ++++++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+) diff --git a/autstr/collapsible_reach.py b/autstr/collapsible_reach.py index d9fbd74..f0280a9 100644 --- a/autstr/collapsible_reach.py +++ b/autstr/collapsible_reach.py @@ -803,3 +803,87 @@ def chain(kind, label, first, final): if source == target: table[('same', None, labels)] = 'accept' return partial_tree_automaton(self.alphabet, 4, table, {'accept'}) + + def a_returns(self) -> SparseTreeAutomaton: + """``A`` as far as returns take it: the stack loses whole words, one + return each. + + Kartzow's Lemma 4.11 decomposes a run of ``A`` into pieces that are + returns (F1), or a 1-loop followed by a level 2 collapse (F2), or a + 1-loop followed by a pop that some later F2 closes off (F3). This + builds the F1 case. It is all of ``A`` for a system whose runs never + collapse on a level 2 link, since F2 and F3 both end in one; where they + can occur this under-approximates, and `collapses_on_links` says so. + + The words the stack drops are the separators of the encoding, and the + run pops them from the last backwards — so within a subtree the chain + runs through the right child's separators, then the left child's, then + the node itself, which is reverse traversal order. + """ + table = {} + annotations = list(self.annotation.names.values()) + marks = [(label, annotation) + for label in self.encoding.nodes for annotation in annotations] + states = self.system.states + + for label, annotation in marks: + for left in (None, 'same'): + for right in (None, 'same'): + table[(left, right, (label, label, annotation, self.NONE))] \ + = 'same' + + # inside a word the first tree drops, nothing is guessed + for label, annotation in marks: + for left in (None, 'gone'): + for right in (None, 'gone'): + table[(left, right, (label, PAD, annotation, self.NONE))] \ + = 'gone' + + # a separator the first tree drops: one return of the word it names + for annotation in annotations: + summary = self.summary_of(annotation) + for guess in self.guesses: + pair = self.guessed(guess) + if pair is None or summary is None or pair not in summary.ret: + continue + first, last = pair + symbols = (SEP, PAD, annotation, guess) + for left in (None, 'gone'): + table[(left, None, symbols)] = f'ret {first} {last}' + # the words to the right go first, this one after them + for entered in states: + table[(left, f'ret {entered} {first}', symbols)] = \ + f'ret {entered} {last}' + + # a node both trees keep, below which words were dropped + for label, annotation in marks: + plain = (label, label, annotation, self.NONE) + for first in states: + for last in states: + done = f'done {first} {last}' + table[(f'ret {first} {last}', None, plain)] = done + table[('same', f'ret {first} {last}', plain)] = done + table[(None, f'ret {first} {last}', plain)] = done + table[(done, None, plain)] = done + table[('same', done, plain)] = done + table[(None, done, plain)] = done + + for source in states: + for target in states: + labels = (f'<{source}>', f'<{target}>', Annotation.START, + self.NONE) + table[(f'done {source} {target}', None, labels)] = 'accept' + if source == target: + table[('same', None, labels)] = 'accept' + return partial_tree_automaton(self.alphabet, 4, table, {'accept'}) + + def collapses_on_links(self) -> bool: + """Whether the system can collapse on a level 2 link at all — if it + cannot, `a_returns` is the whole of ``A``.""" + pushes = {(rule.operation.symbol, rule.operation.level) + for rule in self.system.rules + if rule.operation.kind == 'push'} + if not any(level == 2 for _, level in pushes): + return False + return any(rule.operation.kind == 'collapse' + for rule in self.system.rules) diff --git a/tests/test_collapsible_reach.py b/tests/test_collapsible_reach.py index 5892c5e..76f9300 100644 --- a/tests/test_collapsible_reach.py +++ b/tests/test_collapsible_reach.py @@ -656,3 +656,111 @@ def test_a_letter_pushed_below_a_separator(self): assert self.holds(relations, relation, Configuration('0', twice), Configuration('1', longer)) + + +def pops_words_to(source, target): + """Is the target the source with whole words taken off?""" + current = source + while current is not None: + if current == target: + return True + current = current.pop2() + return False + + +def a_oracle(system, source, target, bound=3000): + """``A``: a run that drops whole words and never dips below the target.""" + if not pops_words_to(source.stack, target.stack): + return False + if source == target: + return True + forbidden = set(substacks(target.stack)) + seen, frontier, steps = {source}, deque([source]), 0 + while frontier and steps < bound: + configuration = frontier.popleft() + steps += 1 + for _, successor in system.step(configuration): + if successor == target: + return True + if successor in seen or successor.stack in forbidden: + continue + if successor.stack.width > 4 or \ + max(map(len, successor.stack.words)) > 5: + continue + seen.add(successor) + frontier.append(successor) + return False + + +class TestRelationAFromReturns: + """``A`` as far as returns take it. + + Kartzow's decomposition allows three kinds of piece: a return, a 1-loop + then a level 2 collapse, and a 1-loop then a pop that a later collapse + closes off. Only the first is built here, which is all of ``A`` unless a + collapse drops *several* words at once — a single word's worth is already + inside a return, since the summaries count a pushed link collapsed after a + 1-loop as one. + """ + + EXACT = { + 'clone and pop': [('0', None, 'c', '1', 'clone'), + ('1', None, 'o', '0', 'pop 2')], + 'a loop before each pop': [('0', None, 'u', '1', 'push a 1'), + ('1', 'a', 'p', '0', 'pop 1'), + ('0', None, 'o', '0', 'pop 2')], + 'a collapse over one word': [('0', None, 'u', '1', 'push a 2'), + ('1', 'a', 'x', '2', 'collapse')], + } + #: a collapse that drops every word the clones added — F2 and F3 territory + INCOMPLETE = [('0', None, 'c', '0', 'clone'), + ('0', None, 'u', '1', 'push a 2'), + ('1', None, 'k', '1', 'clone'), + ('1', 'a', 'x', '2', 'collapse')] + + def relation(self, rules): + system = Level2CPS(rules, symbols=SYMBOLS) + relations = Relations(system) + return system, relations, relations.without_scaffolding( + relations.a_returns()) + + def holds(self, relations, relation, source, target): + return relation.accepts(convolve_trees( + [encode_configuration(source), encode_configuration(target)], + frozenset(relations.encoding.alphabet), PAD)) + + @pytest.mark.parametrize("case", sorted(EXACT)) + def test_it_is_exact_where_no_collapse_spans_words(self, case): + system, relations, relation = self.relation(self.EXACT[case]) + configurations = [Configuration(state, stack) + for stack in walk_stacks(5, 8, width=3, height=3) + for state in system.states] + for source in configurations: + for target in configurations: + assert self.holds(relations, relation, source, target) == \ + a_oracle(system, source, target), (source, target) + + def test_it_is_sound_where_it_is_not_complete(self): + """Where a collapse spans several words the runs are missed, but none + are invented — the relation stays inside the truth.""" + system, relations, relation = self.relation(self.INCOMPLETE) + assert relations.collapses_on_links() + configurations = [Configuration(state, stack) + for stack in walk_stacks(5, 8, width=3, height=3) + for state in system.states] + missed = 0 + for source in configurations: + for target in configurations: + got = self.holds(relations, relation, source, target) + want = a_oracle(system, source, target) + assert not (got and not want), (source, target) + missed += want and not got + assert missed, "this system is meant to show the gap" + + def test_it_is_reflexive(self): + system, relations, relation = self.relation(self.EXACT['clone and pop']) + for stack in walk_stacks(3, 6): + for state in system.states: + configuration = Configuration(state, stack) + assert self.holds(relations, relation, configuration, + configuration) From f317f690395d2958225706d4636b22cf7fd70702 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Thu, 13 Aug 2026 14:44:02 +0200 Subject: [PATCH 61/76] A: a collapse that spans several words The piece of Kartzow's decomposition that looked hardest turns out to be local after all. A level 2 collapse drops the stack to the width its link records, which sounds like a link reaching across the tree -- but the link records the number of separators up to its letter, and the stack it points at is the tree's prefix at that separator, which is exactly where the region being deleted begins. So a 1-loop-and-pop group followed by a 1-loop-and-collapse is a chain climbing that region's own top path, closed off by its root, and it then contributes a pair of states to the outer chain just as a return does. The encoding is what makes this work, and it is why Kartzow chose it. Three things had to be right and none of them was, at first: - The 1-loop before a step may leave the stack where it was. A collapse straight off the top is not a return -- it drops more than one word -- so nothing would have covered it. - The chain's next node may hang to either side: a letter's continuation is its left child, a separator its right, and the last path takes whichever is there. Assuming the left one silently lost every case where a word ends at the letter being collapsed on. - Where no word comes off at all, A and B admit only the empty run, since their condition already fails at step zero -- the stack a run starts on is a substack of itself. A loop that stays put belongs to C, whose condition excludes only the PROPER substacks. My oracle had this wrong for A and read 38 real absences as failures; C had it wrong the other way and needed the high loop added. Checked on a system whose only rule is a collapse, so that no return exists anywhere in it, and where the stack still drops two words in one step: nothing but this piece can account for it. Co-Authored-By: Claude Opus 5 --- autstr/collapsible_reach.py | 134 ++++++++++++++++++++++++++------ tests/test_collapsible_reach.py | 82 ++++++++++++------- 2 files changed, 162 insertions(+), 54 deletions(-) diff --git a/autstr/collapsible_reach.py b/autstr/collapsible_reach.py index f0280a9..25b847b 100644 --- a/autstr/collapsible_reach.py +++ b/autstr/collapsible_reach.py @@ -716,11 +716,17 @@ def c(self) -> SparseTreeAutomaton: def chain(kind, label, first, final): return f'{kind}|{label}|{first}|{final}' + # Where the trees agree, carry up the annotation of the word the top + # path ends at. With no letter gained at all, C is a single high loop + # of that word — the run may touch where it started, which is what + # separates C from A and B — and this is where the root checks it. + agreed = [f'same {name}' for name in annotations] for label, annotation in marks: - for left in (None, 'same'): - for right in (None, 'same'): + for left in [None] + agreed: + for right in [None] + agreed: + ends = right or left or f'same {annotation}' table[(left, right, (label, label, annotation, self.NONE))] \ - = 'same' + = ends # the separator the first tree carries, where its last word ends, and # the one the second loses table[(None, None, (SEP, PAD, PAD, self.NONE))] = 'added' @@ -779,19 +785,22 @@ def chain(kind, label, first, final): source = chain(kind, below, final, deepest) target = f'{done} {start} {deepest}' table[(source, None, plain)] = target - table[('same', source, plain)] = target + for kept in agreed: + table[(kept, source, plain)] = target table[(None, source, plain)] = target for first in states: for final in states: for kind in ('done', 'cutdone'): below_state = f'{kind} {first} {final}' table[(below_state, None, plain)] = below_state - table[('same', below_state, plain)] = below_state + for kept in agreed: + table[(kept, below_state, plain)] = below_state table[(None, below_state, plain)] = below_state grown = f'grown {first} {final}' table[(f'cutdone {first} {final}', 'added', plain)] = grown table[(grown, None, plain)] = grown - table[('same', grown, plain)] = grown + for kept in agreed: + table[(kept, grown, plain)] = grown table[(None, grown, plain)] = grown for source in states: @@ -800,25 +809,34 @@ def chain(kind, label, first, final): self.NONE) for kind in ('done', 'grown'): table[(f'{kind} {source} {target}', None, labels)] = 'accept' - if source == target: - table[('same', None, labels)] = 'accept' + # no letter gained: one high loop of the topmost word + for name in annotations: + summary = self.summary_of(name) + if summary is not None and \ + (source, target) in summary.hloop: + table[(f'same {name}', None, labels)] = 'accept' return partial_tree_automaton(self.alphabet, 4, table, {'accept'}) - def a_returns(self) -> SparseTreeAutomaton: - """``A`` as far as returns take it: the stack loses whole words, one - return each. - - Kartzow's Lemma 4.11 decomposes a run of ``A`` into pieces that are - returns (F1), or a 1-loop followed by a level 2 collapse (F2), or a - 1-loop followed by a pop that some later F2 closes off (F3). This - builds the F1 case. It is all of ``A`` for a system whose runs never - collapse on a level 2 link, since F2 and F3 both end in one; where they - can occur this under-approximates, and `collapses_on_links` says so. - - The words the stack drops are the separators of the encoding, and the - run pops them from the last backwards — so within a subtree the chain - runs through the right child's separators, then the left child's, then - the node itself, which is reverse traversal order. + def a(self) -> SparseTreeAutomaton: + """``A``: the stack loses whole words. + + Kartzow's Lemma 4.11 decomposes such a run into pieces that are + returns (F1), a 1-loop then a level 2 collapse (F2), or a 1-loop then a + pop that some later F2 closes off (F3). The words dropped are the + encoding's separators, and the run drops them from the last backwards + — so within a subtree the chain runs through the right child's + separators, then the left child's, then the node itself, which is + reverse traversal order. + + A collapse spans several words at once, which sounds like a link + reaching across the tree. It is not: a level 2 link records the number + of separators up to its letter, and the stack it points at is the + tree's prefix at that separator — which is exactly where the region + being deleted begins. So an F3-F2 group is a chain climbing the top + path *inside* that region, a 1-loop and a pop at each letter and a + 1-loop and the collapse at the last, closed off by the region's own + root. Every check stays local, and the group then contributes a pair of + states to the outer chain just as a return does. """ table = {} annotations = list(self.annotation.names.values()) @@ -855,6 +873,71 @@ def a_returns(self) -> SparseTreeAutomaton: table[(left, f'ret {entered} {first}', symbols)] = \ f'ret {entered} {last}' + # an F3*F2 group: the run climbs the region's top path, taking a + # letter off after each 1-loop, and collapses on the last of them + for label, annotation in marks: + if label not in self.encoding.letters: + continue + summary = self.summary_of(annotation) + symbol, _, level = label.rpartition(':') + for guess in self.guesses: + pair = self.guessed(guess) + if pair is None or summary is None: + continue + first, last = pair + symbols = (label, PAD, annotation, guess) + # The 1-loop before each step may leave the stack as it was: + # a collapse straight off the top is no return -- it drops + # more than one word -- so nothing else would cover it. + idling = summary.oneloop | summary.loop + # ... a letter the group pops on its way up + if pair in compose(idling, + self.drop_moves(symbol, int(level))): + for below in (None, 'gone'): + table[(below, None, symbols)] = f'group {first} {last}' + for start in states: + # the node the chain came from is this one's left + # child where the word goes on, and its right where a + # separator ends the word -- the path takes either + for other in (None, 'gone'): + table[(f'group {start} {first}', other, + symbols)] = f'group {start} {last}' + table[(other, f'group {start} {first}', + symbols)] = f'group {start} {last}' + # ... and the letter it collapses on, which ends the group + if int(level) == 2 and pair in compose( + idling, self.summaries.moves(symbol, 'collapse')): + for below in (None, 'gone'): + table[(below, None, symbols)] = f'sprung {first} {last}' + table[(None, below, symbols)] = f'sprung {first} {last}' + for start in states: + for other in (None, 'gone'): + table[(f'group {start} {first}', other, + symbols)] = f'sprung {start} {last}' + table[(other, f'group {start} {first}', + symbols)] = f'sprung {start} {last}' + + # below the collapsed letter the region is dropped without any run + # step of its own, and the group closes at the region's root + for label, annotation in marks: + symbols = (label, PAD, annotation, self.NONE) + for first in states: + for last in states: + sprung = f'sprung {first} {last}' + if label in self.encoding.letters: + for other in (None, 'gone'): + table[(sprung, other, symbols)] = sprung + table[(other, sprung, symbols)] = sprung + else: # the region's root separator + for other in (None, 'gone'): + table[(sprung, other, symbols)] = \ + f'ret {first} {last}' + table[(other, sprung, symbols)] = \ + f'ret {first} {last}' + for entered in states: + table[(sprung, f'ret {entered} {first}', + symbols)] = f'ret {entered} {last}' + # a node both trees keep, below which words were dropped for label, annotation in marks: plain = (label, label, annotation, self.NONE) @@ -878,8 +961,9 @@ def a_returns(self) -> SparseTreeAutomaton: return partial_tree_automaton(self.alphabet, 4, table, {'accept'}) def collapses_on_links(self) -> bool: - """Whether the system can collapse on a level 2 link at all — if it - cannot, `a_returns` is the whole of ``A``.""" + """Whether the system can collapse on a level 2 link at all — the + case that makes `a` need its F2 and F3 pieces rather than returns + alone.""" pushes = {(rule.operation.symbol, rule.operation.level) for rule in self.system.rules if rule.operation.kind == 'push'} diff --git a/tests/test_collapsible_reach.py b/tests/test_collapsible_reach.py index 76f9300..020c5b0 100644 --- a/tests/test_collapsible_reach.py +++ b/tests/test_collapsible_reach.py @@ -476,11 +476,14 @@ def drops_to(source, target): def b_oracle(system, source, target, bound=3000): - """``B``: a run to a shorter topmost word that never dips below it.""" + """``B``: a run to a shorter topmost word that never dips below it. + + As with ``A``, dropping no letter at all leaves only the empty run. + """ if not drops_to(source.stack, target.stack): return False - if source == target: - return True # the run of no steps + if source.stack == target.stack: + return source == target forbidden = set(substacks(target.stack)) seen, frontier, steps = {source}, deque([source]), 0 while frontier and steps < bound: @@ -669,11 +672,17 @@ def pops_words_to(source, target): def a_oracle(system, source, target, bound=3000): - """``A``: a run that drops whole words and never dips below the target.""" + """``A``: a run that drops whole words and never dips below the target. + + With no word dropped at all the condition already fails at the run's first + step — the stack it starts on *is* a substack of the target — so nothing + but the empty run qualifies. A loop that stays where it is belongs to C, + whose condition excludes only the *proper* substacks. + """ if not pops_words_to(source.stack, target.stack): return False - if source == target: - return True + if source.stack == target.stack: + return source == target forbidden = set(substacks(target.stack)) seen, frontier, steps = {source}, deque([source]), 0 while frontier and steps < bound: @@ -692,15 +701,16 @@ def a_oracle(system, source, target, bound=3000): return False -class TestRelationAFromReturns: - """``A`` as far as returns take it. +class TestRelationA: + """``A`` — the stack loses whole words. Kartzow's decomposition allows three kinds of piece: a return, a 1-loop then a level 2 collapse, and a 1-loop then a pop that a later collapse - closes off. Only the first is built here, which is all of ``A`` unless a - collapse drops *several* words at once — a single word's worth is already - inside a return, since the summaries count a pushed link collapsed after a - 1-loop as one. + closes off. A collapse spanning several words looks like a link reaching + across the tree, but a level 2 link records the number of separators up to + its letter, and the stack it points at is the tree's prefix at that + separator — exactly where the deleted region begins. So the group is a + chain climbing that region's own top path. """ EXACT = { @@ -712,17 +722,17 @@ class TestRelationAFromReturns: 'a collapse over one word': [('0', None, 'u', '1', 'push a 2'), ('1', 'a', 'x', '2', 'collapse')], } - #: a collapse that drops every word the clones added — F2 and F3 territory - INCOMPLETE = [('0', None, 'c', '0', 'clone'), - ('0', None, 'u', '1', 'push a 2'), - ('1', None, 'k', '1', 'clone'), - ('1', 'a', 'x', '2', 'collapse')] + #: a collapse that drops every word the clones added + SPANNING = [('0', None, 'c', '0', 'clone'), + ('0', None, 'u', '1', 'push a 2'), + ('1', None, 'k', '1', 'clone'), + ('1', 'a', 'x', '2', 'collapse')] def relation(self, rules): system = Level2CPS(rules, symbols=SYMBOLS) relations = Relations(system) return system, relations, relations.without_scaffolding( - relations.a_returns()) + relations.a()) def holds(self, relations, relation, source, target): return relation.accepts(convolve_trees( @@ -730,7 +740,7 @@ def holds(self, relations, relation, source, target): frozenset(relations.encoding.alphabet), PAD)) @pytest.mark.parametrize("case", sorted(EXACT)) - def test_it_is_exact_where_no_collapse_spans_words(self, case): + def test_against_the_search(self, case): system, relations, relation = self.relation(self.EXACT[case]) configurations = [Configuration(state, stack) for stack in walk_stacks(5, 8, width=3, height=3) @@ -740,22 +750,36 @@ def test_it_is_exact_where_no_collapse_spans_words(self, case): assert self.holds(relations, relation, source, target) == \ a_oracle(system, source, target), (source, target) - def test_it_is_sound_where_it_is_not_complete(self): - """Where a collapse spans several words the runs are missed, but none - are invented — the relation stays inside the truth.""" - system, relations, relation = self.relation(self.INCOMPLETE) + def test_a_collapse_that_spans_several_words(self): + system, relations, relation = self.relation(self.SPANNING) assert relations.collapses_on_links() configurations = [Configuration(state, stack) for stack in walk_stacks(5, 8, width=3, height=3) for state in system.states] - missed = 0 for source in configurations: for target in configurations: - got = self.holds(relations, relation, source, target) - want = a_oracle(system, source, target) - assert not (got and not want), (source, target) - missed += want and not got - assert missed, "this system is meant to show the gap" + assert self.holds(relations, relation, source, target) == \ + a_oracle(system, source, target), (source, target) + + def test_a_collapse_no_sequence_of_returns_could_make(self): + """The only rule is a collapse, so the system has no return at all — + and the stack still drops two words in one step, which nothing but the + 1-loop-and-collapse piece can account for.""" + system = Level2CPS([('p', 'a', 'x', 'q', 'collapse')], symbols=SYMBOLS) + relations = Relations(system) + relation = relations.without_scaffolding(relations.a()) + assert all(not summary.ret + for summary in relations.summaries.values.values()) + + word = (Letter('⊥'), Letter('a', 2, 1)) + tall = Stack(((Letter('⊥'),), word, word)) + short = Stack(((Letter('⊥'),),)) + assert tall.collapse() == short # two words in one step + assert self.holds(relations, relation, Configuration('p', tall), + Configuration('q', short)) + # and only from the state the collapse fires in + assert not self.holds(relations, relation, Configuration('q', tall), + Configuration('q', short)) def test_it_is_reflexive(self): system, relations, relation = self.relation(self.EXACT['clone and pop']) From 17159228533d32eefbd205da531b322525e5f371 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Thu, 13 Aug 2026 14:55:05 +0200 Subject: [PATCH 62/76] D and the composition, with D not yet correct The fourth relation and the formula that puts the four together. The formula is the easy half: reachability is A;B;C;D, all four reflexive, so it is Reach(x,y) = exists u,v,w. A(x,u) & B(u,v) & C(v,w) & D(w,y) evaluated over a scratch structure whose domain is the encoding trees. It builds -- 41 to 117 states on the systems tried, a second or two -- and is only ever as good as the four it composes. D is not there yet, and this commit records where. Kartzow's Cor. 4.10 walks the milestones of the stack being built, which the encoding puts in traversal order, one per node; the run only ever moves forward through them, which is what decides where each operation goes. Two readings of that have now been wrong in opposite directions: - cloning where the two words part lets a word be shorter than the copy it came from for nothing, which no system need allow; - cloning at the deepest point and popping back up loses the plain clone, because the step from the last shared node into the new word is then never paid for. What both miss is the GENERALISED milestones: between one word and the next the run passes through the previous word cloned WHOLE, and only then pops it down to where the two part. That intermediate stack is not the LeftStack of any node, which is exactly why Kartzow carries a second correspondence for it, and why guessing a state per node is not enough on its own. A, B and C are unaffected and stay checked against the search. The failing case is kept as an xfail rather than deleted, so the next attempt has something to aim at. Co-Authored-By: Claude Opus 5 --- autstr/collapsible_reach.py | 157 ++++++++++++++++++++++++++++++++ tests/test_collapsible_reach.py | 90 ++++++++++++++++++ 2 files changed, 247 insertions(+) diff --git a/autstr/collapsible_reach.py b/autstr/collapsible_reach.py index 25b847b..d93360e 100644 --- a/autstr/collapsible_reach.py +++ b/autstr/collapsible_reach.py @@ -971,3 +971,160 @@ def collapses_on_links(self) -> bool: return False return any(rule.operation.kind == 'collapse' for rule in self.system.rules) + + def d(self) -> SparseTreeAutomaton: + """``D``: the stack grows, and the run never dips below where it + started. + + Kartzow's Cor. 4.10 says such a run visits the milestones of the stack + it builds in order, consecutive ones joined by a single operation and a + loop — and the encoding puts those milestones in traversal order, one + per node. The run therefore only ever moves *forward* through the tree, + which is what decides where each operation goes: descending to a left + child is a push, and a new word is reached by cloning at the deepest + point of the word before it and then popping back up, one letter per + level, until the two words part company. + + Cloning at the deepest point rather than where the words part is the + whole of it. The other way round would let a word be shorter than the + copy it came from for nothing, which is a run no system need have. + """ + table = {} + annotations = list(self.annotation.names.values()) + marks = [(label, annotation) + for label in self.encoding.nodes for annotation in annotations] + letters = list(self.encoding.letters) + states = self.system.states + + def grew(cloned, label, arrive, leave): + return f'grew|{int(cloned)}|{label}|{arrive}|{leave}' + + for label, annotation in marks: + for left in (None, 'same'): + for right in (None, 'same'): + table[(left, right, (label, label, annotation, self.NONE))] \ + = 'same' + + for label, annotation in marks: + summary = self.summary_of(annotation) + if summary is None: + continue + loop = summary.loop + clones = self.summaries.moves(summary.symbol or '', 'clone') + for guess in self.guesses: + pair = self.guessed(guess) + if pair is None: + continue + arrive, leave = pair + symbols = (PAD, label, annotation, guess) + + # the deepest milestone of a word: the run loops there, and + # clones if another word is still to come + if (arrive, leave) in loop: + table[(None, None, symbols)] = \ + grew(False, label, arrive, leave) + if (arrive, leave) in compose(loop, clones): + table[(None, None, symbols)] = \ + grew(True, label, arrive, leave) + + for below in letters: + symbol, _, level = below.rpartition(':') + down = compose(loop, self.push_moves(annotation, below)) + up = compose(self.drop_moves(symbol, int(level)), loop) + for entered in states: + if (arrive, entered) not in down: + continue + for came_back in states: + # deeper and never back: this word is the last + child = grew(False, below, entered, came_back) + if came_back == leave: + table[(child, None, symbols)] = \ + grew(False, label, arrive, leave) + # deeper, cloned down there, and popping back up + cloned = grew(True, below, entered, came_back) + if (came_back, leave) in up: + table[(cloned, None, symbols)] = \ + grew(True, label, arrive, leave) + # ... until the words part, where the new one + # hangs as this node's right child + for last in states: + sibling = grew(False, SEP, leave, last) + if (came_back, leave) in up: + table[(cloned, sibling, symbols)] = \ + grew(False, label, arrive, last) + shared = grew(True, SEP, leave, last) + if (came_back, leave) in up: + table[(cloned, shared, symbols)] = \ + grew(True, label, arrive, last) + + # a word that parts from this one right here: the clone + # happened below, and nothing was popped since + for last in states: + for kind in (False, True): + sibling = grew(kind, SEP, leave, last) + if (arrive, leave) in compose(loop, clones): + table[(None, sibling, symbols)] = \ + grew(kind, label, arrive, last) + + # Where the growth hangs, and the run starts. The first new word is + # a clone of the one this node names, and this is the only place that + # clone can be checked -- inside the region every node is one the + # second tree alone has. + for label, annotation in marks: + summary = self.summary_of(annotation) + plain = (label, label, annotation, self.NONE) + if summary is None: + continue + starts = compose(summary.loop, + self.summaries.moves(summary.symbol or '', 'clone')) + for arrive in states: + for last in states: + child = grew(False, SEP, arrive, last) + for first in states: + if (first, arrive) not in starts: + continue + done = f'done {first} {last}' + for other in (None, 'same'): + table[(other, child, plain)] = done + table[(child, None, plain)] = done + for first in states: + for last in states: + done = f'done {first} {last}' + table[(done, None, plain)] = done + table[('same', done, plain)] = done + table[(None, done, plain)] = done + + for source in states: + for target in states: + labels = (f'<{source}>', f'<{target}>', Annotation.START, + self.NONE) + table[(f'done {source} {target}', None, labels)] = 'accept' + if source == target: + table[('same', None, labels)] = 'accept' + return partial_tree_automaton(self.alphabet, 4, table, {'accept'}) + + def reach(self) -> SparseTreeAutomaton: + """Reachability: a run of any length, between any two configurations. + + Kartzow's Remark 4.4 splits every run into four stretches — words come + off, then letters, then letters go back on, then words — and each is + reflexive, so no run is excluded by having to pass through all four. + The relation is therefore the composition, which is a first-order + formula over the four and needs no automaton of its own:: + + Reach(x,y) ≡ ∃u ∃v ∃w. A(x,u) ∧ B(u,v) ∧ C(v,w) ∧ D(w,y) + + The quantifiers range over configurations, so the domain of the scratch + structure is the encoding trees themselves. + """ + from autstr.tree_presentations import TreeAutomaticPresentation + scratch = TreeAutomaticPresentation( + {'U': self.encoding.universe(), + 'A': self.without_scaffolding(self.a()), + 'B': self.without_scaffolding(self.b()), + 'C': self.without_scaffolding(self.c(), annotated=1), + 'D': self.without_scaffolding(self.d(), annotated=1)}, + padding_symbol=PAD) + return scratch.evaluate( + 'exists u.(exists v.(exists w.(' + 'A(x,u) & B(u,v) & C(v,w) & D(w,y))))') diff --git a/tests/test_collapsible_reach.py b/tests/test_collapsible_reach.py index 020c5b0..281e7b3 100644 --- a/tests/test_collapsible_reach.py +++ b/tests/test_collapsible_reach.py @@ -788,3 +788,93 @@ def test_it_is_reflexive(self): configuration = Configuration(state, stack) assert self.holds(relations, relation, configuration, configuration) + + +def d_oracle(system, source, target, bound=3000): + """``D``: the stack grows, and after the first step nothing is a substack + of where it started.""" + current = target.stack + grows = False + while current is not None: + if current == source.stack: + grows = True + break + current = current.pop2() + if not grows: + return False + if source.stack == target.stack: + return source == target + forbidden = set(substacks(source.stack)) + seen, frontier, steps = {source}, deque([source]), 0 + while frontier and steps < bound: + configuration = frontier.popleft() + steps += 1 + for _, successor in system.step(configuration): + if successor.stack in forbidden: + continue + if successor == target: + return True + if successor in seen: + continue + if successor.stack.width > 4 or \ + max(map(len, successor.stack.words)) > 5: + continue + seen.add(successor) + frontier.append(successor) + return False + + +class TestRelationD: + """``D`` — the stack grows. **Not correct yet.** + + Kartzow's Cor. 4.10 walks the milestones of the stack being built, which + the encoding puts in traversal order. What this does not yet handle are + the *generalised* milestones: between one word and the next the run passes + through the previous word cloned whole, and only then pops it down to + where the two words part. Getting that wrong shows up in both directions, + and the two cases below are kept as a record of it. + """ + + CLONE_ONLY = [('0', None, 'c', '1', 'clone')] + NO_POPS = [('0', None, 'c', '1', 'clone'), + ('1', None, 'o', '0', 'pop 2')] + + def relation(self, rules): + system = Level2CPS(rules, symbols=SYMBOLS) + relations = Relations(system) + return system, relations, relations.without_scaffolding( + relations.d(), annotated=1) + + def holds(self, relations, relation, source, target): + return relation.accepts(convolve_trees( + [encode_configuration(source), encode_configuration(target)], + frozenset(relations.encoding.alphabet), PAD)) + + def test_one_clone(self): + system, relations, relation = self.relation(self.CLONE_ONLY) + for word in ((Letter('⊥'),), (Letter('⊥'), Letter('a'))): + assert self.holds(relations, relation, + Configuration('0', Stack((word,))), + Configuration('1', Stack((word, word)))) + + def test_it_is_reflexive(self): + system, relations, relation = self.relation(self.CLONE_ONLY) + for stack in walk_stacks(3, 6): + for state in system.states: + configuration = Configuration(state, stack) + assert self.holds(relations, relation, configuration, + configuration) + + @pytest.mark.xfail(reason="the generalised milestones are not handled: " + "the run clones the whole word before popping " + "it down to where the two words part") + def test_a_word_shorter_than_its_clone_needs_pops(self): + """The system can only clone and pop words, never letters — so the + second word cannot be shorter than the first.""" + system, relations, relation = self.relation(self.NO_POPS) + long_word = (Letter('⊥'), Letter('a'), Letter('a')) + short_word = (Letter('⊥'), Letter('a')) + assert not self.holds( + relations, relation, + Configuration('0', Stack((long_word,))), + Configuration('1', Stack((long_word, short_word)))) From d59b40188b915c9d60d37d15c8c8186c6e963e12 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Thu, 13 Aug 2026 17:20:15 +0200 Subject: [PATCH 63/76] D, and reachability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last of the four relations, and with it Reach = A;B;C;D actually works: checked against breadth-first search, and -- the test that mattered -- reflexive and transitive as first-order properties of the relation built. D walks the milestones of the stack being built. Measuring that walk rather than reasoning about it gave three moves and no others: a push to a left child, a clone to a right child, and an ascent that clones at the DEEPEST node reached and then pops one letter per level, each node giving up its own, a separator having none to give. Cloning at the deepest point rather than where the words part is the whole of it -- the other way round lets a word be shorter than the copy it came from for nothing. Five bugs, each found by a check the one before it could not have caught: - The run only ever moves forward through the milestones, so there is no walking back up and cloning afterwards. - A leaf can be the run's end or a pass-through, and a table has one entry per key; which of the two it is belongs on the tape. - Likewise which state the run began in: two starts wanted the same entry and the second was quietly winning. - The state comes up from the child, so it is the CHILD's label an ascent names, not the parent's. - The loop after a pop belongs to the word the PARENT names. Three more came out of Reach itself, none of them in D: - B missed the letter that is dropped but not deleted -- when the word below shares it, the letter stays and only the separator moves up. - The summaries' merged automaton has to be CLOSED, every reachable summary having a transition for every letter, all witnessed by words that have stopped growing. Short of that a real word has no summary, which surfaces as a tree that cannot be annotated at all. - A let a deleted separator pass as inert, so dropping two words was paid for with one return. A separator may only be inert below the letter an F2 group collapses on, which is a different region and now says so on the tape. The last of those was caught by transitivity, on configurations no bounded sweep had reached: x = 0 ⊥:⊥a, y = 1 ⊥:⊥a:⊥a, z = 0 ⊥, where that system only ever cycles. A sweep over small stacks is a weak oracle; asking the built relation whether it is transitive is a strong one, and it belongs in the tests for good. Co-Authored-By: Claude Opus 5 --- autstr/collapsible_reach.py | 357 ++++++++++++++++++++++---------- tests/test_collapsible_reach.py | 109 +++++++++- 2 files changed, 351 insertions(+), 115 deletions(-) diff --git a/autstr/collapsible_reach.py b/autstr/collapsible_reach.py index d93360e..a7007bd 100644 --- a/autstr/collapsible_reach.py +++ b/autstr/collapsible_reach.py @@ -160,7 +160,6 @@ def __init__(self, system: Level2CPS, depth: int = 3, self.converged = False self.values: Dict[tuple, Summary] = {} self.depth = depth - previous_bound = 0 #: the words whose summary stopped growing when the bound last rose — #: a value can only grow, so agreeing twice means it is the truth self.stable: set = set() @@ -169,15 +168,32 @@ def __init__(self, system: Level2CPS, depth: int = 3, self.depth = bound self.stable = {path for path in previous if previous[path] == self.values[path]} - # the longest words of a round are the ones whose own extensions - # were pinned empty, so they always improve when the bound rises; - # convergence is about the words below that edge - if previous and all( - previous[path] == self.values[path] for path in previous - if len(path) < previous_bound): + # Converged means the merged automaton is *closed*: every summary + # it can reach has a transition for every letter, all of them + # witnessed by words that have stopped growing. Anything less and + # a word the structure really contains may have no summary at all, + # which downstream shows up as a tree that cannot be annotated. + try: + start, table = self._merge() + except ValueError: + continue + reachable, frontier = {start}, [start] + while frontier: + current = frontier.pop() + for letter in self.letters: + following = table.get((current, letter)) + if following is None: + break + if following not in reachable: + reachable.add(following) + frontier.append(following) + else: + continue + break + else: self.converged = True + self._merged = (start, table) break - previous_bound = bound # -- the transitions available on one topmost symbol ---------------- def _transitions(self, symbol: str) -> Dict[object, Relation]: @@ -240,6 +256,16 @@ def _fixpoint(self, bound: int) -> Dict[tuple, Summary]: def transitions(self) -> Tuple[Summary, Dict[Tuple[Summary, Letter], Summary]]: + """The merged automaton, as `_merge` builds it, once it is closed.""" + if not self.converged: + raise ValueError( + f"the summaries were still growing, or the automaton they " + f"merge into is not closed, at {self.depth} letters; raise " + f"the limit") + return self._merged + + def _merge(self) -> Tuple[Summary, Dict[Tuple[Summary, Letter], + Summary]]: """The summaries as an automaton: the summary of the bottom letter, and a table saying how one letter extends a summary. @@ -248,11 +274,6 @@ def transitions(self) -> Tuple[Summary, Dict[Tuple[Summary, Letter], fixpoint are left out of the merge: their own extensions were pinned empty, so what they say about a letter is not yet the truth. """ - if not self.converged: - raise ValueError( - f"the summaries were still growing at {self.depth} letters, so " - f"words that look alike need not behave alike yet; raise the " - f"limit") # a word near the edge has its own extensions pinned empty, so what # it says about a letter is short of the truth; only the words whose # summary has stopped growing may speak @@ -500,6 +521,10 @@ class Relations: #: a node no run visits, and so carries no guessed states NONE = '@-' + #: a node one collapse takes away along with everything around it — told + #: apart from an ordinary dropped letter on the tape, since the two inert + #: regions would otherwise want the same entry + BURIED = '@buried' def __init__(self, system: Level2CPS, summaries: Optional[Summaries] = None ) -> None: @@ -513,7 +538,16 @@ def __init__(self, system: Level2CPS, summaries: Optional[Summaries] = None self.guesses = [self.NONE] + [ f'@{before},{after}' for before in system.states for after in system.states] - self.alphabet = set(self.annotation.alphabet) | set(self.guesses) + # D needs one thing more: whether the run ENDS in this subtree, passes + # through it, or is climbing out of what the two stacks share. A leaf + # can be any of the three, and a table has one entry per key, so the + # choice has to be on the tape rather than in the state. + self.guesses.append(self.BURIED) + self.walks = [f'&{before},{after},{kind}' + for before in system.states for after in system.states + for kind in ('end', 'thru', 'rise', 'start')] + self.alphabet = set(self.annotation.alphabet) | set(self.guesses) \ + | set(self.walks) def summary_of(self, annotation: str) -> Optional[Summary]: """The summary an annotation letter stands for.""" @@ -532,11 +566,18 @@ def drop_moves(self, symbol: str, level: int) -> Relation: def guessed(self, guess: str) -> Optional[Tuple[str, str]]: """The pair of states a guess letter carries, or None.""" - if guess == self.NONE: + if guess == self.NONE or guess.startswith('&'): return None before, _, after = guess[1:].partition(',') return before, after + def walked(self, guess: str) -> Optional[Tuple[str, str, str]]: + """The two states and the kind of walk a `D` guess letter carries.""" + if not guess.startswith('&'): + return None + before, after, kind = guess[1:].split(',') + return before, after, kind + def dropping(self, annotation: str, label: str, guess: str) -> bool: """Whether a letter may be dropped as the guess says: a high loop of the word ending at it, then one transition that takes it off. @@ -610,6 +651,37 @@ def b(self) -> SparseTreeAutomaton: table[(None, f'chain {first} {last}', symbols)] = \ f'cutchain {first} {last}' + # A letter the run drops need not be one the second tree loses: when + # the word below shares it, the letter stays and only the separator + # moves up. So the chain carries on through nodes both trees keep, + # dropping their letters too. + for label, annotation in marks: + if label not in self.encoding.letters: + continue + for guess in self.guesses: + if not self.dropping(annotation, label, guess): + continue + before, after = self.guessed(guess) + shared = (label, label, annotation, guess) + # the chain may even begin here, where the word loses only + # letters the word below it shares: then nothing is deleted + # but the separator that used to end it + # ... and the node it hangs from may carry it on either side: + # a letter's continuation is its left child, a separator its + # right + table[('cut', None, shared)] = f'cutrise {before} {after}' + table[(None, 'cut', shared)] = f'cutrise {before} {after}' + for start_state in self.system.states: + # only once a separator has gone: a letter that stays may + # be dropped exactly when the word below shares it, and + # then the separator that ended the word moves up + for kind in ('cutchain', 'cutrise'): + for side in (None, 'same'): + table[(f'{kind} {start_state} {before}', side, + shared)] = f'cutrise {start_state} {after}' + table[(side, f'{kind} {start_state} {before}', + shared)] = f'cutrise {start_state} {after}' + # the chain stops at a node both trees keep. From there up nothing may # follow it in either tree: coming from a right child anything already # read may sit to the left, but coming from a left child there must be @@ -618,13 +690,15 @@ def b(self) -> SparseTreeAutomaton: plain = (label, label, annotation, self.NONE) for first, last in pairs: for chain, kind in ((f'chain {first} {last}', 'done'), - (f'cutchain {first} {last}', 'cutdone')): + (f'cutchain {first} {last}', 'cutdone'), + (f'cutrise {first} {last}', 'cutdone')): for below in (chain, f'{kind} {first} {last}'): table[(below, None, plain)] = f'{kind} {first} {last}' table[('same', below, plain)] = f'{kind} {first} {last}' table[(None, below, plain)] = f'{kind} {first} {last}' # the deleted separator is made good by the added one for below in (f'cutchain {first} {last}', + f'cutrise {first} {last}', f'cutdone {first} {last}'): table[(below, 'added', plain)] = f'grown {first} {last}' grown = f'grown {first} {last}' @@ -849,14 +923,25 @@ def a(self) -> SparseTreeAutomaton: for right in (None, 'same'): table[(left, right, (label, label, annotation, self.NONE))] \ = 'same' - - # inside a word the first tree drops, nothing is guessed + # Inside a word the first tree drops, nothing is guessed -- but only + # its letters. A separator going means a whole word going, and every + # word that goes must be paid for by a return or by an F2 group's + # collapse; letting one pass as inert drops two words for the price of + # one. for label, annotation in marks: + if label not in self.encoding.letters: + continue for left in (None, 'gone'): for right in (None, 'gone'): table[(left, right, (label, PAD, annotation, self.NONE))] \ = 'gone' - + # below the letter an F2 group collapses on, everything goes at once, + # separators included + for label, annotation in marks: + for left in (None, 'buried'): + for right in (None, 'buried'): + table[(left, right, + (label, PAD, annotation, self.BURIED))] = 'buried' # a separator the first tree drops: one return of the word it names for annotation in annotations: summary = self.summary_of(annotation) @@ -893,13 +978,13 @@ def a(self) -> SparseTreeAutomaton: # ... a letter the group pops on its way up if pair in compose(idling, self.drop_moves(symbol, int(level))): - for below in (None, 'gone'): + for below in (None, 'buried'): table[(below, None, symbols)] = f'group {first} {last}' for start in states: # the node the chain came from is this one's left # child where the word goes on, and its right where a # separator ends the word -- the path takes either - for other in (None, 'gone'): + for other in (None, 'buried'): table[(f'group {start} {first}', other, symbols)] = f'group {start} {last}' table[(other, f'group {start} {first}', @@ -907,11 +992,11 @@ def a(self) -> SparseTreeAutomaton: # ... and the letter it collapses on, which ends the group if int(level) == 2 and pair in compose( idling, self.summaries.moves(symbol, 'collapse')): - for below in (None, 'gone'): + for below in (None, 'buried'): table[(below, None, symbols)] = f'sprung {first} {last}' table[(None, below, symbols)] = f'sprung {first} {last}' for start in states: - for other in (None, 'gone'): + for other in (None, 'buried'): table[(f'group {start} {first}', other, symbols)] = f'sprung {start} {last}' table[(other, f'group {start} {first}', @@ -925,11 +1010,11 @@ def a(self) -> SparseTreeAutomaton: for last in states: sprung = f'sprung {first} {last}' if label in self.encoding.letters: - for other in (None, 'gone'): + for other in (None, 'buried'): table[(sprung, other, symbols)] = sprung table[(other, sprung, symbols)] = sprung else: # the region's root separator - for other in (None, 'gone'): + for other in (None, 'buried'): table[(sprung, other, symbols)] = \ f'ret {first} {last}' table[(other, sprung, symbols)] = \ @@ -976,28 +1061,34 @@ def d(self) -> SparseTreeAutomaton: """``D``: the stack grows, and the run never dips below where it started. - Kartzow's Cor. 4.10 says such a run visits the milestones of the stack - it builds in order, consecutive ones joined by a single operation and a - loop — and the encoding puts those milestones in traversal order, one - per node. The run therefore only ever moves *forward* through the tree, - which is what decides where each operation goes: descending to a left - child is a push, and a new word is reached by cloning at the deepest - point of the word before it and then popping back up, one letter per - level, until the two words part company. - - Cloning at the deepest point rather than where the words part is the - whole of it. The other way round would let a word be shorter than the - copy it came from for nothing, which is a run no system need have. + Kartzow's Cor. 4.10 walks the milestones of the stack being built, + consecutive ones joined by a single operation and a loop, and the + encoding lays them out one per node in traversal order. Measuring that + walk gives three moves and no others: + + to a left child push that letter + to a right child clone + back up a level pop, one per level, each node popping its own + + with the clone that starts an ascent happening at the *deepest* node + reached, not where the two words part. A separator carries no letter, + so it pops nothing on the way out. + + That makes one invariant per subtree: the run arrives at its root's + milestone in one state and leaves the subtree in another, having + cloned at its deepest node and popped back up to the word its parent + names. The run ends inside exactly one subtree, and there it leaves + by simply stopping — which is the difference between the two kinds of + walk state below. """ table = {} annotations = list(self.annotation.names.values()) marks = [(label, annotation) for label in self.encoding.nodes for annotation in annotations] - letters = list(self.encoding.letters) states = self.system.states - def grew(cloned, label, arrive, leave): - return f'grew|{int(cloned)}|{label}|{arrive}|{leave}' + def walk(label, arrive, leave, kind): + return f'w|{label}|{arrive}|{leave}|{kind}' for label, annotation in marks: for left in (None, 'same'): @@ -1011,82 +1102,136 @@ def grew(cloned, label, arrive, leave): continue loop = summary.loop clones = self.summaries.moves(summary.symbol or '', 'clone') - for guess in self.guesses: - pair = self.guessed(guess) - if pair is None: - continue - arrive, leave = pair - symbols = (PAD, label, annotation, guess) - - # the deepest milestone of a word: the run loops there, and - # clones if another word is still to come - if (arrive, leave) in loop: - table[(None, None, symbols)] = \ - grew(False, label, arrive, leave) - if (arrive, leave) in compose(loop, clones): - table[(None, None, symbols)] = \ - grew(True, label, arrive, leave) - - for below in letters: - symbol, _, level = below.rpartition(':') + # Leaving a subtree: clone down here, and then this node gives up + # its own letter -- unless it is a separator, which has none. The + # loop that follows that pop belongs to the word the PARENT names, + # not this one, so it is left for the parent to apply; each node + # loops on its own word before it pops. + if label in self.encoding.letters: + symbol, _, level = label.rpartition(':') + drop = self.drop_moves(symbol, int(level)) + out = compose(compose(loop, clones), compose(loop, drop)) + mine = compose(loop, drop) + else: + out = compose(loop, clones) + mine = loop + + for guess in self.walks: + arrive, leave, kind = self.walked(guess) + added = (PAD, label, annotation, guess) + shared = (label, label, annotation, guess) + + # a milestone with nothing below it: the run either stops + # here, or clones and gives this node's letter back up + if kind == 'end' and (arrive, leave) in loop: + table[(None, None, added)] = \ + walk(label, arrive, leave, 'end') + if kind == 'thru' and (arrive, leave) in out: + table[(None, None, added)] = \ + walk(label, arrive, leave, 'thru') + if kind == 'rise' and (arrive, leave) in out: + # the run starts at the top of the first stack and climbs + # out of the part both trees share + table[(None, None, shared)] = \ + walk(label, arrive, leave, 'rise') + + for below in self.encoding.letters: down = compose(loop, self.push_moves(annotation, below)) - up = compose(self.drop_moves(symbol, int(level)), loop) for entered in states: if (arrive, entered) not in down: continue for came_back in states: - # deeper and never back: this word is the last - child = grew(False, below, entered, came_back) - if came_back == leave: - table[(child, None, symbols)] = \ - grew(False, label, arrive, leave) - # deeper, cloned down there, and popping back up - cloned = grew(True, below, entered, came_back) - if (came_back, leave) in up: - table[(cloned, None, symbols)] = \ - grew(True, label, arrive, leave) - # ... until the words part, where the new one - # hangs as this node's right child + child = walk(below, entered, came_back, 'thru') + ends = walk(below, entered, came_back, 'end') + # the run ends somewhere below + if kind == 'end' and came_back == leave: + table[(ends, None, added)] = \ + walk(label, arrive, leave, 'end') + # or comes back up and this node gives up its letter + if kind == 'thru' and (came_back, leave) in mine: + table[(child, None, added)] = \ + walk(label, arrive, leave, 'thru') + # or hands on to a word cloned from this one, which + # the ascent has already popped down to for last in states: - sibling = grew(False, SEP, leave, last) - if (came_back, leave) in up: - table[(cloned, sibling, symbols)] = \ - grew(False, label, arrive, last) - shared = grew(True, SEP, leave, last) - if (came_back, leave) in up: - table[(cloned, shared, symbols)] = \ - grew(True, label, arrive, last) - - # a word that parts from this one right here: the clone - # happened below, and nothing was popped since - for last in states: - for kind in (False, True): - sibling = grew(kind, SEP, leave, last) - if (arrive, leave) in compose(loop, clones): - table[(None, sibling, symbols)] = \ - grew(kind, label, arrive, last) - - # Where the growth hangs, and the run starts. The first new word is - # a clone of the one this node names, and this is the only place that - # clone can be checked -- inside the region every node is one the - # second tree alone has. + for below_kind in ('end', 'thru'): + sibling = walk(SEP, came_back, last, + below_kind) + if kind == 'end' and below_kind == 'end': + table[(child, sibling, added)] = \ + walk(label, arrive, last, 'end') + elif kind == 'thru' and \ + below_kind == 'thru' and \ + (last, leave) in mine: + table[(child, sibling, added)] = \ + walk(label, arrive, leave, 'thru') + + # a word cloned from this one, with nothing pushed first + across = compose(loop, clones) + for entered in states: + if (arrive, entered) not in across: + continue + for last in states: + for below_kind in ('end', 'thru'): + sibling = walk(SEP, entered, last, below_kind) + if kind == 'end' and below_kind == 'end': + table[(None, sibling, added)] = \ + walk(label, arrive, last, 'end') + elif kind == 'thru' and below_kind == 'thru' and \ + (last, leave) in mine: + table[(None, sibling, added)] = \ + walk(label, arrive, leave, 'thru') + + # climbing out of the shared part: each node gives up its own letter + # until the one the new words hang from for label, annotation in marks: summary = self.summary_of(annotation) - plain = (label, label, annotation, self.NONE) if summary is None: continue - starts = compose(summary.loop, - self.summaries.moves(summary.symbol or '', 'clone')) - for arrive in states: - for last in states: - child = grew(False, SEP, arrive, last) - for first in states: - if (first, arrive) not in starts: - continue - done = f'done {first} {last}' - for other in (None, 'same'): - table[(other, child, plain)] = done - table[(child, None, plain)] = done + plain = (label, label, annotation, self.NONE) + if label in self.encoding.letters: + symbol, _, level = label.rpartition(':') + mine = compose(summary.loop, + self.drop_moves(symbol, int(level))) + else: + mine = summary.loop + # the state comes up from the child, so it is the CHILD's label + # the ascent has to name, not this node's + for child_label in self.encoding.nodes: + for first in states: + for came_back in states: + below = walk(child_label, first, came_back, 'rise') + for leave in states: + if (came_back, leave) not in mine: + continue + for other in (None, 'same'): + table[(below, other, plain)] = \ + walk(label, first, leave, 'rise') + # where the new words hang: the ascent is over + for last in states: + sibling = walk(SEP, came_back, last, 'end') + table[(below, sibling, plain)] = \ + f'done {first} {last}' + + # Or the first stack ends right here, and the clone that starts + # the new words happens at this very node. Which state the run + # began in is a choice like any other, so it goes on the tape -- + # two starts would otherwise want the same entry, and the table + # has room for one. + start = compose(summary.loop, + self.summaries.moves(summary.symbol or '', 'clone')) + for entered in states: + for first in states: + if (entered, first) not in start: + continue + marked = (label, label, annotation, + f'&{entered},{first},start') + for last in states: + sibling = walk(SEP, first, last, 'end') + # only where the shared part stops: a shared child + # would mean the run began higher up than the first + # stack actually reaches + table[(None, sibling, marked)] = f'done {entered} {last}' for first in states: for last in states: done = f'done {first} {last}' diff --git a/tests/test_collapsible_reach.py b/tests/test_collapsible_reach.py index 281e7b3..e148235 100644 --- a/tests/test_collapsible_reach.py +++ b/tests/test_collapsible_reach.py @@ -825,14 +825,14 @@ def d_oracle(system, source, target, bound=3000): class TestRelationD: - """``D`` — the stack grows. **Not correct yet.** + """``D`` — the stack grows, and the run never dips below where it started. Kartzow's Cor. 4.10 walks the milestones of the stack being built, which - the encoding puts in traversal order. What this does not yet handle are - the *generalised* milestones: between one word and the next the run passes - through the previous word cloned whole, and only then pops it down to - where the two words part. Getting that wrong shows up in both directions, - and the two cases below are kept as a record of it. + the encoding puts in traversal order, so the run only ever moves forward + through the tree. Measuring that walk gives three moves: a push to a left + child, a clone to a right child, and an ascent that clones at the *deepest* + node reached and then pops one letter per level, each node giving up its + own. A separator carries no letter, so it pops nothing. """ CLONE_ONLY = [('0', None, 'c', '1', 'clone')] @@ -865,9 +865,6 @@ def test_it_is_reflexive(self): assert self.holds(relations, relation, configuration, configuration) - @pytest.mark.xfail(reason="the generalised milestones are not handled: " - "the run clones the whole word before popping " - "it down to where the two words part") def test_a_word_shorter_than_its_clone_needs_pops(self): """The system can only clone and pop words, never letters — so the second word cannot be shorter than the first.""" @@ -878,3 +875,97 @@ def test_a_word_shorter_than_its_clone_needs_pops(self): relations, relation, Configuration('0', Stack((long_word,))), Configuration('1', Stack((long_word, short_word)))) + # the cloned copy itself, however, is one step away + assert self.holds(relations, relation, + Configuration('0', Stack((long_word,))), + Configuration('1', Stack((long_word, long_word)))) + + @pytest.mark.parametrize("case", ['clone only', 'clone, pop, push']) + def test_against_the_search(self, case): + rules = {'clone only': self.CLONE_ONLY, + 'clone, pop, push': [('0', None, 'c', '1', 'clone'), + ('1', 'a', 'p', '2', 'pop 1'), + ('2', None, 'u', '0', 'push b 1'), + ('0', None, 'k', '0', 'clone')]}[case] + system, relations, relation = self.relation(rules) + # a random walk alone rarely produces two stacks that are actually + # related, so grow the sample from each stack's clone and pops + stacks = walk_stacks(5, 6, width=2, height=3) + related = list(stacks) + for stack in stacks: + related += [stack.clone()] + \ + ([stack.pop2()] if stack.pop2() else []) + \ + ([stack.clone().pop1()] if stack.clone().pop1() else []) + seen, unique = set(), [] + for stack in related: + if repr(stack) not in seen and stack.width <= 3: + seen.add(repr(stack)) + unique.append(stack) + configurations = [Configuration(state, stack) for stack in unique + for state in system.states] + for source in configurations: + for target in configurations: + assert self.holds(relations, relation, source, target) == \ + d_oracle(system, source, target), (source, target) + + +class TestReach: + """Reachability itself: the composition of the four. + + Every run splits into words coming off, letters coming off, letters going + on and words going on, and all four relations are reflexive — so the + composition excludes nothing, and it is a first-order formula rather than + an automaton of its own. + """ + + CASES = { + 'clone and pop back': [('0', None, 'c', '1', 'clone'), + ('1', None, 'o', '0', 'pop 2')], + 'push and pop letters': [('0', None, 'u', '1', 'push a 1'), + ('1', 'a', 'p', '0', 'pop 1')], + } + + def search(self, system, source, bound=4000): + seen, frontier, steps = {source}, deque([source]), 0 + while frontier and steps < bound: + configuration = frontier.popleft() + steps += 1 + for _, successor in system.step(configuration): + if successor in seen: + continue + if successor.stack.width > 3 or \ + max(map(len, successor.stack.words)) > 4: + continue + seen.add(successor) + frontier.append(successor) + return seen + + @pytest.mark.parametrize("case", sorted(CASES)) + def test_against_the_search(self, case): + system = Level2CPS(self.CASES[case], symbols=SYMBOLS) + relations = Relations(system) + reach = relations.reach() + alphabet = frozenset(relations.encoding.alphabet) + configurations = [Configuration(state, stack) + for stack in walk_stacks(4, 5, width=2, height=3) + for state in system.states] + for source in configurations: + found = self.search(system, source) + for target in configurations: + got = reach.accepts(convolve_trees( + [encode_configuration(source), + encode_configuration(target)], alphabet, PAD)) + assert got == (target in found), (source, target) + + def test_it_is_reflexive_and_transitive(self): + """Two first-order questions about reachability — which is the whole + point of having it as a relation of the structure.""" + from autstr.tree_presentations import TreeAutomaticPresentation + system = Level2CPS(self.CASES['clone and pop back'], symbols=SYMBOLS) + relations = Relations(system) + structure = TreeAutomaticPresentation( + {'U': relations.encoding.universe(), + 'Reach': relations.reach()}, padding_symbol=PAD) + assert structure.check('all x.(Reach(x,x))') + assert structure.check('all x.(all y.(all z.(' + '(Reach(x,y) & Reach(y,z)) -> Reach(x,z))))') From 98d524b8fe256077b0be380cf48236e42c1bb5ac Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Thu, 13 Aug 2026 17:53:58 +0200 Subject: [PATCH 64/76] Reach_L, and reachability as a relation of the graph The last piece. Kartzow's regular reachability constrains a run by the labels it reads, and where he builds the label automaton into the reachability automaton, here it goes into a PRODUCT SYSTEM whose ordinary reachability is the answer. What is left is to say that the two configurations are the plain ones underneath -- same stack, control state tagged with the label automaton's initial state at one end and an accepting one at the other: Reach_L(x,y) = exists u,v. Tag_p0(x,u) & Reach(u,v) & (or_f Tag_f(y,v)) Both kinds of configuration share a structure for the length of that formula, which is why the encoding can carry root letters for a second system's states, and the alphabet is narrowed again once the tags are quantified away. An epsilon-contraction needs nothing further: it is the case of any number of silent labels followed by one other. `Reach` is now a relation of the configuration graph like any other, so a first-order formula may ask about runs of any length -- the question `autstr.turing` cannot answer, and the reason these graphs are on the tree engine at all. It is declared from the start and built the first time a query mentions it, since it is exponential in the number of control states; that deferral is the opt-out, and no flag is needed. `reach_along` installs a label-constrained version under a name of the caller's choosing. Checked against a search that tracks the labels it reads, on four constraints: an exact word, a two-letter word, everything (which is shown to give back plain reachability), and the contraction shape. The graph's own reachability is checked to be reflexive, transitive, and to contain the edge relation. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- autstr/collapsible.py | 65 ++++++++++-- autstr/collapsible_reach.py | 175 ++++++++++++++++++++++++++++++-- tests/test_collapsible_reach.py | 94 ++++++++++++++++- 4 files changed, 313 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index bf6159a..cfeb484 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ batch: | `autstr.arithmetic`, `autstr.buildin` | Presburger and Büchi arithmetic (ℤ, +, <, \|₂), Skolem arithmetic (ℕ, ·), the MSO0 finite-powerset structure | | `autstr.algebra`, `autstr.tree_algebra` | the localizations **ℤ[1/p]**, finite **Boolean algebras**, and the countable **atomless Boolean algebra** | | `autstr.infinite_graphs`, `autstr.ordinals`, `autstr.turing` | the **integer grid** ℤⁿ, the **regular tree** T_k, the **ordinals** below ω^ω and — on the tree engine — below ω^(ω^ω), **Turing-machine configuration graphs** | -| `autstr.collapsible` | **level 2 collapsible pushdown graphs** — tree-automatic by Kartzow's encoding, and the one automatic route to them, since their MSO theory is undecidable | +| `autstr.collapsible`, `autstr.collapsible_reach` | **level 2 collapsible pushdown graphs** — tree-automatic by Kartzow's encoding, and the one automatic route to them, since their MSO theory is undecidable. **Reachability** is a relation of the graph, so first-order formulas may ask about runs of any length — the question a Turing machine's configuration graph cannot answer | **Classes** — one automaton for a whole family, indexed by advice: diff --git a/autstr/collapsible.py b/autstr/collapsible.py index b5428c9..aadfc2b 100644 --- a/autstr/collapsible.py +++ b/autstr/collapsible.py @@ -45,15 +45,24 @@ **Reachability, and the contrast with Turing machines.** For a level 2 collapsible pushdown graph the reachability relation is itself tree-automatic -(Kartzow, Prop. 5.1), so first-order logic *with* reachability is decidable -here — the very question that `autstr.turing` cannot answer, since for a -configuration graph of a Turing machine reachability is the halting problem. -That construction is not implemented: it is exponential in the number of -control states and takes up the bulk of the paper, whereas everything below is -elementary. This module presents the one-step relation, over *all* -configurations rather than only those reachable from an initial one — the same -reading Kartzow's result takes, and the only one available without the -reachability construction. +(Kartzow, Prop. 5.1), and it is built here — ``Reach`` is a relation of the +graph like any other, so a first-order formula may ask about runs of any +length. That is the very question `autstr.turing` cannot answer, since for a +configuration graph of a Turing machine reachability is the halting problem. It +is also the reason these graphs belong on the tree engine at all:: + + >>> 'Reach' in graph.get_relation_symbols() + True + +`reach_along` gives the sharper version: reachability along runs whose labels a +finite automaton accepts, which is Kartzow's ``Reach_L`` and covers the +ε-contraction of the graph as the case of any number of silent labels followed +by one other. The construction is in `autstr.collapsible_reach`; it is +exponential in the number of control states, so ``Reach`` is declared but not +built until a query asks for it. + +Configurations are *all* of them, not only those reachable from an initial +one — the same reading Kartzow's result takes. References: @@ -354,14 +363,19 @@ class _Encoding: ACCEPT = 'accept' def __init__(self, states: Sequence[str], symbols: Sequence[str], - bottom: str) -> None: + bottom: str, extra_states: Sequence[str] = ()) -> None: self.bottom_label = f'{bottom}:1' #: the labels of letter nodes: the bottom symbol carries a level 1 #: link and never occurs anywhere but at the bottom self.letters = [self.bottom_label] + [ f'{symbol}:{level}' for symbol in symbols if symbol != bottom for level in (1, 2)] - self.states = [f'<{state}>' for state in states] + # `extra_states` are root letters for configurations of another + # system over the same stacks — a product with a label automaton, say, + # which has to be related to the plain configurations tree by tree + self.states = [f'<{state}>' for state in states] + \ + [f'<{state}>' for state in extra_states + if state not in set(states)] #: everything that can label a node of a stack tree self.nodes = self.letters + [SEP] self.alphabet = set(self.nodes) | set(self.states) | {PAD} @@ -863,6 +877,10 @@ def __init__(self, system: Level2CPS, 'Pop1': encoding.pop1, 'Pop2': encoding.pop2, 'Collapse': self._collapse, + # reachability is in the signature from the start, but the + # automaton is exponential in the number of control states, so it + # is built the first time a query actually asks for it + 'Reach': self._reach, **{self.push_names[symbol, level]: (lambda symbol=symbol, level=level: encoding.push(symbol, level)) @@ -904,6 +922,31 @@ def _state_automaton(self, state: str): return partial_tree_automaton(encoding.alphabet, 1, table, {encoding.ACCEPT}) + def _reach(self): + """Reachability: a run of any length between two configurations. + + Decidable here, and *not* over a Turing machine's configuration graph, + where the same question is halting — the difference this whole module + exists to show. The construction is Kartzow's; see + `autstr.collapsible_reach`. + """ + from autstr.collapsible_reach import Relations + return Relations(self.system).reach() + + def reach_along(self, name: str, labels) -> None: + """Install ``Reach_L``: reachability along runs whose labels the + given `autstr.collapsible_reach.LabelAutomaton` accepts. + + Plain reachability is the case where every label word is allowed, and + an ε-contraction is the case of any number of silent labels followed by + one other — so this one relation covers both. + + :param name: the relation symbol to install it under. + :param labels: which sequences of labels a run may read. + """ + from autstr.collapsible_reach import regular_reach + self.presentation.update(**{name: regular_reach(self.system, labels)}) + def _collapse(self): """``collapse``, at either link level: on a level 2 link the stack is cut back to the recorded width, and on a level 1 link — which always diff --git a/autstr/collapsible_reach.py b/autstr/collapsible_reach.py index a7007bd..0f7a103 100644 --- a/autstr/collapsible_reach.py +++ b/autstr/collapsible_reach.py @@ -1,8 +1,28 @@ -"""Returns and loops of a level 2 collapsible pushdown system. - -Reachability in a collapsible pushdown graph is built out of four kinds of run -(Kartzow 2013, §4), all of them concerned with what a stack can do *before* it -drops below where it started: +"""Reachability in a level 2 collapsible pushdown graph. + +Whether one configuration can reach another is decidable here, and that is the +point of the whole encoding: over a Turing machine's configuration graph the +same question is halting. Kartzow proves the relation tree-automatic; this +builds it. + +**Reach = A ; B ; C ; D.** Every run splits into four stretches — words come +off the stack, then letters, then letters go back on, then words — and all four +relations are reflexive, so the composition excludes nothing. Reachability is +therefore a first-order formula over the four rather than an automaton of its +own, and `Relations.reach` writes it out. `regular_reach` gives Kartzow's +sharper ``Reach_L``, along runs whose labels a finite automaton accepts, by +building that automaton into a product system; an ε-contraction is the case of +any number of silent labels followed by one other. + +Each of the four is checked on four tapes: the two configurations, the summary +annotation of one of them, and a guessed control state per node. The annotation +is what lets a bottom-up automaton consult a computation that runs top-down — +a node carries the summary of the word read from the root down to it, which is +a local check — and both scaffolding tapes are quantified away afterwards. + +**The summaries** are what all of that rests on. Four kinds of run matter +(Kartzow 2013, §4), all concerned with what a stack can do *before* it drops +below where it started: * a **return** takes a stack to the one below it, * a **loop** takes a stack back to itself, and splits into a **high loop**, @@ -52,11 +72,13 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Dict, FrozenSet, Optional, Tuple +from typing import Dict, FrozenSet, Optional, Sequence, Tuple from autstr.collapsible import PAD, SEP, Level2CPS from autstr.sparse_tree_automata import SparseTreeAutomaton, Tree -from autstr.utils.tree_automata_tools import partial_tree_automaton +from autstr.utils.tree_automata_tools import ( + minimize, partial_tree_automaton, +) #: a relation on control states Relation = FrozenSet[Tuple[str, str]] @@ -526,11 +548,12 @@ class Relations: #: regions would otherwise want the same entry BURIED = '@buried' - def __init__(self, system: Level2CPS, summaries: Optional[Summaries] = None - ) -> None: + def __init__(self, system: Level2CPS, summaries: Optional[Summaries] = None, + extra_states: Sequence[str] = ()) -> None: from autstr.collapsible import _Encoding self.system = system - self.encoding = _Encoding(system.states, system.symbols, system.bottom) + self.encoding = _Encoding(system.states, system.symbols, system.bottom, + extra_states) self.summaries = summaries or Summaries(system) self.annotation = Annotation(self.encoding, self.summaries) # a letter the run drops carries the states it is in before and after @@ -1273,3 +1296,135 @@ def reach(self) -> SparseTreeAutomaton: return scratch.evaluate( 'exists u.(exists v.(exists w.(' 'A(x,u) & B(u,v) & C(v,w) & D(w,y))))') + + +# ---------------------------------------------------------------------- +# reachability along a regular set of label words +# ---------------------------------------------------------------------- + +@dataclass(frozen=True) +class LabelAutomaton: + """A finite automaton over a system's edge labels. + + It says which sequences of labels a run may read, which is what turns + plain reachability into Kartzow's regular reachability ``Reach_L``. + + :param transitions: ``(state, label, state)`` triples; several may share a + state and label, so the automaton need not be deterministic. + :param initial: the state a run starts in. + :param final: the states a run may end in. + """ + transitions: Tuple[Tuple[str, str, str], ...] + initial: str + final: FrozenSet[str] + + @staticmethod + def of_word(labels) -> 'LabelAutomaton': + """Runs reading exactly this sequence of labels.""" + steps = tuple((f'p{index}', label, f'p{index + 1}') + for index, label in enumerate(labels)) + return LabelAutomaton(steps, 'p0', frozenset({f'p{len(steps)}'})) + + @staticmethod + def anything(labels) -> 'LabelAutomaton': + """Runs reading any sequence at all — plain reachability.""" + return LabelAutomaton(tuple(('p', label, 'p') for label in labels), + 'p', frozenset({'p'})) + + @staticmethod + def contracting(labels, silent) -> 'LabelAutomaton': + """Runs reading any number of `silent` labels and then one other — + the step relation of an ε-contraction.""" + steps = [('p', label, 'p') for label in silent] + steps += [('p', label, 'q') for label in labels if label not in silent] + return LabelAutomaton(tuple(steps), 'p', frozenset({'q'})) + + @property + def states(self): + found = {self.initial} | set(self.final) + for source, _, target in self.transitions: + found |= {source, target} + return sorted(found) + + +def product(system: Level2CPS, labels: LabelAutomaton) -> Level2CPS: + """The system whose runs are those of `system` whose labels `labels` + accepts, its control state carrying both. + + Kartzow builds the label automaton into the reachability automaton + directly; as a product system it is the same thing said once, and it costs + nothing beyond the states multiplying. + """ + rules = [] + for rule in system.rules: + for source, label, target in labels.transitions: + if label != rule.label: + continue + rules.append((f'{rule.state}|{source}', rule.symbol, rule.label, + f'{rule.target}|{target}', rule.operation)) + return Level2CPS(rules, bottom=system.bottom, + initial_state=f'{system.initial_state}|{labels.initial}', + states=[f'{state}|{other}' for state in system.states + for other in labels.states], + symbols=system.symbols) + + +def relabel_root(encoding, source: str, target: str) -> SparseTreeAutomaton: + """Two configurations with the same stack, whose control states are the + two given ones — the trees agree everywhere but at the root.""" + table = {} + for label in encoding.nodes: + for left in (None, 'same'): + for right in (None, 'same'): + table[(left, right, (label, label))] = 'same' + table[('same', None, (f'<{source}>', f'<{target}>'))] = 'accept' + return partial_tree_automaton(encoding.alphabet, 2, table, {'accept'}) + + +def regular_reach(system: Level2CPS, labels: LabelAutomaton, + summaries: Optional[Summaries] = None + ) -> SparseTreeAutomaton: + """``Reach_L``: reachability along runs whose labels `labels` accepts. + + The label automaton goes into a product system, whose reachability is the + ordinary one; what remains is to say that the two configurations are the + plain ones underneath — same stack, control state tagged with the label + automaton's initial state at one end and an accepting one at the other:: + + Reach_L(x,y) ≡ ∃u ∃v. Tag_{p₀}(x,u) ∧ Reach(u,v) ∧ ⋁_f Tag_f(y,v) + + Both kinds of configuration live in the same structure for the length of + that formula, which is why the encoding carries root letters for both, and + the alphabet is narrowed again once the tags are quantified away. + """ + from autstr.tree_presentations import TreeAutomaticPresentation + from autstr.utils.tree_automata_tools import restrict_alphabet + both = product(system, labels) + relations = Relations(both, summaries, extra_states=system.states) + encoding = relations.encoding + + tags, names = {}, {} + for index, state in enumerate(labels.states): + pairs = [relabel_root(encoding, plain, f'{plain}|{state}') + for plain in system.states] + joined = pairs[0] + for other in pairs[1:]: + joined = minimize(joined.union(other)) + names[state] = f'Tag{index}' + tags[f'Tag{index}'] = joined + + scratch = TreeAutomaticPresentation( + {'U': encoding.universe(), 'Reach': relations.reach(), **tags}, + padding_symbol=PAD) + accepting = ' | '.join(f'{names[state]}(y,v)' for state in + sorted(labels.final) if state in names) + result = scratch.evaluate( + f'exists u.(exists v.({names[labels.initial]}(x,u) & Reach(u,v) ' + f'& ({accepting})))') + plain = _Encoding_of(system) + return restrict_alphabet(result, plain.alphabet) + + +def _Encoding_of(system: Level2CPS): + from autstr.collapsible import _Encoding + return _Encoding(system.states, system.symbols, system.bottom) diff --git a/tests/test_collapsible_reach.py b/tests/test_collapsible_reach.py index e148235..3c82ad2 100644 --- a/tests/test_collapsible_reach.py +++ b/tests/test_collapsible_reach.py @@ -21,7 +21,10 @@ Configuration, Letter, Level2CPS, Operation, PAD, Stack, _Encoding, encode_configuration, initial_stack, ) -from autstr.collapsible_reach import Annotation, Relations, Summaries +from autstr.collapsible_reach import ( + Annotation, LabelAutomaton, Relations, Summaries, _Encoding_of, + regular_reach, +) from autstr.sparse_tree_automata import Tree, convolve_trees @@ -969,3 +972,92 @@ def test_it_is_reflexive_and_transitive(self): assert structure.check('all x.(Reach(x,x))') assert structure.check('all x.(all y.(all z.(' '(Reach(x,y) & Reach(y,z)) -> Reach(x,z))))') + + +class TestRegularReach: + """``Reach_L`` — reachability along runs whose labels a finite automaton + accepts. The automaton goes into a product system, whose plain + reachability is the answer; what is left is to say that the two + configurations are the untagged ones underneath.""" + + RULES = [('0', None, 'c', '1', 'clone'), + ('1', None, 'o', '0', 'pop 2')] + + def accepts(self, labels, word): + current = {labels.initial} + for letter in word: + current = {target for source, label, target in labels.transitions + if source in current and label == letter} + return bool(current & labels.final) + + def search(self, system, source, labels, bound=2000): + found, seen = set(), {(source, ())} + frontier, steps = deque([(source, ())]), 0 + while frontier and steps < bound: + configuration, word = frontier.popleft() + steps += 1 + if self.accepts(labels, word): + found.add(configuration) + for label, successor in system.step(configuration): + longer = word + (label,) + if len(longer) > 3 or (successor, longer) in seen: + continue + if successor.stack.width > 3 or \ + max(map(len, successor.stack.words)) > 3: + continue + seen.add((successor, longer)) + frontier.append((successor, longer)) + return found + + def cases(self, system): + return { + 'one clone': LabelAutomaton.of_word(['c']), + 'a clone then a pop': LabelAutomaton.of_word(['c', 'o']), + 'anything at all': LabelAutomaton.anything(system.labels), + 'clones are silent': + LabelAutomaton.contracting(system.labels, {'c'}), + } + + @pytest.mark.parametrize("case", ['one clone', 'a clone then a pop', + 'anything at all', 'clones are silent']) + def test_against_the_search(self, case): + system = Level2CPS(self.RULES, symbols=SYMBOLS) + labels = self.cases(system)[case] + relation = regular_reach(system, labels) + alphabet = frozenset(_Encoding_of(system).alphabet) + configurations = [Configuration(state, stack) + for stack in walk_stacks(6, 4, width=2, height=2) + for state in system.states] + for source in configurations: + found = self.search(system, source, labels) + for target in configurations: + got = relation.accepts(convolve_trees( + [encode_configuration(source), + encode_configuration(target)], alphabet, PAD)) + assert got == (target in found), (case, source, target) + + def test_anything_at_all_is_plain_reachability(self): + """The label automaton that accepts everything gives back the + relation `Relations.reach` builds on its own.""" + from autstr.utils.tree_automata_tools import equivalent + system = Level2CPS(self.RULES, symbols=SYMBOLS) + constrained = regular_reach( + system, LabelAutomaton.anything(system.labels)) + assert equivalent(constrained, Relations(system).reach()) + + def test_the_graph_declares_reachability(self): + """It is in the signature from the start, and built when asked for.""" + system = Level2CPS(self.RULES, symbols=SYMBOLS) + graph = system.configuration_graph() + assert 'Reach' in graph.get_relation_symbols() + assert graph.check('all x.(Reach(x,x))') + assert graph.check('all x.(all y.(all z.(' + '(Reach(x,y) & Reach(y,z)) -> Reach(x,z))))') + assert graph.check('all x.(all y.(E(x,y) -> Reach(x,y)))') + + def test_the_graph_takes_a_label_constraint(self): + system = Level2CPS(self.RULES, symbols=SYMBOLS) + graph = system.configuration_graph() + graph.reach_along('Cloned', LabelAutomaton.of_word(['c'])) + # one clone is one step, so this is the c-labelled edge relation + assert graph.check('all x.(all y.(Cloned(x,y) <-> Edgec(x,y)))') From ff3048b99b51c95a1112aac35c9e5ca332f76ddd Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Thu, 13 Aug 2026 18:21:59 +0200 Subject: [PATCH 65/76] The rule table is a literal block, not a definition list Its lead-in ended in an em dash, so docutils read the aligned rules as a definition list whose last two terms lack a definition, and the docs build warned. `::` is what the rest of the module already uses. Co-Authored-By: Claude Opus 5 --- autstr/collapsible_reach.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/autstr/collapsible_reach.py b/autstr/collapsible_reach.py index 0f7a103..fef65ac 100644 --- a/autstr/collapsible_reach.py +++ b/autstr/collapsible_reach.py @@ -43,7 +43,7 @@ through µ-calculus model checking on collapsible pushdown graphs, which is a decision procedure of its own. It is not needed: the decomposition lemmas the paper proves are already a closed system of rules, each saying how one kind of -run is built from shorter ones — +run is built from shorter ones:: return = high loop, then a pop, or a drop into the word below followed by a return of it, or a pushed level 2 letter @@ -54,7 +54,7 @@ 1-loop = loop, clone, loop, clone, … loop = high loop, or high loop then low loop then high loop -— and the least fixpoint of those rules is the summary. The rules refer to the +The least fixpoint of those rules is the summary. The rules refer to the summaries of *longer* words, which is why this is one simultaneous fixpoint over the whole table rather than an induction on word length. From 3a2e0e13b8169044541d4edd47f9b09f7570f554 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Thu, 13 Aug 2026 20:35:34 +0200 Subject: [PATCH 66/76] Every structure declares its own vocabulary; dissolve `buildin` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that turn out to be one. `.symbolic()` could be called with no arguments on nine of the library's structures, because each declares a `default_signature` naming its operators and the codec that turns Python values into elements. It could not on Büchi arithmetic or MSO0: those were built by plain functions returning a bare presentation, with nowhere to hang a signature. `autstr.arithmetic` existed to fill that gap -- for ONE of the three, which is what made it look like a second, rival interface. So the signature moves onto the presentation, where the rest of the library keeps it, and the module that worked around its absence goes away. All three gain their codec, verified against the automata rather than assumed: - N: binary, least significant bit first (checked against A(x,y,z)), - Z: sign symbol then magnitude (as before), - MSO0: a set as a bitmask, position i set iff i is a member, in CANONICAL form -- the universe rejects trailing zeros, so {0} is `1` and the empty set is the empty word (checked against Subset over all subsets of {0,1,2}). MSO0 therefore gains an interface it never had: union, intersection and difference as `+`, `*`, `-`, and solutions that come back as Python sets. `buildin` was the other half of the same confusion: every structure the library ships is built in, so the name carved nothing. Its contents move to where their subject already lives -- `arithmetic` (Buechi N and Z), `powerset` (MSO0), `tree_arithmetic` (Skolem, matching algebra/tree_algebra), and the generic automaton constructors to `utils.automata_tools`, which was already importing `one` from across the package boundary. `CompiledPresentation` holds the part the presentations share. The precompiled `.autstr` artifacts go too. They saved 0.03s, 0.23s and 0.44s against building from scratch, and the built presentations are identical to the loaded ones relation by relation. Serialization stays fully supported; it just stops shipping artifacts nobody needed. BREAKING: `autstr.buildin.*` is gone, as are `autstr.arithmetic.integers` and `signature`; encode/decode are now classmethods on the presentations. Co-Authored-By: Claude Opus 5 --- .github/workflows/publish.yaml | 2 +- autstr/arithmetic.py | 398 ++++++++++--- autstr/buildin/__init__.py | 0 autstr/buildin/automata.py | 138 ----- autstr/buildin/bin/buechi.autstr | Bin 12816 -> 0 bytes autstr/buildin/bin/buechiZ.autstr | Bin 102128 -> 0 bytes autstr/buildin/bin/mso0.autstr | Bin 70412 -> 0 bytes autstr/buildin/presentations.py | 522 ------------------ autstr/infinite_graphs.py | 8 +- autstr/ordinals.py | 2 +- autstr/powerset.py | 319 +++++++++++ autstr/presentations.py | 25 +- autstr/symbolic/backends.py | 2 +- autstr/tree_algebra.py | 2 +- ...ee_presentations.py => tree_arithmetic.py} | 23 +- autstr/uniform.py | 5 +- autstr/utils/automata_tools.py | 209 ++++++- autstr/utils/tree_automata_tools.py | 2 +- docs/source/overview.md | 22 +- notebooks/arithmetic_and_algebra.ipynb | 16 +- pyproject.toml | 2 - scripts/gen_builtin_presentations.py | 43 -- scripts/gen_readme_media.py | 4 +- tests/test_arithmetic.py | 83 ++- tests/test_infinite_graphs.py | 6 +- tests/test_interpretations.py | 6 +- tests/test_ordinals.py | 2 +- tests/test_presentations.py | 7 +- tests/test_serialization.py | 2 +- tests/test_skolem.py | 2 +- tests/test_symbolic.py | 12 +- tests/test_symbolic_tree.py | 2 +- tests/test_tree_interpretations.py | 2 +- tests/test_tree_presentations.py | 2 +- 34 files changed, 1034 insertions(+), 836 deletions(-) delete mode 100644 autstr/buildin/__init__.py delete mode 100644 autstr/buildin/automata.py delete mode 100644 autstr/buildin/bin/buechi.autstr delete mode 100644 autstr/buildin/bin/buechiZ.autstr delete mode 100644 autstr/buildin/bin/mso0.autstr delete mode 100644 autstr/buildin/presentations.py create mode 100644 autstr/powerset.py rename autstr/{buildin/tree_presentations.py => tree_arithmetic.py} (91%) delete mode 100644 scripts/gen_builtin_presentations.py diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 40e5592..3e59519 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -63,7 +63,7 @@ jobs: /tmp/smoke/bin/pip install --quiet dist/*.whl /tmp/smoke/bin/python -c " import autstr - from autstr.buildin.presentations import BuechiArithmeticZ + from autstr.arithmetic import BuechiArithmeticZ assert BuechiArithmeticZ().check('exists x.(A(x,x,x))') print('smoke test passed for', autstr.__version__) " diff --git a/autstr/arithmetic.py b/autstr/arithmetic.py index d33a0f9..6f67499 100644 --- a/autstr/arithmetic.py +++ b/autstr/arithmetic.py @@ -1,85 +1,335 @@ -"""Büchi arithmetic over the integers, as a symbolic structure. +"""Büchi arithmetic, over the naturals and over the integers. -:math:`(\\mathbb{Z}, +, <, \\mid_2)` presented in base 2, with the generic -symbolic interface of `autstr.symbolic` on top: build terms and formulas with -Python operators, then evaluate, model check, test for emptiness or -finiteness, and enumerate solutions. +:math:`(\\mathbb{N}, +, <, \\mid_2)` and :math:`(\\mathbb{Z}, +, <, \\mid_2)` +presented in base 2, where ``x \\mid_2 y`` says that `y` is a power of two +dividing `x`. Adding that predicate to Presburger arithmetic is what makes the +structure *Büchi* arithmetic: it can talk about the binary expansion of a +number, and it remains decidable. - >>> Z = integers() + >>> Z = BuechiArithmeticZ().symbolic() >>> x, y, z = Z.vars("x y z") - >>> phi = (x + y).eq(z) & z.lt(100) - >>> phi.check() + >>> ((x + y).eq(z) & z.lt(100)).check() True - >>> phi.evaluate().contains(x=3, y=4, z=7) - True - -Integers are written directly wherever a term is expected -- ``x + 5``, -``x.lt(100)`` -- and solutions come back as Python integers. -The signature is that of the underlying presentation: ``A`` (addition graph), -``Lt``/``Gt``/``Eq``, ``Neg`` (the graph of negation), ``N0`` (non-negative), -``Z`` (zero), ``Pt`` (powers of two) and ``B``, where ``B(x, y)`` holds iff -``y`` is a power of two dividing ``x``. Anything the operators do not cover is -reachable through `SymbolicContext.rel`. +Both presentations are compiled from a handful of small automata the first time +they are constructed; the derived relations (order, equality, negation) are +first-order definitions over those. """ -from __future__ import annotations - -from functools import lru_cache from typing import List -from autstr.buildin.presentations import BuechiArithmeticZ -from autstr.symbolic import FunctionCodec, Signature, SymbolicContext - -#: Base-2 encoding: a sign symbol followed by the magnitude, least significant -#: bit first. This is the convention of `lsbf_Z_automaton`. -SIGN_POSITIVE = '0' -SIGN_NEGATIVE = '1' -PADDING = '*' - - -def encode(n: int) -> List[str]: - """The word encoding an integer: sign symbol, then magnitude bits LSB - first.""" - sign = SIGN_POSITIVE if n >= 0 else SIGN_NEGATIVE - return [sign] + list(format(abs(n), 'b')[::-1]) - - -def decode(word) -> int: - """The integer encoded by a word, ignoring padding.""" - word = ''.join(word).replace(PADDING, '') - if not word: - raise ValueError("empty encoding") - magnitude = int(word[1:][::-1] or '0', base=2) - return magnitude if word[0] == SIGN_POSITIVE else -magnitude - - -def signature() -> Signature: - """The signature of Büchi arithmetic: ``+`` and unary ``-`` as functions, - the order and equality as relational methods, and the integer codec.""" - sig = Signature(codec=FunctionCodec(encode, decode)) - sig.function('+', graph='A', out=2) - sig.function('neg', graph='Neg', out=1) - sig.operator('+', '+') - sig.operator('-', 'neg') - sig.operator('eq', 'Eq') - sig.operator('lt', 'Lt') - sig.operator('gt', 'Gt') - # B(x, y): y is a power of two dividing x. Spelled out as a method rather - # than bound to `|`, which on formulas already means union. - sig.operator('divided_by_power', 'B') - return sig - - -@lru_cache(maxsize=1) -def integers() -> SymbolicContext: - """The symbolic interface to Büchi arithmetic over :math:`\\mathbb{Z}`. - - The presentation is built once and shared; expressions are immutable, so - sharing it is safe and avoids rebuilding the base automata per term. +import itertools as it + +from autstr.presentations import ( + AutomaticPresentation, CompiledPresentation, +) +from autstr.utils.automata_tools import create_sparse_dfa + + +def _build_buechi_arithmetic() -> AutomaticPresentation: + """Büchi arithmetic over the natural numbers, compiled from scratch.""" + # Universe automaton - only accepts valid binary numbers (no '*') + universe = create_sparse_dfa( + states={'i', '0', '0+', '1', '*'}, + input_symbols={('0',), ('1',), ('*',)}, + transitions={ + 'i': {('0',): '0', ('1',): '1', ('*',): '*'}, + '0': {('0',): '0+', ('1',): '1', ('*',): '*'}, + '0+': {('0',): '0+', ('1',): '1', ('*',): '*'}, + '1': {('0',): '0+', ('1',): '1', ('*',): '*'}, + '*': {('0',): '*', ('1',): '*', ('*',): '*'}, + }, + initial_state='i', + final_states={'0', '1'} + ) + + addition = create_sparse_dfa( + states={0, 1, 2}, + input_symbols={('0', '0', '0'), ('0', '0', '1'), ('0', '0', '*'), ('0', '1', '0'), ('0', '1', '1'), + ('0', '1', '*'), ('0', '*', '0'), ('0', '*', '1'), ('0', '*', '*'), + ('1', '0', '0'), ('1', '0', '1'), ('1', '0', '*'), ('1', '1', '0'), ('1', '1', '1'), + ('1', '1', '*'), ('1', '*', '0'), ('1', '*', '1'), ('1', '*', '*'), + ('*', '0', '0'), ('*', '0', '1'), ('*', '0', '*'), ('*', '1', '0'), ('*', '1', '1'), + ('*', '1', '*'), ('*', '*', '0'), ('*', '*', '1'), ('*', '*', '*'), + }, + transitions={ + 0: { + ('0', '0', '0'): 0, ('0', '0', '1'): 2, ('0', '0', '*'): 2, ('0', '1', '0'): 2, ('0', '1', '1'): 0, + ('0', '1', '*'): 2, ('0', '*', '0'): 0, ('0', '*', '1'): 2, ('0', '*', '*'): 2, + ('1', '0', '0'): 2, ('1', '0', '1'): 0, ('1', '0', '*'): 2, ('1', '1', '0'): 1, ('1', '1', '1'): 2, + ('1', '1', '*'): 2, ('1', '*', '0'): 2, ('1', '*', '1'): 0, ('1', '*', '*'): 2, + ('*', '0', '0'): 0, ('*', '0', '1'): 2, ('*', '0', '*'): 2, ('*', '1', '0'): 2, ('*', '1', '1'): 0, + ('*', '1', '*'): 2, ('*', '*', '0'): 2, ('*', '*', '1'): 2, ('*', '*', '*'): 2, + }, + 1: { + ('0', '0', '0'): 2, ('0', '0', '1'): 0, ('0', '0', '*'): 2, ('0', '1', '0'): 1, ('0', '1', '1'): 2, + ('0', '1', '*'): 2, ('0', '*', '0'): 2, ('0', '*', '1'): 0, ('0', '*', '*'): 2, + ('1', '0', '0'): 1, ('1', '0', '1'): 2, ('1', '0', '*'): 2, ('1', '1', '0'): 2, ('1', '1', '1'): 1, + ('1', '1', '*'): 2, ('1', '*', '0'): 1, ('1', '*', '1'): 2, ('1', '*', '*'): 2, + ('*', '0', '0'): 2, ('*', '0', '1'): 0, ('*', '0', '*'): 2, ('*', '1', '0'): 1, ('*', '1', '1'): 2, + ('*', '1', '*'): 2, ('*', '*', '0'): 2, ('*', '*', '1'): 0, ('*', '*', '*'): 2, + }, + 2: { + ('0', '0', '0'): 2, ('0', '0', '1'): 2, ('0', '0', '*'): 2, ('0', '1', '0'): 2, ('0', '1', '1'): 2, + ('0', '1', '*'): 2, ('0', '*', '0'): 2, ('0', '*', '1'): 2, ('0', '*', '*'): 2, + ('1', '0', '0'): 2, ('1', '0', '1'): 2, ('1', '0', '*'): 2, ('1', '1', '0'): 2, ('1', '1', '1'): 2, + ('1', '1', '*'): 2, ('1', '*', '0'): 2, ('1', '*', '1'): 2, ('1', '*', '*'): 2, + ('*', '0', '0'): 2, ('*', '0', '1'): 2, ('*', '0', '*'): 2, ('*', '1', '0'): 2, ('*', '1', '1'): 2, + ('*', '1', '*'): 2, ('*', '*', '0'): 2, ('*', '*', '1'): 2, ('*', '*', '*'): 2, + }, + }, + initial_state=0, + final_states={0} + ) + + input_symbols = {a for a in it.product(['0', '1', '*'], repeat=2)} + weak_div = create_sparse_dfa( + states={'0', '1', 'e'}, + input_symbols=input_symbols, + transitions={ + '0': { + a: '0' if a == ('0', '0') else '1' if a == ('0', '1') or a == ('1', '1') else 'e' for a in input_symbols + }, + '1': { + a: 'e' if a[1] != '*' else '1' for a in input_symbols + }, + 'e': {a: 'e' for a in input_symbols} + }, + initial_state='0', + final_states={'1'} + ) + + + # Create presentation + presentation = AutomaticPresentation({'U': universe, 'A': addition, 'B': weak_div}) + + # Add bootstrap remaining relations + presentation.update(Z='A(x,x,x)') + presentation.update(Eq='exists z.(Z(z) and A(x,z,y))') + presentation.update(Pt='B(x,x)') + presentation.update(Lt='exists z.(not Z(z) and A(x, z, y))') + presentation.update(Gt='exists z.(not Z(z) and A(y, z, x))') + + return presentation + +def _build_buechi_arithmetic_Z() -> AutomaticPresentation: + """Büchi arithmetic over the integers, compiled from scratch.""" + # Universe automaton + universe_states = ['-1', 'i+', 'i', '0', '0+', '1', '*'] + universe_symbols = {('0',), ('1',), ('*',)} + universe_trans = { + '-1': {('0',): 'i', ('1',): 'i+', ('*',): '*'}, + 'i+': {('0',): '0+', ('1',): '1', ('*',): '*'}, + 'i': {('0',): '0', ('1',): '1', ('*',): '*'}, + '0': {('0',): '0+', ('1',): '1', ('*',): '*'}, + '0+': {('0',): '0+', ('1',): '1', ('*',): '*'}, + '1': {('0',): '0+', ('1',): '1', ('*',): '*'}, + '*': {('0',): '*', ('1',): '*', ('*',): '*'}, + } + universe = create_sparse_dfa( + universe_states, universe_symbols, universe_trans, '-1', {'0', '1'} + ) + + # Addition automaton (intermediate) + add_states = [-1, 0, 1, 2] + add_symbols = set(it.product(['0', '1', '*'], repeat=3)) + add_trans = { + -1: {a: 0 if '*' not in a else 2 for a in add_symbols}, + 0: { + ('0','0','0'): 0, ('0','0','1'): 2, ('0','0','*'): 2, + ('0','1','0'): 2, ('0','1','1'): 0, ('0','1','*'): 2, + ('0','*','0'): 0, ('0','*','1'): 2, ('0','*','*'): 2, + ('1','0','0'): 2, ('1','0','1'): 0, ('1','0','*'): 2, + ('1','1','0'): 1, ('1','1','1'): 2, ('1','1','*'): 2, + ('1','*','0'): 2, ('1','*','1'): 0, ('1','*','*'): 2, + ('*','0','0'): 0, ('*','0','1'): 2, ('*','0','*'): 2, + ('*','1','0'): 2, ('*','1','1'): 0, ('*','1','*'): 2, + ('*','*','0'): 2, ('*','*','1'): 2, ('*','*','*'): 2, + }, + 1: { + ('0','0','0'): 2, ('0','0','1'): 0, ('0','0','*'): 2, + ('0','1','0'): 1, ('0','1','1'): 2, ('0','1','*'): 2, + ('0','*','0'): 2, ('0','*','1'): 0, ('0','*','*'): 2, + ('1','0','0'): 1, ('1','0','1'): 2, ('1','0','*'): 2, + ('1','1','0'): 2, ('1','1','1'): 1, ('1','1','*'): 2, + ('1','*','0'): 1, ('1','*','1'): 2, ('1','*','*'): 2, + ('*','0','0'): 2, ('*','0','1'): 0, ('*','0','*'): 2, + ('*','1','0'): 1, ('*','1','1'): 2, ('*','1','*'): 2, + ('*','*','0'): 2, ('*','*','1'): 0, ('*','*','*'): 2, + }, + 2: {s: 2 for s in add_symbols} + } + addition_intermediate = create_sparse_dfa(add_states, add_symbols, add_trans, -1, {0}) + + # Weak division automaton + div_states = ['-1', '0', '1', 'e'] + div_symbols = set(it.product(['0', '1', '*'], repeat=2)) + div_trans = { + '-1': {a: '0' if a[1] == '0' else 'e' for a in div_symbols}, + '0': { + a: '0' if a == ('0','0') + else '1' if a in {('0','1'), ('1','1')} + else 'e' for a in div_symbols + }, + '1': { + a: 'e' if a[1] != '*' else '1' for a in div_symbols + }, + 'e': {a: 'e' for a in div_symbols} + } + weak_div = create_sparse_dfa(div_states, div_symbols, div_trans, '-1', {'1'}) + + # N0 automaton + n0_states = [-1, 0, 1] + n0_symbols = {('0',), ('1',), ('*',)} + n0_trans = { + -1: {('0',): 1, ('1',): 0, ('*',): 0}, + 0: {s: 0 for s in n0_symbols}, + 1: {s: 1 for s in n0_symbols} + } + N0 = create_sparse_dfa(n0_states, n0_symbols, n0_trans, -1, {1}) + + # Create presentation + presentation = AutomaticPresentation({ + 'U': universe, + 'A0': addition_intermediate, + 'B': weak_div, + 'N0': N0 + }) + presentation.update(Z='A0(x,x,x)') + + # Define addition formula + c000 = '(N0(x) and N0(y) and N0(z) and A0(x, y, z))' + c001 = '(N0(x) and N0(y) and not N0(z) and exists a z0.(Z(z0) and A0(x,y,a) and A0(a,z,z0)))' + c010 = '(N0(x) and not N0(y) and N0(z) and A0(z, y, x))' + c011 = '(N0(x) and not N0(y) and not N0(z) and A0(z, x, y))' + c100 = '(not N0(x) and N0(y) and N0(z) and A0(x, z, y))' + c101 = '(not N0(x) and N0(y) and not N0(z) and A0(z, y, x))' + c110 = '(not N0(x) and not N0(y) and N0(z) and exists a z0.(Z(z0) and A0(x,y,a) and A0(a,z,z0)))' + c111 = '(not N0(x) and not N0(y) and not N0(z) and A0(x,y,z))' + phi_A = ' or '.join([c000, c001, c010, c011, c100, c101, c110, c111]) + presentation.update(A=phi_A) + + presentation.update(Eq='exists z.(Z(z) and A(x,z,y))') + presentation.update(Pt='B(x,x) and N0(x)') + presentation.update(Lt='exists z.(N0(z) and not Z(z) and A(x, z, y))') + presentation.update(Gt='exists z.(N0(z) and not Z(z) and A(y, z, x))') + presentation.update(Neg='exists z.(Z(z) and A(x,y,z))') + + # Delete auxiliary relation + del presentation.automata['A0'] + + return presentation + + +# -------------------------------------------------------------------------- +# The presentations. Each declares its own vocabulary, so `symbolic()` takes +# no argument; see `CompiledPresentation`. +# -------------------------------------------------------------------------- + +class BuechiArithmetic(CompiledPresentation): + """Büchi arithmetic over the natural numbers: :math:`(\\mathbb{N}, +, <, + \\mid_2)` in base 2. + + >>> N = BuechiArithmetic() + >>> x, y = N.symbolic().vars("x y") + >>> ((x + y).eq(12) & x.lt(y)).check() + True + + ``B(x, y)`` holds iff `y` is a power of two dividing `x`; it is spelled + ``.divided_by_power`` rather than bound to ``|``, which on formulas already + means union. """ - return BuechiArithmeticZ().symbolic(signature()) + + _BUILD = staticmethod(_build_buechi_arithmetic) + + #: base-2 encoding: the magnitude, least significant bit first + PADDING = '*' + + @staticmethod + def encode(n: int) -> List[str]: + """The word encoding a natural number: binary, least significant bit + first.""" + if n < 0: + raise ValueError(f"not a natural number: {n}") + return list(format(n, 'b')[::-1]) + + @staticmethod + def decode(word) -> int: + """The natural number encoded by a word, ignoring padding.""" + digits = ''.join(word).replace(BuechiArithmetic.PADDING, '') + return int(digits[::-1] or '0', base=2) + + def default_signature(self): + """``+`` as addition, with the order, equality and divisibility as + methods, and naturals written as Python integers.""" + from autstr.symbolic import FunctionCodec, Signature + signature = Signature(codec=FunctionCodec(self.encode, self.decode)) + signature.function('+', graph='A', out=2) + signature.operator('+', '+') + signature.operator('eq', 'Eq') + signature.operator('lt', 'Lt') + signature.operator('gt', 'Gt') + signature.operator('divided_by_power', 'B') + return signature + + +class BuechiArithmeticZ(CompiledPresentation): + """Büchi arithmetic over the integers: :math:`(\\mathbb{Z}, +, <, \\mid_2)` + in base 2. + + >>> Z = BuechiArithmeticZ() + >>> x, y, z = Z.symbolic().vars("x y z") + >>> ((x + y).eq(z) & z.lt(100)).check() + True + >>> (3, 4, 7) in (x + y).eq(z) + True + + Integers are written directly wherever a term is expected -- ``x + 5``, + ``x.lt(100)`` -- and solutions come back as Python integers. Anything the + operators do not cover is reachable through `SymbolicContext.rel`. + """ + + _BUILD = staticmethod(_build_buechi_arithmetic_Z) + + #: base-2 encoding: a sign symbol, then the magnitude, least significant + #: bit first + SIGN_POSITIVE = '0' + SIGN_NEGATIVE = '1' + PADDING = '*' + + @staticmethod + def encode(n: int) -> List[str]: + """The word encoding an integer: sign symbol, then magnitude bits least + significant first.""" + sign = (BuechiArithmeticZ.SIGN_POSITIVE if n >= 0 + else BuechiArithmeticZ.SIGN_NEGATIVE) + return [sign] + list(format(abs(n), 'b')[::-1]) + + @staticmethod + def decode(word) -> int: + """The integer encoded by a word, ignoring padding.""" + word = ''.join(word).replace(BuechiArithmeticZ.PADDING, '') + if not word: + raise ValueError("empty encoding") + magnitude = int(word[1:][::-1] or '0', base=2) + return (magnitude if word[0] == BuechiArithmeticZ.SIGN_POSITIVE + else -magnitude) + + def default_signature(self): + """``+`` as addition and unary ``-`` as negation, with the order, + equality and divisibility as methods, and integers written as Python + integers.""" + from autstr.symbolic import FunctionCodec, Signature + signature = Signature(codec=FunctionCodec(self.encode, self.decode)) + signature.function('+', graph='A', out=2) + signature.function('neg', graph='Neg', out=1) + signature.operator('+', '+') + signature.operator('-', 'neg') + signature.operator('eq', 'Eq') + signature.operator('lt', 'Lt') + signature.operator('gt', 'Gt') + # B(x, y): y is a power of two dividing x. Spelled out as a method + # rather than bound to `|`, which on formulas already means union. + signature.operator('divided_by_power', 'B') + return signature -def variables(names) -> tuple: - """Shorthand for ``integers().vars(names)``.""" - return integers().vars(names) diff --git a/autstr/buildin/__init__.py b/autstr/buildin/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/autstr/buildin/automata.py b/autstr/buildin/automata.py deleted file mode 100644 index f0844f6..0000000 --- a/autstr/buildin/automata.py +++ /dev/null @@ -1,138 +0,0 @@ -import itertools as it -from typing import Optional, Set -from autstr.sparse_automata import SparseDFA -import numpy as np - -from autstr.utils.misc import encode_symbol - -def length_automaton(n: int, base_alphabet: Set[int]) -> SparseDFA: - """ - Creates an automaton that recognizes all words over base_alphabet with length exactly n. - - :param n: The exact word length - :param base_alphabet: Set of integer symbols - :return: SparseDFA recognizing words of length n - """ - # States: 0 (start), 1, 2, ..., n (accepting), n+1 (dead) - num_states = n + 2 - # Default transitions: move to next state or dead state - default_states = np.array([i + 1 for i in range(n)] + [n + 1, n + 1], dtype=np.int32) - # No exceptions needed (same behavior for all symbols) - exception_symbols = np.full((num_states, 0), -1, dtype=np.int32) - exception_states = np.full((num_states, 0), -1, dtype=np.int32) - # Only state n is accepting - is_accepting = np.array([False] * n + [True, False]) - start_state = 0 - - return SparseDFA( - num_states=num_states, - default_states=default_states, - exception_symbols=exception_symbols, - exception_states=exception_states, - is_accepting=is_accepting, - start_state=start_state, - symbol_arity=1, - base_alphabet=base_alphabet - ) - -def k_longer_automaton(k: int, r: int, base_alphabet: Set[int], padding_symbol: int) -> SparseDFA: - """ - Creates an automaton recognizing (r+1)-tuples where the last word is at least k letters - longer than the other r words. - - :param k: Minimal length difference - :param r: Number of reference words - :param base_alphabet: Set of integer symbols - :param padding_symbol: Padding symbol integer - :return: SparseDFA for the k-longer condition - """ - # State mapping: [-1, 0, 1, ..., k] -> [0, 1, 2, ..., k+1] - state_mapping = {s: i for i, s in enumerate(range(-1, k+1))} - num_states = len(state_mapping) - sorted_alphabet = sorted(base_alphabet) - arity = r + 1 - - # Precompute all symbol tuples and their encodings - symbol_tuples = list(it.product(sorted_alphabet, repeat=arity)) - symbol_encodings = [encode_symbol(t, base_alphabet) for t in symbol_tuples] - - # Initialize DFA components - default_states = np.full(num_states, state_mapping[-1], dtype=np.int32) # Default to dead state - exception_list = [[] for _ in range(num_states)] - - # Build transitions - for state in range(-1, k+1): - state_idx = state_mapping[state] - for t, enc in zip(symbol_tuples, symbol_encodings): - # Compute next state - if state == -1: - next_state = -1 # Stay in dead state - else: - if all(x == padding_symbol for x in t[:-1]) and t[-1] != padding_symbol: - next_state = min(state + 1, k) # Count extra length - elif t[-1] == padding_symbol: - next_state = -1 # Reject if padding the last word - elif state == 0: - next_state = 0 # Wait for other words to end - else: - next_state = -1 # Reject otherwise - - next_state_idx = state_mapping[next_state] - if next_state_idx != state_mapping[-1]: - exception_list[state_idx].append((enc, next_state_idx)) - - # Find max exceptions needed - max_exceptions = max(len(ex_list) for ex_list in exception_list) if exception_list else 0 - - # Build exception arrays - exception_symbols = np.full((num_states, max_exceptions), -1, dtype=np.int32) - exception_states = np.full((num_states, max_exceptions), -1, dtype=np.int32) - - for i, ex_list in enumerate(exception_list): - if ex_list: - syms, states = zip(*ex_list) - exception_symbols[i, :len(syms)] = syms - exception_states[i, :len(states)] = states - - # Final states: state k (meaning we've counted k extra symbols) - is_accepting = np.array([i == state_mapping[k] for i in range(num_states)]) - - return SparseDFA( - num_states=num_states, - default_states=default_states, - exception_symbols=exception_symbols, - exception_states=exception_states, - is_accepting=is_accepting, - start_state=state_mapping[0], - symbol_arity=arity, - base_alphabet=base_alphabet - ) - - -def zero(symbol_arity: int = 1, base_alphabet: Optional[Set[int]] = None) -> SparseDFA: - """Automaton that rejects all inputs.""" - base_alphabet = base_alphabet or {0} - return SparseDFA( - num_states=1, - default_states=np.array([0], dtype=np.int32), - exception_symbols=np.full((1, 0), -1, dtype=np.int32), - exception_states=np.full((1, 0), -1, dtype=np.int32), - is_accepting=np.array([False]), - start_state=0, - symbol_arity=symbol_arity, - base_alphabet=base_alphabet - ) - -def one(symbol_arity: int = 1, base_alphabet: Optional[Set[int]] = None) -> SparseDFA: - """Automaton that accepts all inputs.""" - base_alphabet = base_alphabet or {0} - return SparseDFA( - num_states=1, - default_states=np.array([0], dtype=np.int32), - exception_symbols=np.full((1, 0), -1, dtype=np.int32), - exception_states=np.full((1, 0), -1, dtype=np.int32), - is_accepting=np.array([True]), - start_state=0, - symbol_arity=symbol_arity, - base_alphabet=base_alphabet - ) \ No newline at end of file diff --git a/autstr/buildin/bin/buechi.autstr b/autstr/buildin/bin/buechi.autstr deleted file mode 100644 index 24326b354d965a453e411ec7c2cd7b0d2dd9ef56..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12816 zcmeI1!EO^V5QZ0?WR)YzA$B%R0;eJ&E*vTl7Z9qT4X6^N(3V40^?i5{UWhkfoHWko z;ORPevustQDxJ)Z$N$XyGj@{wgO{%!6h-mt;g2VUEs9FypQb3TdSBlkAD_%W9iCr) z`EdHV_qgmm>h;UsZ2s}|>}Ym)bUHsjIlq|Ak1ple#o71S^;Pe+h;Jvuez`a4m*au_ zAN5PyFGX7ZIb9{s$42zSs$ZJQKNCT${aY@hEGxZ@;ex3=aV{&dUi=q%xRM3WAG+yM zHx)eIqX-*s{Uc594MZR~>g~uSB7CWPzG1Gur62B3SyJK6sE;{ioU!MrI{Mkze+}WC zP-?%9HO9ySGnN`!BQM5P%sLu*w`LCkN?m=h-a<44)EINfz`H0jL6TdaIO8P|Opv-U z%q-0;%`CG)D{V*Gq;jljyCCWywl1LS0*2I#-$EIcC8@sen_X9P>Bh3>q+wqN^Vo18 zTb|{XV>_p+oHZOzI;h>?U~np?X0wJ8XglU)Hft@ggq z($BXX&>D^$pG~g~&c$2Ck)H-4>?T_QlHi1HI_4aIVT#xW>`_j?U{Z067`|cO1T+oa zU_a_}0?Jt%+_f1^7r=D^Luzu_U7AdWr24&Yc52_Lz1BvCf;J&U9znq|%2TVUx`&gC zEYl$E0tLHLN7P)#()MeE1+hH>bUZ);HpEu8p$qGSvxkHUj<=n(nF^zg9Hj1{GB(H$ z&&b{6iX`H0sd2Pn=f3k>{c#S(W%otm)7mc*hqBlXoZCv+X~q(}9dLA#vuwK{N#J-; z(423exmk|P?9dMbqfKfh`6-3$;q*`S6|+n6uf-Gd#}C=W%h+iX^AXb#Cw) zzlK}W$M#sZ&>EfVlQ(sJ;(oSqKjYYeyf})F35#W53RRhSb9zK;Lh9F%E@p>+8_SJ< zxFN)BZ-R5_d-km^f8uX3U)+~Wb|5ci!Y!B&CX;bc?!=H`0AvFP0BvGPjOjFAG20y7 zHv5ptT*vrmlZuP$DW|SawbL-i)=M1gP#nDvF6!uQCQ|f~J3uQpDEj4;B+wYW-i14g z%ywvVJSf@lg<%WEY-2jVScKSfCZW#umi4YqFY4>$%40^ZShBBmSINbVXLWz!#fKj< z)e1IMDe`mI^50}RmiZhVF^~ad81uk9voLJ46rVYmV{5m?81r!#b#2Do6hC5l)_lio zo7T0NL-t0~aisbq2KF#}p8ZUjAJ$m26rZX1QtdP>)KgCN+jTq6V68Sy#jWw92@IO= zHuBHBQD)xA?;K3xXZgn_ak9HBJ+HkhS;1%B?}?Qp%nSwL|Ep5;2Afl(gte?5@1ff9 zK`N2zq?_iGYZIVtWuoELFmC0%!^UmK-4s7!>7?&n@+7u*n>l1}#IUrXBL=NMf*Pzo zz{*1QIgb*}C$C>k$3~2liHrKC_y1S^cuiq^tPS-!ILQxfU)FdV4D?MOIe1goYB;GU Tk5c`{?3VW=db{bCch|oGP-eS+ diff --git a/autstr/buildin/bin/buechiZ.autstr b/autstr/buildin/bin/buechiZ.autstr deleted file mode 100644 index f121de438d4144b13a116945c49a50d90e6330a2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 102128 zcmeI5&2B756~~h|vAjfHOttN?ogGMs4T=zm4N-(3xu8gpKoS-R@d`WzPr`d3@dj*o z0P1wdC*_*+YyWGy+B5cTY3_A(b)C;Tb?Ve-|L|u&|HX$NeDJ}4|Mlzx(Rz&))s|v#-AV=8JE> zefQ;Of6ec{{rWHO{{FYiU*_Z|_t#e!`}?blyIuZ!dv%c?b87mfUAnqRH~Ar5Kji=J zt}cf0zsCam$3!kNI9Qd)I6Nn13jXkr-(25ZUEB=c{mtp?$8=_Cd{^dW9xU&nj-5jwZoKs{Y1RJ>)IItHHj%8jQ)=V{VI!WV>yC=;UG#GQ*Wp=yforlp8g21=&O zvUEN0lDc711LFtyNRRhYMOI z){+}Xnu#>h4pE8Xj{{Ybmk<-Gat3m(16DINsDadjLfQBr{2?l23J`_ZbEc}EYw}lQ ztk>jQuTjxrU_h3kjw^eYL(PcuDZTko@u3Nwh;O!w%&BCjTe(B?+WUJ~ z4!VMK*MqI;hCiE5i(gPDG++oD;>nq~J`-cI3Biwd&&>!9x0&<77OWp~uQ=T1M7q!J z&e<-z5FUXQ()XVdG@c3quAeZ|6!lox6Alt4sF>$_A_PCo6w7-tPaL8ObJi{&5!=_R zZ|~5D45BxkH-irhxwm{6#XX6eN~eI6((OyfWia~uVv^Qp~Q1TObyX2WOo9vimf3nvp-DGd#uS(^VM$y0fDk zlsLJ4w#wUD-UX}#7atwGm)%x$yW?+dk8A7G1*`-YA07Oy>c7&%n~`^Ixq8_{YkR5W zqQ~y_*vjYH`dJ&lE08o?<@fSqz3^1*`QzI7ozRqUHJ+-z)|S@=tOOSy9lV!*)|Pi| z{H_pDfxYxrk#jbB;cxYyYx8p^a=Dnj?5Y8stX=-C z>bX}vF7#t=2CaD3uJ^U^yWms8$-Py3J2!bxrRPcy{@9D3UhUc$s}jBVY;EtSqFaei zk7wI{&s=Y=PqJGtFeRSVb8Go?+b6R&|7+uKZPyh){wVFs%f_vibHi~8Q$mF&`)sx! zakD7VqrHSw9(V8JZbjVDzEo9Dwh@-qfCpeyig?hVSu1!qgvtt0M-lPv10GimEW%$?Igw z#0}YpZ4)jFdKxu(>qV->_uGrB7g&vU7P~4tJu-1>D7Pp%IsIFb{1z>OP$9c}GJ!ow zju2S2dy;%lb&gHkjyi?)VYj}AKh-cv+&g?heJbkuj`VOxdQh3aBef9oc6KaO7cy_x z>xU3%4|Zum2%$=KPii4n?nw$?djub_h(~it1VXHvnPZ*zaf5yj3F(#F@fyen1Mx}`_7YVH)XR%Jj3I-VShMZkcQ>+l90L`WiMCJZ^*4RFk9Cmlmd$wmU<8&!qq6fp=* z9EFPGlC-BN1i1-iG(GQ8HTEr_5RB^?<5IR^3``m1j-*%XEMaVl`nw}#s5BWfg&(Zu z`>aFXdLY%{y`o6OkyeR> zL29zAE2`4d#2wo{QU?4hhAj-3& ztV;|%2Mj$24AKXj6h#+2pk)uN9KQP(qt5}OPt}A6j770}aW-_oP;|gc6LB~?;M7^d zXl1}ylxu4F{o)vyq6RT8#n^4YKy<+NIkX3&0|uf4&V7y-1cjb-rlK$uMV(jfQO%Es5Nsyq#$yolC+5XN5Od<; z3-%_tcnC4$7yx2Ge$9c0M9hDOFPJ)tj*h^lwnHMuZ}FMV;5+yIV8e%YQ8xHNvL^y5rUX&4qu?9SO*Y7pkHg5+vlk(e0i!IV9)n=tYb#SB2=hh4tKEbxRD~qx`iTs4 z__NV}i0O_LfqubUOo$5gaJN(sB<^^tVI=POZM^6O`p5CpdJzQDCN-eq?nw=(I2+lA zsjxqTonz}%AiN2aLHPd1E?*+sW8t}lf!M1EXP3L#J_$;Ry=2x?`# zzAOY$y};;pLkuigWXYye8>)t=62^by>Qo4WjcK^8CiChX?b z+C-(F^4?mOx5yQLlL5v z27W`WCZRKFZfN5WJmjFcLG&~98Kc3qrE!QI3X(A7#eFx_GI8>t+05E8jYH^IK>QTr ziyJep&xefX%=U(Rgyw8LCuAVaEZXNFQ&3;RIF4*6WF%(_#Ks~7pD=t&c%mp_Kuc8G z`_F{&X2Ro1X~h~N$&xMJ(91Ait+9lsjL1e39xF9-o-InYauniB*w-fU|}hp9){E_PLmXBt8S$(aKlY;CVuqS20Nz*9rx z=b1u6N5L!{-wk+PJ!H}JO>S$znayL20z5)bBJFJP=@9astVJyfCHOo@4abl<(h2i( zW|&X-)&7LwY0CnY=Yt-k*_kDJkkvNYhf1Jj_+H9vXVtuQ$f`N(@I}|50QJYV=RTy~ zZ5r72(T5bsaPY(&hDx}uZbSW$cKcL+JB{`^;eG>D+btgoPD(#wZpjX9hBRy$DtvJS z5#$#qmu!$9Qn#x`!WTyOvo<~tF%bYx z=-9k(h{*=%;R{@H!%fAcL1K2i>m#I%5ZK`m?QsDpq=8mHUgi;kjO#by8`bVnFdI3C zn0fakK@bhf=k1}WR6d1+M|#FZACQom1rCi#l%>_6!jCt6#KPdYo@A#g0frjq$>qc| zJPBjMr96)pfrNTObHpP(<8>n;uEb}0Fw&N-BinPR1@$Z5A;%e`3g5fj%wk)%TuCi& zqa4p&*~YCCC<3q3>1_ht@tvEER^O8P2?Z@i4;s)(~;i;QOWsRbF1eKat7f8n!BtT#k0P$_P%RA(cP8 zJ4r>*yByyC)SXbCMGZru-h?)M@gNwt3U3OF6}IVR)62AfX$zR6Sv6d>-Wty9Y&bKA z%}>(;aAV_UVXxD|^5WZh#ele#?99gzas!~{IvGJ{MbKuWQ}LtIJvYj)AOc^vv(BP9h_H?u{E4!4QHDukhNCt>Ry%;_-&ptpZ-A z0yaHwdfp;3j>z8p0_Qdp0hQl35?{KGy!15Yx%>I0iwz8A9MUu10?Y%ZRuhg|`q7e0ZA=599UGgNYOK##{Jyq5K(3!rQTu zT{05>lHETPzrkzv5}th+TT+Y-4^iQ@t9FY|hzq|thj;$M*@#cCE-ruk=gW^SKCydm z?3Q8EleMAz=%l~fyExXBF`KMqc)gYCn$Gw)r|rUoF`fB2$jhzO z^6E>JGnzwf9p+3jd44ISi<=YFEF7>be3c``y#Jcr$rA|Dpnx0zD z5myPKUlsF5l^&`dm&L7nZ;Coic^w3rp_X|MrR8v6IIh=|gl`h$q3g5dNIji1uHhdI{ zW{Z&g!I_skGykC48%!ryosdM>gx$Ya3EZ)nX8@|A_GnWXKl}FCsSLw9jQ2Z^t1UL0 zVPC}a9-w3sgLE&Q3EN%w&Q6!vj1v2V`7o!-rvlE{apfI+ZTwD1Oh`>%i{wXCB%ex$ z`h(J%O3|piN{{%Zc%`+x%7 zgOlD`%P-W=v-}XB3+cL;E8%}ut~2M~_2Ytg&U~VmK6~kBAs!dMmt3U1Uif?AAwFFp zRG`W~Rr$5`&ms)Km7oGYeMmW#`mNb^BN)dDDR#O zM6DwhiyR1wh1;Z`OmLR+-H{^jbC>pk(h`HG(RbLlPQ6r_K*{k-qV{pq@P`nnE8FHB zq9Xb3>4jX;A-SA+-|R8)3!CJQ?Q^Fg`Fi)b7eaUZKzcxvc_0J8%CUuL=cG&^jq!`8 zWl2w?U5?)z^n|9;|P{y>QYG*1~DG4Ld8D!J~m}T@MGa${CZ=VG&Ys1rW zLnFZw@8#nNQw$_o&lqB|()g5+$c?Hh*KL(h;ni|fBjlDUcKlJL=Kxhgg(rT=FR6Gd zcKlI=XDcJmk6U+^wle3vN~oe>TYR#BTOki0Rf-{S&Q#&KRi0XxxC;$8)$_VIb?@z_ zNBVBZWM|?+Yaa06qIrP>tf~7{Q657gzQN5uFx*M8C!7F612?8qEPKf{H*%RV}&BG?!y_?mPY;yl~of6Ns zFj++Tzh{<~LWbk{xI7;9%rmdkX)2sf&1gye6Ee(1(^~qKGAJ0zjk|B zh~LE_d0X-K!sCop39q0kzk2jyXTp!Ow-P^RE3Y%=8kkSCmhYv{UU*2lSK5wToVQvp zE?|{d$sSydgui0r+48M+RpIx?c)laASsnSp8Fva(;z)D|Mgw13ZKGi?5~yC#c$)aY z-82rvnE)^1AYZ&9gmuNo!)HJ!(qa&dNU81d&i{m4ofsGCc!uHTwvWqU z$QGU(P#Zaa$xy|>ywRA2RmO)N0OO=SqF3BUOO~c=c9e-4LaDXa7&5b(U zrSw#IwH$8x#FmVAMo_C%S*bMZk1D)ej%)yxQ zqh!)JHADa>J5!Y5%psEuuSwemCmLuxj`$>fdyWJE`7!c;E2NY+mZyV%|5!s`6$n zSF!%|sKRgMwjOSc9&WR%&8{}PnwuKw+dt9_yW9?6X+P5YXgE!D%^eNLnTj1@~`t0 zEi>)23?+%dcFhiUM{E9TyD-PAMO~h9godkoWo13toojpSZ1vZ69*=)>+7@lbbY|!v zFVAOMUcJ*UqshNY!-qLj0nabRtYFJWFJFog7AP}wx)nc_C%P3m=d%@$16By>k@z7z zlu!6u%dahOE1$$qg~!FD@~wKV@cW~*?vNX}TFx1F3R6ObCwbkeRCuZUQhMayRlK|k zL>j_r0GzBR*`K)4QeR90yI<|%N{_0jj3G*TDqgBO!b>JZ6-szYUduGvE-qfSa)h3TSjBH4AWQh7 zlvGKpwa8nw$!_q>0dDV6c_B+sM~USBRrucJsD<>VLp^wJIvYNU)^ZIM-Sqa diff --git a/autstr/buildin/bin/mso0.autstr b/autstr/buildin/bin/mso0.autstr deleted file mode 100644 index c5274f1aa0ebcf9fdd26a0c283e51118084ae955..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 70412 zcmeI5&u$#Y5ys_L2#`CWW2^&b|B&3=atI6qF%U%XDKG?^W()xi6^j%Q!|=Q1A@Vpm zqV`TRlD1RbN$CS6BD+%>L=Ge|!1avuDqK{QZAF|KIOEd$zLj zf9&61p8xCH@4tV2`@^fdpa1#Yn;)P5@$C5@p1(MIeslYeH*c?RUR}Sry?cFke{*~N zv)#LY`|jq~U!H$$#lK%IUz}ZBy*Rr(w}00!&g{}k{a?4R8{KmC;%sq#VWlg(e`yyF z_wB}e+IZo$R_G#;G4OjEyOVgYjNkTOD=#0)CU~!I)7NbbqBV_too6PmSWgWj@grq# z?uAE)?Us{y;c&?ADlv#Z({bFss)bM{KpXyc7y-{+%G#B$)$Rd zTTAZ%_OTUfYUwj3-kfj}J=GH*vX2AE7}Dj(&c9Y)X-2T&s?w2swLRG%!-|+##uyd* zp5)a9Q(Hvrg^XSbM(YkemQ0AORNHoB1eJkG*HQMFCXnLaf|Op6naJP^_7sv$W??nR zI~Jt)vZ%FHqT*@QT5P0tf=X8ul8}ibi*OF*OYNz*Xo*F9s+UeNX(6VuzGF|+0%~Ly z%W}_%J=l>ucVtrxMX2*r$e@sbJzcK`jc@V|wX0(H# z-9|1h+Y2TYoPKA%N|yCL=>cJ&?YLc%gD%m-XpUJBtDW707%XASWV_R0&oK_|b9CpH z$}Cb|-d$Oe(XDM-?Em*LXd)t>J07SF+78qq8y&rx`!?LK$tZ~++ARTn0PTg0p!QPa z<78eC1xa(7mS$~Pk$hE7bd9Kj4X=3VWs*mHR2;;Y5*L-PO6Lk@1+_ilr&R{kXX5=* z#X%`CMk_Vf27izzvZ3uKnh3oEvVyF)h#PZ#S~iw5+J1NZ`ufnEv4y`f9^HirSe)B% zzcd&2Q5=Uq?@WX~Z9HR`wf9HVeofygUhnYp&I&-|EpHer_sdQ;5`ULCHh&lNcwuaP z$L8N8Rsac>4M5|DEKpR(DrJF~1w$af z-Fe*3l%bGBG0$n8H&NfWmUH3e8v$)8c>}%V$IC4w@9aA#u*?iGN1sY>B=Vo=mP^4&?u2_@!WAGrdaZ zz}*skf$Fh;X1+KWY!7r0vIvGiY=9zYg)~&D)9T=1q>#H)mrx&4lAet&zm$8B0FJ-B zfK47DdRpE?NOA2MOqzvd3)%o z!O9G_vHuq7srVvI%jsyQN9rh&4-N*GZT!%{Kox^~vC`D=dYXeMfOdObTr_Oc97 zbY^Fq705L*GQU)Dd4sH=wkNNtHLy?fABY2c8v2bonsAJ*C&?u{W@hqBwcor!R=}aO zi1}A+v>?-9TB%~hKAmd)0luW>_teK~`>E(?tW1UHB$xJ$&BKJj6-RF@AB|lpL&L2p z++f9*1L3erTIv4zr&q%J#X(#0ya;nzS=(CvL{D<5p73Pw{IZtb8}hfb=nCWJDe%`#A9&0R>UMR3XEkHy-F8($Fka< zB17b*lvR0TFzOSkr@4^QdC~!1Ba#x1tQSn)8LT&*~7)Yx%O*%Vy}1j&ZIAt&Bcj&)_iRKE{^a!L0@<>Jegd-bO2scEI1kI zHLzb?s5%v?bPn7l^aZL%{lsl+$|h)Xr|gFEXyQNE9@v3o5e#YAfNl)NPWcWBq~2GN zsC1dr`6U_K39^DJ5A_P+J+dYP#W^F=Q(4AG05Qj{MK~IA8j#eLypu*lUN$0hrkuT; zvgqGTEoQAVv-ec}%!6OFV`IOQ?Lb46_q1u2El)HlS^q+N=VP0urv&@A@0y=7b@D2o zDQE`3UWMU$P!JLH^-qR}V2~X$`X|GwPFE_O11E*Dda{QqkH&bbOnvY)j|L&nCyUf) z>LgmVEw#t}5rWPSD#i7fia2vFza(2bK~_-Zk%dSe8ECY87osTP!@X$C>_(S;`EoxN(;(+ZrafA#v{ z3mA*-C!5mi*=fRBoLjIy?l&-^<*e+J69x~@KO9$XJ#e$R7 z;`D|^w)>T}^!pt8nvrliypj03I_ku#(m8OK&==J9q%S1~w5Cwy(V78M8t{8ey6R8W zsL*kdfgzf=U&svHq8#oml4Tw5m(;W9G1Ne|4|Em2fq~Gce+5Had!a4C6y7;mVX%oo z9?d4?Oq7IVaQ$*D+~^s&2&>fy8M$s*0T1S8bbhJYhTpcJPNm@S$O|17wgo)s&T6(y zh}HIu>1Yy@v-QS!rjpB%-7F;d#ZZ@=6 zedvP&+aKYz9CKw3-(6S$-G0~JS;hf^JGf45V0Ti!;U~!U1MhxKZz8XckllJ#h$v&d zH;k40WhWbn|GvbmhU8{s&2bL2o5PdBsWu&NS-GMvp)XK9*-ynq3p`2N^-CEy0mRJ#7m*c^ z5e^v3QQf6Y6jy24l0FRe{u3uVBYRU88D1GkZR3nHB!#mm>4DM<)4Y8ONZCW-!ehX`PbT^*3J$%Q-m(3qMw^yt?bjv4{QCTmE2Z*YVni( z)g_clNB&Or=%?Z`pX^C%Ns>ofE0p!h_8`UIr`cK8;O2@fl5Bzx!pSJOE>WT28A;y6 zCdZaVQbv!@1W9}TG8;KddX+ls&Hhpak3VR;B(v~u1jn#WIDZ9zCp1hy_QpKft|7J{+xK+ME_C;1`_zN-vwn)G9Q$)or2rjn30dh&> z;F9`3ePW^r7Y`#{70IoV;S#F!%Sc6T)U)a3x?%$y&R3-P6)C>6r51&yFSi%}(k@!@ z#?D?_q}Y(+7)v&!Iri*^G*?HkN*5K2Wg6&_CYDusWNng1cA&TtF~#y&_@azh9!nlR zx5Gz+Vb?lPFsm=;KGDn-$ zB(3I6NAYJWgOg`Xg-4Z3V@Pe^nvSNl)^g^~j|0-Y$E9jlnnu<3Q_*3LmgfH>h^tfC z^;45eS1rgXBpI2m2hn)Zrvpi4?x zFG+vdT3rv{s}b{G4z)jsErR>o*5KoYSReT#NKAE-^p0?6t&Pv5 z;=*R<;n)}l(ThD)tTZjWo^o>6v@bY4^=ec#Ya1_}56LC|we*CCoX>$pwEJZzYlSBh z(VRc&JGR`h`IBC5zzhIglAmPs&bC#*55)gucxG+~he!QbYv)=%wfr4$R`3qZ>glo- zeXYK&=QmQLh%$&YvW=RT@VkC;Gtt|fzQji1^F+wz>dOW z9`z?iaDek304??Locbkcj&SjChB_)#x;QV!vf7@K&d4j4Rlc!JEK{h9G_g$6Aod{y zw#YMkUQv@hgNh zOfj{o6ww7}&{Ai||Neawhq#h3q?ajop#-lkgMH zvGKIx--=JIeY}G*0OF-LC%+}XBAl7r=KRS|Tu=t!W7mu9Tx*BSEPhG+ouDtcd0W|Y zApR%AL-rYn-_6^~o~>}U!bA3nl12gUHyqVf`}*RRNLZ-I(#^WPs&x1_~d-NBXy7b z@*d>+{1FoB3Oq%p+J)R4kKEvyI$0u(pxTjw$G7lE9gYqBj3+)VSG8r4WCrSLJVS>K zD3sJ6p(7Eh74SeE^*KJ;#`AReSXRYIP;i?DPuJm&4<4rF%_Evt@N^xjLEeBnTG$8p z5l`GsBT)F(AKI4p_#md@Cp__0M2su^RwKy9FL)v-{DddBps~p<==nXizz<2|XOu9+ z;1`)tI3C@=Pk5q#;!gmfyLIQ}x@eQ`oaR(D5~*~w6j0lvYT(W&59sNf0rC;g$+W_~ zv(+6zz26OwwN`#|%Z_A`vGm{*mlSh$o*hJMQv($`zAZuv-zYqmRe`ZhEaT_u@lYs! zt~s)e%#dhY*7=xB#(s{Y5l+o=E@i(JYXk43+^>%nVlEnv-EKiV{(JMI%cnpcm* zFHik@>fe4x_vFvz{P|>;r?5+RZg$RA^Zg}$0~UW51%G-Ef5i`fDG+~j5PymWe{B&z z+JYYgg7?Uu*8y^u%88@SRCtpnV;0n9*mKPBs#M7w3Y!rdV*YvGEk8TV*S`#3z&cWcrpP zns%D8)YgHMLRmf8qr|&ZN2>|ij-%BrZEp~L2}`LC_eb&hHdzD@c2WZ}4R#UP0CgxS zT&g39_!yB?dgRpRhr4VEapt9dNnzCqvOsSYg^=9|cx3=}7^NKfSwmQr0UK0Ks> z+jh0c@ia22O7$gmi*;xN SJX6dsS int: - """Encode a symbol tuple into an integer using base conversion.""" - base = len(base_alphabet) - mapping = {sym: idx for idx, sym in enumerate(sorted(base_alphabet))} - enc = 0 - for char in tup: - enc = enc * base + mapping[char] - return enc - -# Helper function to create a SparseDFA from a traditional DFA description -def create_sparse_dfa(states: List[str], input_symbols: Set[Tuple[str]], - transitions: Dict[str, Dict[Tuple[str], str]], - initial_state: str, final_states: Set[str]) -> SparseDFA: - """Convert a traditional DFA description to a SparseDFA.""" - # Map states to integers - state_to_index = {s: i for i, s in enumerate(states)} - num_states = len(states) - - # Determine base alphabet and arity - base_alphabet = set() - for sym in input_symbols: - for char in sym: - base_alphabet.add(char) - arity = len(next(iter(input_symbols))) if input_symbols else 0 - - # Create state arrays - default_states = [] - exception_symbols = [] - exception_states = [] - is_accepting = np.array([state in final_states for state in states], dtype=bool) - - # Build symbol mapping - symbol_map = {} - for symbol in input_symbols: - symbol_map[symbol] = encode_symbol(symbol, base_alphabet) - - # Process each state - for state in states: - # Find most common transition - next_states = [transitions[state][sym] for sym in input_symbols] - default_target = max(set(next_states), key=next_states.count) - default_states.append(state_to_index[default_target]) - - # Collect exceptions - exceptions = [] - for symbol, next_state in transitions[state].items(): - if next_state != default_target: - sym_enc = symbol_map[symbol] - next_idx = state_to_index[next_state] - exceptions.append((sym_enc, next_idx)) - - # Sort exceptions by symbol for consistency - exceptions.sort(key=lambda x: x[0]) - exception_symbols.append([e[0] for e in exceptions]) - exception_states.append([e[1] for e in exceptions]) - - # Pad exceptions - max_exceptions = max(len(e) for e in exception_symbols) if exception_symbols else 0 - padded_ex_syms = np.full((num_states, max_exceptions), -1, dtype=np.int32) - padded_ex_states = np.full((num_states, max_exceptions), -1, dtype=np.int32) - - for i in range(num_states): - if exception_symbols[i]: - padded_ex_syms[i, :len(exception_symbols[i])] = exception_symbols[i] - padded_ex_states[i, :len(exception_states[i])] = exception_states[i] - - return SparseDFA( - num_states=num_states, - default_states=np.array(default_states, dtype=np.int32), - exception_symbols=padded_ex_syms, - exception_states=padded_ex_states, - is_accepting=is_accepting, - start_state=state_to_index[initial_state], - symbol_arity=arity, - base_alphabet=base_alphabet - ).minimize() - -def BuechiArithmetic() -> AutomaticPresentation: - """Load the serialized Büchi arithmetic presentation.""" - current_dir = Path(__file__).parent - bin_path = current_dir / 'bin' / 'buechi.autstr' - return AutomaticPresentation.automatic_presentation_from_file(str(bin_path)) - -def buechi_arithmetic() -> AutomaticPresentation: - """Sparse version of Büchi arithmetic over natural numbers. Compiles definable relation from scratch.""" - # Universe automaton - only accepts valid binary numbers (no '*') - universe = create_sparse_dfa( - states={'i', '0', '0+', '1', '*'}, - input_symbols={('0',), ('1',), ('*',)}, - transitions={ - 'i': {('0',): '0', ('1',): '1', ('*',): '*'}, - '0': {('0',): '0+', ('1',): '1', ('*',): '*'}, - '0+': {('0',): '0+', ('1',): '1', ('*',): '*'}, - '1': {('0',): '0+', ('1',): '1', ('*',): '*'}, - '*': {('0',): '*', ('1',): '*', ('*',): '*'}, - }, - initial_state='i', - final_states={'0', '1'} - ) - - addition = create_sparse_dfa( - states={0, 1, 2}, - input_symbols={('0', '0', '0'), ('0', '0', '1'), ('0', '0', '*'), ('0', '1', '0'), ('0', '1', '1'), - ('0', '1', '*'), ('0', '*', '0'), ('0', '*', '1'), ('0', '*', '*'), - ('1', '0', '0'), ('1', '0', '1'), ('1', '0', '*'), ('1', '1', '0'), ('1', '1', '1'), - ('1', '1', '*'), ('1', '*', '0'), ('1', '*', '1'), ('1', '*', '*'), - ('*', '0', '0'), ('*', '0', '1'), ('*', '0', '*'), ('*', '1', '0'), ('*', '1', '1'), - ('*', '1', '*'), ('*', '*', '0'), ('*', '*', '1'), ('*', '*', '*'), - }, - transitions={ - 0: { - ('0', '0', '0'): 0, ('0', '0', '1'): 2, ('0', '0', '*'): 2, ('0', '1', '0'): 2, ('0', '1', '1'): 0, - ('0', '1', '*'): 2, ('0', '*', '0'): 0, ('0', '*', '1'): 2, ('0', '*', '*'): 2, - ('1', '0', '0'): 2, ('1', '0', '1'): 0, ('1', '0', '*'): 2, ('1', '1', '0'): 1, ('1', '1', '1'): 2, - ('1', '1', '*'): 2, ('1', '*', '0'): 2, ('1', '*', '1'): 0, ('1', '*', '*'): 2, - ('*', '0', '0'): 0, ('*', '0', '1'): 2, ('*', '0', '*'): 2, ('*', '1', '0'): 2, ('*', '1', '1'): 0, - ('*', '1', '*'): 2, ('*', '*', '0'): 2, ('*', '*', '1'): 2, ('*', '*', '*'): 2, - }, - 1: { - ('0', '0', '0'): 2, ('0', '0', '1'): 0, ('0', '0', '*'): 2, ('0', '1', '0'): 1, ('0', '1', '1'): 2, - ('0', '1', '*'): 2, ('0', '*', '0'): 2, ('0', '*', '1'): 0, ('0', '*', '*'): 2, - ('1', '0', '0'): 1, ('1', '0', '1'): 2, ('1', '0', '*'): 2, ('1', '1', '0'): 2, ('1', '1', '1'): 1, - ('1', '1', '*'): 2, ('1', '*', '0'): 1, ('1', '*', '1'): 2, ('1', '*', '*'): 2, - ('*', '0', '0'): 2, ('*', '0', '1'): 0, ('*', '0', '*'): 2, ('*', '1', '0'): 1, ('*', '1', '1'): 2, - ('*', '1', '*'): 2, ('*', '*', '0'): 2, ('*', '*', '1'): 0, ('*', '*', '*'): 2, - }, - 2: { - ('0', '0', '0'): 2, ('0', '0', '1'): 2, ('0', '0', '*'): 2, ('0', '1', '0'): 2, ('0', '1', '1'): 2, - ('0', '1', '*'): 2, ('0', '*', '0'): 2, ('0', '*', '1'): 2, ('0', '*', '*'): 2, - ('1', '0', '0'): 2, ('1', '0', '1'): 2, ('1', '0', '*'): 2, ('1', '1', '0'): 2, ('1', '1', '1'): 2, - ('1', '1', '*'): 2, ('1', '*', '0'): 2, ('1', '*', '1'): 2, ('1', '*', '*'): 2, - ('*', '0', '0'): 2, ('*', '0', '1'): 2, ('*', '0', '*'): 2, ('*', '1', '0'): 2, ('*', '1', '1'): 2, - ('*', '1', '*'): 2, ('*', '*', '0'): 2, ('*', '*', '1'): 2, ('*', '*', '*'): 2, - }, - }, - initial_state=0, - final_states={0} - ) - - input_symbols = {a for a in it.product(['0', '1', '*'], repeat=2)} - weak_div = create_sparse_dfa( - states={'0', '1', 'e'}, - input_symbols=input_symbols, - transitions={ - '0': { - a: '0' if a == ('0', '0') else '1' if a == ('0', '1') or a == ('1', '1') else 'e' for a in input_symbols - }, - '1': { - a: 'e' if a[1] != '*' else '1' for a in input_symbols - }, - 'e': {a: 'e' for a in input_symbols} - }, - initial_state='0', - final_states={'1'} - ) - - - # Create presentation - presentation = AutomaticPresentation({'U': universe, 'A': addition, 'B': weak_div}) - - # Add bootstrap remaining relations - presentation.update(Z='A(x,x,x)') - presentation.update(Eq='exists z.(Z(z) and A(x,z,y))') - presentation.update(Pt='B(x,x)') - presentation.update(Lt='exists z.(not Z(z) and A(x, z, y))') - presentation.update(Gt='exists z.(not Z(z) and A(y, z, x))') - - return presentation - -def BuechiArithmeticZ() -> AutomaticPresentation: - """Load the serialized Büchi arithmetic presentation.""" - current_dir = Path(__file__).parent - bin_path = current_dir / 'bin' / 'buechiZ.autstr' - return AutomaticPresentation.automatic_presentation_from_file(str(bin_path)) - -def buechi_arithmetic_Z() -> AutomaticPresentation: - """Sparse version of Büchi arithmetic over integers.""" - # Universe automaton - universe_states = ['-1', 'i+', 'i', '0', '0+', '1', '*'] - universe_symbols = {('0',), ('1',), ('*',)} - universe_trans = { - '-1': {('0',): 'i', ('1',): 'i+', ('*',): '*'}, - 'i+': {('0',): '0+', ('1',): '1', ('*',): '*'}, - 'i': {('0',): '0', ('1',): '1', ('*',): '*'}, - '0': {('0',): '0+', ('1',): '1', ('*',): '*'}, - '0+': {('0',): '0+', ('1',): '1', ('*',): '*'}, - '1': {('0',): '0+', ('1',): '1', ('*',): '*'}, - '*': {('0',): '*', ('1',): '*', ('*',): '*'}, - } - universe = create_sparse_dfa( - universe_states, universe_symbols, universe_trans, '-1', {'0', '1'} - ) - - # Addition automaton (intermediate) - add_states = [-1, 0, 1, 2] - add_symbols = set(it.product(['0', '1', '*'], repeat=3)) - add_trans = { - -1: {a: 0 if '*' not in a else 2 for a in add_symbols}, - 0: { - ('0','0','0'): 0, ('0','0','1'): 2, ('0','0','*'): 2, - ('0','1','0'): 2, ('0','1','1'): 0, ('0','1','*'): 2, - ('0','*','0'): 0, ('0','*','1'): 2, ('0','*','*'): 2, - ('1','0','0'): 2, ('1','0','1'): 0, ('1','0','*'): 2, - ('1','1','0'): 1, ('1','1','1'): 2, ('1','1','*'): 2, - ('1','*','0'): 2, ('1','*','1'): 0, ('1','*','*'): 2, - ('*','0','0'): 0, ('*','0','1'): 2, ('*','0','*'): 2, - ('*','1','0'): 2, ('*','1','1'): 0, ('*','1','*'): 2, - ('*','*','0'): 2, ('*','*','1'): 2, ('*','*','*'): 2, - }, - 1: { - ('0','0','0'): 2, ('0','0','1'): 0, ('0','0','*'): 2, - ('0','1','0'): 1, ('0','1','1'): 2, ('0','1','*'): 2, - ('0','*','0'): 2, ('0','*','1'): 0, ('0','*','*'): 2, - ('1','0','0'): 1, ('1','0','1'): 2, ('1','0','*'): 2, - ('1','1','0'): 2, ('1','1','1'): 1, ('1','1','*'): 2, - ('1','*','0'): 1, ('1','*','1'): 2, ('1','*','*'): 2, - ('*','0','0'): 2, ('*','0','1'): 0, ('*','0','*'): 2, - ('*','1','0'): 1, ('*','1','1'): 2, ('*','1','*'): 2, - ('*','*','0'): 2, ('*','*','1'): 0, ('*','*','*'): 2, - }, - 2: {s: 2 for s in add_symbols} - } - addition_intermediate = create_sparse_dfa(add_states, add_symbols, add_trans, -1, {0}) - - # Weak division automaton - div_states = ['-1', '0', '1', 'e'] - div_symbols = set(it.product(['0', '1', '*'], repeat=2)) - div_trans = { - '-1': {a: '0' if a[1] == '0' else 'e' for a in div_symbols}, - '0': { - a: '0' if a == ('0','0') - else '1' if a in {('0','1'), ('1','1')} - else 'e' for a in div_symbols - }, - '1': { - a: 'e' if a[1] != '*' else '1' for a in div_symbols - }, - 'e': {a: 'e' for a in div_symbols} - } - weak_div = create_sparse_dfa(div_states, div_symbols, div_trans, '-1', {'1'}) - - # N0 automaton - n0_states = [-1, 0, 1] - n0_symbols = {('0',), ('1',), ('*',)} - n0_trans = { - -1: {('0',): 1, ('1',): 0, ('*',): 0}, - 0: {s: 0 for s in n0_symbols}, - 1: {s: 1 for s in n0_symbols} - } - N0 = create_sparse_dfa(n0_states, n0_symbols, n0_trans, -1, {1}) - - # Create presentation - presentation = AutomaticPresentation({ - 'U': universe, - 'A0': addition_intermediate, - 'B': weak_div, - 'N0': N0 - }) - presentation.update(Z='A0(x,x,x)') - - # Define addition formula - c000 = '(N0(x) and N0(y) and N0(z) and A0(x, y, z))' - c001 = '(N0(x) and N0(y) and not N0(z) and exists a z0.(Z(z0) and A0(x,y,a) and A0(a,z,z0)))' - c010 = '(N0(x) and not N0(y) and N0(z) and A0(z, y, x))' - c011 = '(N0(x) and not N0(y) and not N0(z) and A0(z, x, y))' - c100 = '(not N0(x) and N0(y) and N0(z) and A0(x, z, y))' - c101 = '(not N0(x) and N0(y) and not N0(z) and A0(z, y, x))' - c110 = '(not N0(x) and not N0(y) and N0(z) and exists a z0.(Z(z0) and A0(x,y,a) and A0(a,z,z0)))' - c111 = '(not N0(x) and not N0(y) and not N0(z) and A0(x,y,z))' - phi_A = ' or '.join([c000, c001, c010, c011, c100, c101, c110, c111]) - presentation.update(A=phi_A) - - presentation.update(Eq='exists z.(Z(z) and A(x,z,y))') - presentation.update(Pt='B(x,x) and N0(x)') - presentation.update(Lt='exists z.(N0(z) and not Z(z) and A(x, z, y))') - presentation.update(Gt='exists z.(N0(z) and not Z(z) and A(y, z, x))') - presentation.update(Neg='exists z.(Z(z) and A(x,y,z))') - - # Delete auxiliary relation - del presentation.automata['A0'] - - return presentation - -def MSO0() -> AutomaticPresentation: - """Load the serialized MSO0 presentation.""" - current_dir = Path(__file__).parent - bin_path = current_dir / 'bin' / 'mso0.autstr' - return AutomaticPresentation.automatic_presentation_from_file(str(bin_path)) - -def finite_powerset() -> AutomaticPresentation: - # Base alphabet for the presentation - base_alphabet = {'0', '1', '*'} - - # 1. Universe Automaton (U) - Includes empty set - universe = create_sparse_dfa( - states={'start', 'empty', 'zero', 'one', 'pad_one', 'pad_zero', 'reject'}, - input_symbols={'0', '1', '*'}, - transitions={ - 'start': { - '*': 'empty', - '0': 'zero', - '1': 'one' - }, - 'empty': { - '*': 'empty', - '0': 'reject', - '1': 'reject' - }, - 'zero': { - '*': 'pad_zero', - '0': 'zero', - '1': 'one' - }, - 'one': { - '*': 'pad_one', - '0': 'zero', - '1': 'one' - }, - 'pad_zero': { - '*': 'pad_zero', - '0': 'reject', - '1': 'reject' - }, - 'pad_one': { - '*': 'pad_one', - '0': 'reject', - '1': 'reject' - }, - 'reject': { - '0': 'reject', - '1': 'reject', - '*': 'reject' - } - }, - initial_state='start', - final_states={'start', 'one','empty', 'pad_one'} # Accepts empty set and non-empty sets - ) - - # 2. Subset Automaton (Subset) - Empty set is subset of all sets - subset = create_sparse_dfa( - states={'start', 'error'}, - input_symbols=set(it.product(base_alphabet, repeat=2)), - transitions={ - 'start': { - ('0','0'): 'start', - ('0','1'): 'start', - ('0','*'): 'start', - ('1','1'): 'start', - ('*','0'): 'start', - ('*','1'): 'start', - ('*','*'): 'start', - ('1','0'): 'error', - ('1','*'): 'error' - }, - 'error': {k: 'error' for k in it.product(base_alphabet, repeat=2)} - }, - initial_state='start', - final_states={'start'} - ) - - # 3. Singleton Automaton (Sing) - Empty set is not a singleton - sing = create_sparse_dfa( - states={'start', 'one', 'one_pad', 'many', 'reject'}, - input_symbols=base_alphabet, - transitions={ - 'start': { - '0': 'start', - '1': 'one', - '*': 'reject' # Reject empty set immediately - }, - 'one': { - '0': 'reject', - '1': 'many', - '*': 'one_pad' - }, - 'one_pad': { - '0': 'many', - '1': 'many', - '*': 'one_pad' - }, - 'many': { - '0': 'many', - '1': 'many', - '*': 'many' - }, - 'reject': { - '0': 'reject', - '1': 'reject', - '*': 'reject' - } - }, - initial_state='start', - final_states={'one','one_pad'} # Only non-empty singletons - ) - - # 4. Successor Automaton (Succ) - Empty set has no successor - succ = create_sparse_dfa( - states={'start', 'after_x', 'after_y', 'error'}, - input_symbols=set(it.product(base_alphabet, repeat=2)), - transitions={ - 'start': { - ('0','0'): 'start', - ('1','0'): 'after_x', - ('0','1'): 'error', - ('1','1'): 'error', - ('*','*'): 'error', # Reject empty set - ('0','*'): 'error', - ('1','*'): 'error', - ('*','0'): 'error', - ('*','1'): 'error' - }, - 'after_x': { - ('*','1'): 'after_y', - ('0','0'): 'error', - ('0','1'): 'error', - ('1','0'): 'error', - ('1','1'): 'error', - ('*','0'): 'error', - ('0','*'): 'error', - ('1','*'): 'error', - ('*','*'): 'error' - }, - 'after_y': { - ('*','*'): 'after_y', - ('0','0'): 'error', - ('0','1'): 'error', - ('1','0'): 'error', - ('1','1'): 'error', - ('*','0'): 'error', - ('*','1'): 'error', - ('0','*'): 'error', - ('1','*'): 'error' - }, - 'error': {k: 'error' for k in it.product(base_alphabet, repeat=2)} - }, - initial_state='start', - final_states={'after_y'} # Only consecutive singletons - ) - - # 5. Less-Than on Singletons Automaton (Lt_sing) - Empty set not involved - lt_sing = create_sparse_dfa( - states={'init', 'x_first', 'x_first_accept', 'error'}, - input_symbols=set(it.product(base_alphabet, repeat=2)), - transitions={ - 'init': { - ('0','0'): 'init', - ('0','1'): 'error', - ('1','0'): 'x_first', - ('1','1'): 'error', - ('*','*'): 'error', - ('0','*'): 'error', - ('*','0'): 'error', - ('1','*'): 'error', - ('*','1'): 'error' - }, - 'x_first': { - ('0','0'): 'x_first', - ('0','1'): 'x_first_accept', - ('1','0'): 'error', - ('1','1'): 'error', - ('*','*'): 'error', - ('0','*'): 'error', - ('*','0'): 'x_first', - ('1','*'): 'error', - ('*','1'): 'x_first_accept' - }, - 'x_first_accept': { - ('*','*'): 'x_first_accept', - ('0','0'): 'error', - ('0','1'): 'error', - ('1','0'): 'error', - ('1','1'): 'error', - ('*','0'): 'error', - ('*','1'): 'error', - ('0','*'): 'error', - ('1','*'): 'error' - }, - 'error': {k: 'error' for k in it.product(base_alphabet, repeat=2)} - }, - initial_state='init', - final_states={'x_first_accept'} # Only when first singleton < second - ) - - # Create the presentation with base automata - presentation = AutomaticPresentation({ - 'U': universe, - 'Subset': subset, - 'Sing': sing, - 'Succ': succ, - 'Lt_sing': lt_sing - }) - - # Define additional relations using formulas - presentation.update( - In="Sing(x) and Subset(x, y)", - Eq_set="Subset(x, y) and Subset(y, x)", - Leq_sing="Lt_sing(x, y) or (Eq_set(x, y) and Sing(x) and Sing(y))", - Gt_sing="not Leq_sing(x, y) and Sing(x) and Sing(y)", - Min="(forall z. not Subset(z, x)) or " # Empty set case - "(Sing(y) and Subset(y, x) and forall z. (-(Sing(z) and Subset(z, x)) or Leq_sing(y, z)))", - Max="Sing(y) and Subset(y, x) and forall z. (-(Sing(z) and Subset(z, x)) or Leq_sing(z, y))", - Intersect="forall a. (-Sing(a) or ((Subset(a, z) and (Subset(a, x) and Subset(a, y))) or (-Subset(a, z) and -(Subset(a, x) and Subset(a, y)))))", - Union="forall a. (-Sing(a) or ((Subset(a, z) and (Subset(a, x) or Subset(a, y))) or (-Subset(a, z) and -(Subset(a, x) or Subset(a, y)))))", - SetMinus="forall a. (-Sing(a) or ((Subset(a, z) and (Subset(a, x) and not Subset(a, y))) or (-Subset(a, z) and -(Subset(a, x) and not Subset(a, y)))))" - ) - - presentation.update( - Geq_sing="not Lt_sing(x, y) and Sing(x) and Sing(y)", - ) - - return presentation - diff --git a/autstr/infinite_graphs.py b/autstr/infinite_graphs.py index 791af59..74b8ae1 100644 --- a/autstr/infinite_graphs.py +++ b/autstr/infinite_graphs.py @@ -135,7 +135,7 @@ def _path(): """The two-way integer path as a minimal presentation carrying only the domain, the ±1 edge, and equality — so the product folds those and nothing else.""" - from autstr.buildin.presentations import BuechiArithmeticZ + from autstr.arithmetic import BuechiArithmeticZ from autstr.presentations import AutomaticPresentation z = BuechiArithmeticZ() z.update(E=f'exists o.(({_ONE}) & (A(x,o,y) | A(y,o,x)))') @@ -145,7 +145,8 @@ def _path(): # -- element codec: an n-tuple <-> its nested-pair convolution ------ def encode(self, point: Sequence[int]) -> list: - from autstr.arithmetic import encode as encode_int + from autstr.arithmetic import BuechiArithmeticZ + encode_int = BuechiArithmeticZ.encode point = tuple(point) if len(point) != self.n: raise ValueError(f"expected a {self.n}-tuple, got {point!r}") @@ -161,7 +162,8 @@ def encode(self, point: Sequence[int]) -> list: return word def decode(self, word) -> Tuple[int, ...]: - from autstr.arithmetic import decode as decode_int + from autstr.arithmetic import BuechiArithmeticZ + decode_int = BuechiArithmeticZ.decode coordinates = [] current = list(word) for _ in range(self.n - 1): diff --git a/autstr/ordinals.py b/autstr/ordinals.py index 776fd11..283f3b4 100644 --- a/autstr/ordinals.py +++ b/autstr/ordinals.py @@ -43,7 +43,7 @@ from typing import Dict, List, Mapping, Optional, Sequence, Tuple, Union -from autstr.buildin.presentations import BuechiArithmetic +from autstr.arithmetic import BuechiArithmetic from autstr.interpretations import interpret from autstr.sparse_tree_automata import SparseTreeAutomaton, Tree from autstr.symbolic import FunctionCodec, order_signature diff --git a/autstr/powerset.py b/autstr/powerset.py new file mode 100644 index 0000000..2f35394 --- /dev/null +++ b/autstr/powerset.py @@ -0,0 +1,319 @@ +"""The finite subsets of the naturals -- the structure MSO0. + +Finite subsets of :math:`\\mathbb{N}` under :math:`\\subseteq`, with singletons, +the successor on singletons and their order. A finite set is a +:math:`\\{0,1\\}`-word, position `i` set iff `i` is a member, so by Büchi's +theorem first-order logic over this structure is exactly **monadic second-order +logic** over :math:`(\\mathbb{N}, <)`: the definable sets of naturals are +precisely the regular ones. + + >>> M = MSO0().symbolic() + >>> x, y = M.vars("x y") + >>> ({0, 2}, {0, 1, 2}) in x.subset(y) + True + +That correspondence is why the structure is called MSO0, and why quantifying +over sets here costs no more than quantifying over elements elsewhere. +""" +from typing import List + +import itertools as it + +from autstr.presentations import ( + AutomaticPresentation, CompiledPresentation, +) +from autstr.utils.automata_tools import create_sparse_dfa + + +def _build_finite_powerset() -> AutomaticPresentation: + """The finite subsets of the naturals, compiled from scratch.""" + # Base alphabet for the presentation + base_alphabet = {'0', '1', '*'} + + # 1. Universe Automaton (U) - Includes empty set + universe = create_sparse_dfa( + states={'start', 'empty', 'zero', 'one', 'pad_one', 'pad_zero', 'reject'}, + input_symbols={'0', '1', '*'}, + transitions={ + 'start': { + '*': 'empty', + '0': 'zero', + '1': 'one' + }, + 'empty': { + '*': 'empty', + '0': 'reject', + '1': 'reject' + }, + 'zero': { + '*': 'pad_zero', + '0': 'zero', + '1': 'one' + }, + 'one': { + '*': 'pad_one', + '0': 'zero', + '1': 'one' + }, + 'pad_zero': { + '*': 'pad_zero', + '0': 'reject', + '1': 'reject' + }, + 'pad_one': { + '*': 'pad_one', + '0': 'reject', + '1': 'reject' + }, + 'reject': { + '0': 'reject', + '1': 'reject', + '*': 'reject' + } + }, + initial_state='start', + final_states={'start', 'one','empty', 'pad_one'} # Accepts empty set and non-empty sets + ) + + # 2. Subset Automaton (Subset) - Empty set is subset of all sets + subset = create_sparse_dfa( + states={'start', 'error'}, + input_symbols=set(it.product(base_alphabet, repeat=2)), + transitions={ + 'start': { + ('0','0'): 'start', + ('0','1'): 'start', + ('0','*'): 'start', + ('1','1'): 'start', + ('*','0'): 'start', + ('*','1'): 'start', + ('*','*'): 'start', + ('1','0'): 'error', + ('1','*'): 'error' + }, + 'error': {k: 'error' for k in it.product(base_alphabet, repeat=2)} + }, + initial_state='start', + final_states={'start'} + ) + + # 3. Singleton Automaton (Sing) - Empty set is not a singleton + sing = create_sparse_dfa( + states={'start', 'one', 'one_pad', 'many', 'reject'}, + input_symbols=base_alphabet, + transitions={ + 'start': { + '0': 'start', + '1': 'one', + '*': 'reject' # Reject empty set immediately + }, + 'one': { + '0': 'reject', + '1': 'many', + '*': 'one_pad' + }, + 'one_pad': { + '0': 'many', + '1': 'many', + '*': 'one_pad' + }, + 'many': { + '0': 'many', + '1': 'many', + '*': 'many' + }, + 'reject': { + '0': 'reject', + '1': 'reject', + '*': 'reject' + } + }, + initial_state='start', + final_states={'one','one_pad'} # Only non-empty singletons + ) + + # 4. Successor Automaton (Succ) - Empty set has no successor + succ = create_sparse_dfa( + states={'start', 'after_x', 'after_y', 'error'}, + input_symbols=set(it.product(base_alphabet, repeat=2)), + transitions={ + 'start': { + ('0','0'): 'start', + ('1','0'): 'after_x', + ('0','1'): 'error', + ('1','1'): 'error', + ('*','*'): 'error', # Reject empty set + ('0','*'): 'error', + ('1','*'): 'error', + ('*','0'): 'error', + ('*','1'): 'error' + }, + 'after_x': { + ('*','1'): 'after_y', + ('0','0'): 'error', + ('0','1'): 'error', + ('1','0'): 'error', + ('1','1'): 'error', + ('*','0'): 'error', + ('0','*'): 'error', + ('1','*'): 'error', + ('*','*'): 'error' + }, + 'after_y': { + ('*','*'): 'after_y', + ('0','0'): 'error', + ('0','1'): 'error', + ('1','0'): 'error', + ('1','1'): 'error', + ('*','0'): 'error', + ('*','1'): 'error', + ('0','*'): 'error', + ('1','*'): 'error' + }, + 'error': {k: 'error' for k in it.product(base_alphabet, repeat=2)} + }, + initial_state='start', + final_states={'after_y'} # Only consecutive singletons + ) + + # 5. Less-Than on Singletons Automaton (Lt_sing) - Empty set not involved + lt_sing = create_sparse_dfa( + states={'init', 'x_first', 'x_first_accept', 'error'}, + input_symbols=set(it.product(base_alphabet, repeat=2)), + transitions={ + 'init': { + ('0','0'): 'init', + ('0','1'): 'error', + ('1','0'): 'x_first', + ('1','1'): 'error', + ('*','*'): 'error', + ('0','*'): 'error', + ('*','0'): 'error', + ('1','*'): 'error', + ('*','1'): 'error' + }, + 'x_first': { + ('0','0'): 'x_first', + ('0','1'): 'x_first_accept', + ('1','0'): 'error', + ('1','1'): 'error', + ('*','*'): 'error', + ('0','*'): 'error', + ('*','0'): 'x_first', + ('1','*'): 'error', + ('*','1'): 'x_first_accept' + }, + 'x_first_accept': { + ('*','*'): 'x_first_accept', + ('0','0'): 'error', + ('0','1'): 'error', + ('1','0'): 'error', + ('1','1'): 'error', + ('*','0'): 'error', + ('*','1'): 'error', + ('0','*'): 'error', + ('1','*'): 'error' + }, + 'error': {k: 'error' for k in it.product(base_alphabet, repeat=2)} + }, + initial_state='init', + final_states={'x_first_accept'} # Only when first singleton < second + ) + + # Create the presentation with base automata + presentation = AutomaticPresentation({ + 'U': universe, + 'Subset': subset, + 'Sing': sing, + 'Succ': succ, + 'Lt_sing': lt_sing + }) + + # Define additional relations using formulas + presentation.update( + In="Sing(x) and Subset(x, y)", + Eq_set="Subset(x, y) and Subset(y, x)", + Leq_sing="Lt_sing(x, y) or (Eq_set(x, y) and Sing(x) and Sing(y))", + Gt_sing="not Leq_sing(x, y) and Sing(x) and Sing(y)", + Min="(forall z. not Subset(z, x)) or " # Empty set case + "(Sing(y) and Subset(y, x) and forall z. (-(Sing(z) and Subset(z, x)) or Leq_sing(y, z)))", + Max="Sing(y) and Subset(y, x) and forall z. (-(Sing(z) and Subset(z, x)) or Leq_sing(z, y))", + Intersect="forall a. (-Sing(a) or ((Subset(a, z) and (Subset(a, x) and Subset(a, y))) or (-Subset(a, z) and -(Subset(a, x) and Subset(a, y)))))", + Union="forall a. (-Sing(a) or ((Subset(a, z) and (Subset(a, x) or Subset(a, y))) or (-Subset(a, z) and -(Subset(a, x) or Subset(a, y)))))", + SetMinus="forall a. (-Sing(a) or ((Subset(a, z) and (Subset(a, x) and not Subset(a, y))) or (-Subset(a, z) and -(Subset(a, x) and not Subset(a, y)))))" + ) + + presentation.update( + Geq_sing="not Lt_sing(x, y) and Sing(x) and Sing(y)", + ) + + return presentation + + + + +# -------------------------------------------------------------------------- +# The presentation. It declares its own vocabulary, so `symbolic()` takes no +# argument; see `CompiledPresentation`. +# -------------------------------------------------------------------------- + +class MSO0(CompiledPresentation): + """The finite subsets of :math:`\\mathbb{N}` under :math:`\\subseteq`, with + singletons, successor and the order on singletons. + + >>> M = MSO0() + >>> x, y = M.symbolic().vars("x y") + >>> ({0, 2} , {0, 1, 2}) in x.subset(y) + True + >>> (x + y).eq({0, 1}).check() # union + True + + By Büchi's theorem, first-order logic over this structure is exactly + monadic second-order logic over :math:`(\\mathbb{N}, <)`, so the definable + sets are precisely the regular ones. + """ + + _BUILD = staticmethod(_build_finite_powerset) + + #: a set as a bitmask, position i set iff i is a member. The universe + #: rejects trailing zeros, so the encoding is the CANONICAL one: {0} is + #: `1`, {0, 2} is `101`, and the empty set is the empty word. + PADDING = '*' + + @staticmethod + def encode(s) -> List[str]: + """The word encoding a finite set of naturals: position `i` carries + ``1`` iff `i` is a member, up to the largest one.""" + s = set(s) + if not s: + return [] + if min(s) < 0: + raise ValueError(f"not a set of naturals: {s}") + return ['1' if i in s else '0' for i in range(max(s) + 1)] + + @staticmethod + def decode(word) -> set: + """The set encoded by a word, ignoring padding.""" + digits = ''.join(word).replace(MSO0.PADDING, '') + return {i for i, c in enumerate(digits) if c == '1'} + + def default_signature(self): + """Union as ``+``, intersection as ``*``, difference as binary ``-``, + and the relations of the structure as methods, with sets written as + Python sets.""" + from autstr.symbolic import FunctionCodec, Signature + signature = Signature(codec=FunctionCodec(self.encode, self.decode)) + signature.function('union', graph='Union', out=2) + signature.function('intersect', graph='Intersect', out=2) + signature.function('minus', graph='SetMinus', out=2) + signature.operator('+', 'union') + signature.operator('*', 'intersect') + signature.operator('-', 'minus') + signature.operator('eq', 'Eq_set') + signature.operator('subset', 'Subset') + signature.operator('sing', 'Sing') + # In(x, y): x is a singleton contained in y -- membership, for the + # singletons that stand in for the elements of N. + signature.operator('member_of', 'In') + signature.operator('lt_sing', 'Lt_sing') + return signature diff --git a/autstr/presentations.py b/autstr/presentations.py index f48cbb8..eda24a2 100644 --- a/autstr/presentations.py +++ b/autstr/presentations.py @@ -3,7 +3,7 @@ from autstr.sparse_automata import SparseDFA, SparseDFASerializer from autstr.utils.automata_tools import pad, unpad, projection, expand, stack -from autstr.buildin.automata import zero, one +from autstr.utils.automata_tools import zero, one from autstr.utils.logic import get_free_elementary_vars, optimize_query import json @@ -498,4 +498,25 @@ def _build_automaton(self, phi: logic.Expression, verbose=False, init=True) -> S print(f'{str(phi)}: {result.num_states} states') return result - raise ValueError(f"Unsupported expression type: {type(phi)}") \ No newline at end of file + raise ValueError(f"Unsupported expression type: {type(phi)}") + + +class CompiledPresentation(AutomaticPresentation): + """A presentation whose automata are compiled by a builder function. + + Subclasses set `_BUILD` to that function and declare their vocabulary in + `default_signature`, so the structure can be constructed with no arguments + and addressed symbolically with no setup. The builder's automata are + adopted as they are rather than passed to `AutomaticPresentation.__init__`, + which would restrict and pad relations the builder already restricted and + padded. + """ + + #: the builder, as a staticmethod on the subclass + _BUILD = None + + def __init__(self) -> None: + built = type(self)._BUILD() + self.padding_symbol = built.padding_symbol + self.automata = built.automata + self.sigma = built.sigma diff --git a/autstr/symbolic/backends.py b/autstr/symbolic/backends.py index 5ce4185..58dab12 100644 --- a/autstr/symbolic/backends.py +++ b/autstr/symbolic/backends.py @@ -9,7 +9,7 @@ from typing import Dict, List, Optional, Sequence -from autstr.buildin.automata import k_longer_automaton +from autstr.utils.automata_tools import k_longer_automaton from autstr.sparse_tree_automata import Tree, convolve_trees, tree_to_arrays from autstr.utils.tree_automata_tools import ( canonical as tree_canonical, iterate_trees, k_deeper_automaton, diff --git a/autstr/tree_algebra.py b/autstr/tree_algebra.py index 5eb152a..cea2ed4 100644 --- a/autstr/tree_algebra.py +++ b/autstr/tree_algebra.py @@ -106,7 +106,7 @@ def default_signature(self): The lattice operations are terms, not connectives: ``&``, ``|`` and ``~`` already mean conjunction, disjunction and negation of *formulas*, so binding them here would make ``x & y`` ambiguous — the same reason - `autstr.arithmetic` spells its divisibility relation + Büchi arithmetic spells its divisibility relation ``divided_by_power``. """ signature = Signature(codec=FunctionCodec(self.encode, self.decode)) diff --git a/autstr/buildin/tree_presentations.py b/autstr/tree_arithmetic.py similarity index 91% rename from autstr/buildin/tree_presentations.py rename to autstr/tree_arithmetic.py index ca264a9..385f191 100644 --- a/autstr/buildin/tree_presentations.py +++ b/autstr/tree_arithmetic.py @@ -1,10 +1,19 @@ -"""Built-in tree-automatic presentations. - -The flagship example is Skolem arithmetic, the multiplicative monoid -(N_{>0}, ·): multiplication of naturals is not string-automatic, but the -prime-factorization encoding makes it tree-automatic — the multiplicative -structure is the direct sum of countably many copies of (N, +), one per -prime, and a tree bundles the finitely many nonzero summands. +"""Skolem arithmetic: the multiplicative monoid of the naturals. + +:math:`(\\mathbb{N}_{>0}, \\cdot)` is *not* string-automatic, but it is +tree-automatic, and the prime factorization is why: multiplicatively the +structure is the direct sum of countably many copies of :math:`(\\mathbb{N}, ++)`, one per prime, and a tree bundles the finitely many nonzero summands. So +multiplication becomes addition of exponents, carried out in parallel across +the primes. + + >>> S = skolem_arithmetic().symbolic() + >>> x, y = S.vars("x y") + >>> (6, 35, 210) in (x * y).eq(S.vars("z")[0]) + True + +This is the tree-engine counterpart of `autstr.arithmetic`, whose additive +structures the string engine can present directly. """ from itertools import product as iter_product from typing import List, Optional diff --git a/autstr/uniform.py b/autstr/uniform.py index 8eb2e22..c34fbbc 100644 --- a/autstr/uniform.py +++ b/autstr/uniform.py @@ -19,8 +19,9 @@ from autstr.presentations import AutomaticPresentation, DeferredRelations from autstr.sparse_automata import SparseDFA -from autstr.buildin.automata import one -from autstr.buildin.presentations import create_sparse_dfa, encode_symbol +from autstr.utils.automata_tools import one +from autstr.utils.automata_tools import create_sparse_dfa +from autstr.utils.misc import encode_symbol from autstr.utils.automata_tools import expand, pad, permute_tapes, projection, word_automaton from autstr.utils.logic import get_free_elementary_vars from autstr.utils.misc import get_unique_id diff --git a/autstr/utils/automata_tools.py b/autstr/utils/automata_tools.py index 1882435..27fd973 100644 --- a/autstr/utils/automata_tools.py +++ b/autstr/utils/automata_tools.py @@ -1,14 +1,13 @@ import heapq import numpy as np from collections import deque -from typing import Callable, Dict, Generator, List, Set +from typing import Callable, Dict, Generator, List, Optional, Set, Tuple import itertools as it from autstr.mtbdd import NONE, var_tables from autstr.sparse_automata import ( SparseDFA, SparseNFA, _determinize_set_nfa, reduce_set_nfa, ) -from autstr.buildin.automata import one from autstr.utils.misc import decode_symbol, encode_symbol, complement @@ -801,3 +800,209 @@ def partial_dfa(base_alphabet: Set, arity: int, symbol_arity=arity, base_alphabet=set(base_alphabet), ).minimize() + + +# -------------------------------------------------------------------------- +# Basic automata the constructions above build on: fixed and relative word +# lengths, and the two constant languages. +# -------------------------------------------------------------------------- + +def length_automaton(n: int, base_alphabet: Set[int]) -> SparseDFA: + """ + Creates an automaton that recognizes all words over base_alphabet with length exactly n. + + :param n: The exact word length + :param base_alphabet: Set of integer symbols + :return: SparseDFA recognizing words of length n + """ + # States: 0 (start), 1, 2, ..., n (accepting), n+1 (dead) + num_states = n + 2 + # Default transitions: move to next state or dead state + default_states = np.array([i + 1 for i in range(n)] + [n + 1, n + 1], dtype=np.int32) + # No exceptions needed (same behavior for all symbols) + exception_symbols = np.full((num_states, 0), -1, dtype=np.int32) + exception_states = np.full((num_states, 0), -1, dtype=np.int32) + # Only state n is accepting + is_accepting = np.array([False] * n + [True, False]) + start_state = 0 + + return SparseDFA( + num_states=num_states, + default_states=default_states, + exception_symbols=exception_symbols, + exception_states=exception_states, + is_accepting=is_accepting, + start_state=start_state, + symbol_arity=1, + base_alphabet=base_alphabet + ) + +def k_longer_automaton(k: int, r: int, base_alphabet: Set[int], padding_symbol: int) -> SparseDFA: + """ + Creates an automaton recognizing (r+1)-tuples where the last word is at least k letters + longer than the other r words. + + :param k: Minimal length difference + :param r: Number of reference words + :param base_alphabet: Set of integer symbols + :param padding_symbol: Padding symbol integer + :return: SparseDFA for the k-longer condition + """ + # State mapping: [-1, 0, 1, ..., k] -> [0, 1, 2, ..., k+1] + state_mapping = {s: i for i, s in enumerate(range(-1, k+1))} + num_states = len(state_mapping) + sorted_alphabet = sorted(base_alphabet) + arity = r + 1 + + # Precompute all symbol tuples and their encodings + symbol_tuples = list(it.product(sorted_alphabet, repeat=arity)) + symbol_encodings = [encode_symbol(t, base_alphabet) for t in symbol_tuples] + + # Initialize DFA components + default_states = np.full(num_states, state_mapping[-1], dtype=np.int32) # Default to dead state + exception_list = [[] for _ in range(num_states)] + + # Build transitions + for state in range(-1, k+1): + state_idx = state_mapping[state] + for t, enc in zip(symbol_tuples, symbol_encodings): + # Compute next state + if state == -1: + next_state = -1 # Stay in dead state + else: + if all(x == padding_symbol for x in t[:-1]) and t[-1] != padding_symbol: + next_state = min(state + 1, k) # Count extra length + elif t[-1] == padding_symbol: + next_state = -1 # Reject if padding the last word + elif state == 0: + next_state = 0 # Wait for other words to end + else: + next_state = -1 # Reject otherwise + + next_state_idx = state_mapping[next_state] + if next_state_idx != state_mapping[-1]: + exception_list[state_idx].append((enc, next_state_idx)) + + # Find max exceptions needed + max_exceptions = max(len(ex_list) for ex_list in exception_list) if exception_list else 0 + + # Build exception arrays + exception_symbols = np.full((num_states, max_exceptions), -1, dtype=np.int32) + exception_states = np.full((num_states, max_exceptions), -1, dtype=np.int32) + + for i, ex_list in enumerate(exception_list): + if ex_list: + syms, states = zip(*ex_list) + exception_symbols[i, :len(syms)] = syms + exception_states[i, :len(states)] = states + + # Final states: state k (meaning we've counted k extra symbols) + is_accepting = np.array([i == state_mapping[k] for i in range(num_states)]) + + return SparseDFA( + num_states=num_states, + default_states=default_states, + exception_symbols=exception_symbols, + exception_states=exception_states, + is_accepting=is_accepting, + start_state=state_mapping[0], + symbol_arity=arity, + base_alphabet=base_alphabet + ) + + +def zero(symbol_arity: int = 1, base_alphabet: Optional[Set[int]] = None) -> SparseDFA: + """Automaton that rejects all inputs.""" + base_alphabet = base_alphabet or {0} + return SparseDFA( + num_states=1, + default_states=np.array([0], dtype=np.int32), + exception_symbols=np.full((1, 0), -1, dtype=np.int32), + exception_states=np.full((1, 0), -1, dtype=np.int32), + is_accepting=np.array([False]), + start_state=0, + symbol_arity=symbol_arity, + base_alphabet=base_alphabet + ) + +def one(symbol_arity: int = 1, base_alphabet: Optional[Set[int]] = None) -> SparseDFA: + """Automaton that accepts all inputs.""" + base_alphabet = base_alphabet or {0} + return SparseDFA( + num_states=1, + default_states=np.array([0], dtype=np.int32), + exception_symbols=np.full((1, 0), -1, dtype=np.int32), + exception_states=np.full((1, 0), -1, dtype=np.int32), + is_accepting=np.array([True]), + start_state=0, + symbol_arity=symbol_arity, + base_alphabet=base_alphabet + ) + + +def create_sparse_dfa(states: List[str], input_symbols: Set[Tuple[str]], + transitions: Dict[str, Dict[Tuple[str], str]], + initial_state: str, final_states: Set[str]) -> SparseDFA: + """Convert a traditional DFA description to a SparseDFA.""" + # Map states to integers + state_to_index = {s: i for i, s in enumerate(states)} + num_states = len(states) + + # Determine base alphabet and arity + base_alphabet = set() + for sym in input_symbols: + for char in sym: + base_alphabet.add(char) + arity = len(next(iter(input_symbols))) if input_symbols else 0 + + # Create state arrays + default_states = [] + exception_symbols = [] + exception_states = [] + is_accepting = np.array([state in final_states for state in states], dtype=bool) + + # Build symbol mapping + symbol_map = {} + for symbol in input_symbols: + symbol_map[symbol] = encode_symbol(symbol, frozenset(base_alphabet)) + + # Process each state + for state in states: + # Find most common transition + next_states = [transitions[state][sym] for sym in input_symbols] + default_target = max(set(next_states), key=next_states.count) + default_states.append(state_to_index[default_target]) + + # Collect exceptions + exceptions = [] + for symbol, next_state in transitions[state].items(): + if next_state != default_target: + sym_enc = symbol_map[symbol] + next_idx = state_to_index[next_state] + exceptions.append((sym_enc, next_idx)) + + # Sort exceptions by symbol for consistency + exceptions.sort(key=lambda x: x[0]) + exception_symbols.append([e[0] for e in exceptions]) + exception_states.append([e[1] for e in exceptions]) + + # Pad exceptions + max_exceptions = max(len(e) for e in exception_symbols) if exception_symbols else 0 + padded_ex_syms = np.full((num_states, max_exceptions), -1, dtype=np.int32) + padded_ex_states = np.full((num_states, max_exceptions), -1, dtype=np.int32) + + for i in range(num_states): + if exception_symbols[i]: + padded_ex_syms[i, :len(exception_symbols[i])] = exception_symbols[i] + padded_ex_states[i, :len(exception_states[i])] = exception_states[i] + + return SparseDFA( + num_states=num_states, + default_states=np.array(default_states, dtype=np.int32), + exception_symbols=padded_ex_syms, + exception_states=padded_ex_states, + is_accepting=is_accepting, + start_state=state_to_index[initial_state], + symbol_arity=arity, + base_alphabet=base_alphabet + ).minimize() diff --git a/autstr/utils/tree_automata_tools.py b/autstr/utils/tree_automata_tools.py index 21b7523..3971758 100644 --- a/autstr/utils/tree_automata_tools.py +++ b/autstr/utils/tree_automata_tools.py @@ -532,7 +532,7 @@ def k_deeper_automaton(k: int, references: int, base_alphabet, reference tape: some root-to-leaf path carries `k` nodes at which all `references` reference tapes are padding and the last tape is not. - The tree counterpart of `buildin.automata.k_longer_automaton`, and it + The tree counterpart of `automata_tools.k_longer_automaton`, and it supports exists-infinity for the same pumping reason. A tree's domain is closed under parents, so the nodes outside every reference's domain form a suffix of each root-to-leaf path; `k` of them make a body automaton with diff --git a/docs/source/overview.md b/docs/source/overview.md index e01ddf0..8bd4eac 100644 --- a/docs/source/overview.md +++ b/docs/source/overview.md @@ -17,7 +17,7 @@ An `AutomaticPresentation` bundles automata for a domain and its relations, and decides first-order statements about the presented structure: ```python -from autstr.buildin.presentations import BuechiArithmeticZ +from autstr.arithmetic import BuechiArithmeticZ Z = BuechiArithmeticZ() # (ℤ, +, <, |) as automata @@ -41,10 +41,10 @@ relation symbols and function symbols that compose with ordinary Python operators. ```python -from autstr.arithmetic import integers +from autstr.arithmetic import BuechiArithmeticZ -Z = integers() -x, y, z = Z.vars('x y z') +Z = BuechiArithmeticZ().symbolic() # no arguments: the structure knows its own +x, y, z = Z.vars('x y z') # vocabulary phi = (x + y).eq(z) & z.lt(100) # a relation over x, y, z phi.check() # satisfiable? — True @@ -61,10 +61,11 @@ restores the names in results. What a structure offers is declared in a `Signature`: which relations are function graphs, which operators they bind to, and how Python values encode as -elements. +elements. Every structure in the library declares its own, which is why +`symbolic()` above needed no argument. For a presentation you built yourself, +write one: ```python -from autstr.arithmetic import encode, decode from autstr.symbolic import Signature, FunctionCodec signature = (Signature(codec=FunctionCodec(encode, decode)) @@ -75,6 +76,11 @@ signature = (Signature(codec=FunctionCodec(encode, decode)) S = my_presentation.symbolic(signature) ``` +`encode` and `decode` are the structure's own — a Python value to the word that +represents it, and back — so results come out as the values you put in. Give a +presentation a `default_signature` method returning this, and its `symbolic()` +takes no argument either. + Results carry their tape order as variable *names*, so membership and enumeration are keyed by name rather than position: @@ -149,7 +155,7 @@ presentation encodes elements as strings and a word automaton reads them, a tree automaton — exactly the step from Büchi's theorem to Rabin's. `autstr.tree_uniform` hosts the classes whose advice is naturally a tree: a tree decomposition (bounded tree-width) or a k-expression (bounded clique-width), and -Skolem arithmetic (ℕ, ·) in `autstr.buildin.tree_presentations`, where a number +Skolem arithmetic (ℕ, ·) in `autstr.tree_arithmetic`, where a number is the tree of its prime exponents. The {doc}`graphs ` and {doc}`groups ` @@ -309,7 +315,7 @@ is **decidable**, and every definable relation is again automatic. ### A concrete encoding -In the arithmetic package an integer is written **sign-magnitude, least-significant +In Büchi arithmetic an integer is written **sign-magnitude, least-significant bit first**: the first symbol is a sign bit (`0` for non-negative, `1` for negative), followed by the binary digits of the magnitude from the lowest bit upward, with `*` padding the shorter arguments of a multi-tape relation so all diff --git a/notebooks/arithmetic_and_algebra.ipynb b/notebooks/arithmetic_and_algebra.ipynb index 341fa6f..a2d2057 100644 --- a/notebooks/arithmetic_and_algebra.ipynb +++ b/notebooks/arithmetic_and_algebra.ipynb @@ -35,9 +35,9 @@ "source": [ "## Presburger arithmetic over the integers\n", "\n", - "$(\\mathbb{Z}, +, <)$ is automatic in base 2. `autstr.arithmetic` exposes it through\n", - "a small term algebra, so an *infinite* definable set is an ordinary Python value you\n", - "can intersect, complement and iterate." + "$(\\mathbb{Z}, +, <)$ is automatic in base 2. Calling `.symbolic()` on the presentation\n", + "hands out variables that compose with Python operators, so an *infinite* definable set\n", + "is an ordinary Python value you can intersect, complement and iterate." ] }, { @@ -47,10 +47,10 @@ "metadata": {}, "outputs": [], "source": [ - "from autstr.arithmetic import integers\n", + "from autstr.arithmetic import BuechiArithmeticZ\n", "from time import time\n", "\n", - "Z = integers()\n", + "Z = BuechiArithmeticZ().symbolic() # the structure declares its own vocabulary\n", "x = Z.var('x')" ] }, @@ -212,7 +212,7 @@ "metadata": {}, "outputs": [], "source": [ - "from autstr.buildin.presentations import BuechiArithmetic\n", + "from autstr.arithmetic import BuechiArithmetic\n", "\n", "ba = BuechiArithmetic()\n", "ba.automata['A'].show_diagram() # the addition relation A(x, y, z): x + y = z" @@ -288,7 +288,7 @@ "metadata": {}, "outputs": [], "source": [ - "from autstr.buildin.tree_presentations import skolem_arithmetic\n", + "from autstr.tree_arithmetic import skolem_arithmetic\n", "\n", "skolem = skolem_arithmetic()\n", "print('360 = 2^3 * 3^2 * 5 round-trips to:', skolem.decode(skolem.encode(360)))\n", @@ -425,7 +425,7 @@ "metadata": {}, "outputs": [], "source": [ - "from autstr.buildin.presentations import MSO0\n", + "from autstr.powerset import MSO0\n", "\n", "mso0 = MSO0()\n", "print('relations:', mso0.get_relation_symbols())\n", diff --git a/pyproject.toml b/pyproject.toml index 17b795c..1110853 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,8 +73,6 @@ testpaths = ["tests"] [tool.setuptools.packages.find] include = ["autstr*"] -[tool.setuptools.package-data] -"autstr.buildin" = ["bin/*.autstr"] [tool.poetry-dynamic-versioning] enable = true diff --git a/scripts/gen_builtin_presentations.py b/scripts/gen_builtin_presentations.py deleted file mode 100644 index 22ac213..0000000 --- a/scripts/gen_builtin_presentations.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Regenerate the serialized built-in presentations in `autstr/buildin/bin`. - -The loaders (`BuechiArithmetic`, `BuechiArithmeticZ`, `MSO0`) deserialize these -artifacts with ``enforce_consistency=False`` — the stored automata are taken to -be already restricted to the universe. So whenever the construction of a -built-in presentation changes, or the consistency preparation itself does, the -artifacts have to be rebuilt from their generators: - - python scripts/gen_builtin_presentations.py [name ...] - -Rewriting an artifact also re-encodes it in the current serializer format, -which is bulkier than the one some of them were written with — so rebuild only -the ones whose content actually changed. -""" -import sys -from pathlib import Path - -from autstr.buildin.presentations import ( - buechi_arithmetic, buechi_arithmetic_Z, finite_powerset, -) - -BIN = Path(__file__).resolve().parent.parent / 'autstr' / 'buildin' / 'bin' - -TARGETS = { - 'buechi.autstr': buechi_arithmetic, - 'buechiZ.autstr': buechi_arithmetic_Z, - 'mso0.autstr': finite_powerset, -} - - -def main(names=()) -> None: - for filename, build in TARGETS.items(): - if names and filename not in names and Path(filename).stem not in names: - continue - presentation = build() - path = BIN / filename - presentation.automatic_presentation_to_file(str(path)) - print(f'{filename}: {len(presentation.automata)} automata ' - f'({", ".join(sorted(presentation.automata))})') - - -if __name__ == '__main__': - main(sys.argv[1:]) diff --git a/scripts/gen_readme_media.py b/scripts/gen_readme_media.py index 1cb6b52..337bd18 100755 --- a/scripts/gen_readme_media.py +++ b/scripts/gen_readme_media.py @@ -12,14 +12,14 @@ """ from pathlib import Path -from autstr.arithmetic import integers +from autstr.arithmetic import BuechiArithmeticZ MEDIA = Path(__file__).resolve().parent.parent / "docs" / "media" def coprime_automaton(primes): """The DFA for {x > 1 : no p in `primes` divides x}.""" - Z = integers() + Z = BuechiArithmeticZ().symbolic() x = Z.var("x") s = x.gt(1) for p in primes: diff --git a/tests/test_arithmetic.py b/tests/test_arithmetic.py index ce308b7..3dbc98d 100644 --- a/tests/test_arithmetic.py +++ b/tests/test_arithmetic.py @@ -3,12 +3,25 @@ import pytest -from autstr.arithmetic import decode, encode, integers +from autstr.arithmetic import BuechiArithmetic, BuechiArithmeticZ +from autstr.powerset import MSO0 + +encode, decode = BuechiArithmeticZ.encode, BuechiArithmeticZ.decode @pytest.fixture(scope='module') def Z(): - return integers() + return BuechiArithmeticZ().symbolic() + + +@pytest.fixture(scope='module') +def N(): + return BuechiArithmetic().symbolic() + + +@pytest.fixture(scope='module') +def sets(): + return MSO0().symbolic() def bounded(formula, limit=6): @@ -159,3 +172,69 @@ def test_enumeration_yields_integers(Z): x, y = Z.vars('x y') formula = (x + y).eq(4) & x.gt(0) & x.lt(y) assert sorted(islice(iter(formula), 5)) == [(1, 3)] + + +# ---------------------------------------------------------------------- +# the naturals: the same interface, without negation +# ---------------------------------------------------------------------- + +@pytest.mark.parametrize('n', [0, 1, 2, 7, 8, 255]) +def test_natural_encode_decode_roundtrip(n): + assert BuechiArithmetic.decode(BuechiArithmetic.encode(n)) == n + + +def test_naturals_reject_negatives(): + with pytest.raises(ValueError): + BuechiArithmetic.encode(-1) + + +def test_naturals_add(N): + x, y = N.vars('x y') + relation = (x + y).eq(12).evaluate() + assert relation.contains(x=5, y=7) + assert not relation.contains(x=5, y=8) + + +def test_naturals_have_a_least_element(N): + x, y = N.vars('x y') + # unbounded above, but 0 has nothing below it -- the difference from Z + assert x.lt(y).drop(y).all(x).check() + assert not y.lt(x).drop(y).all(x).check() + + +# ---------------------------------------------------------------------- +# MSO0: finite sets as Python sets +# ---------------------------------------------------------------------- + +@pytest.mark.parametrize('s', [set(), {0}, {1}, {0, 2}, {0, 1, 2}, {5}]) +def test_set_encode_decode_roundtrip(s): + assert MSO0.decode(MSO0.encode(s)) == s + + +def test_set_encoding_is_canonical(): + # the universe rejects trailing zeros, so {0} is `1`, not `100` + assert MSO0.encode({0}) == ['1'] + assert MSO0.encode({0, 2}) == ['1', '0', '1'] + assert MSO0.encode(set()) == [] + + +def test_set_operations(sets): + x, y, z = sets.vars('x y z') + assert ({0, 1}, {1, 2}, {0, 1, 2}) in (x + y).eq(z) # union + assert ({0, 1}, {1, 2}, {1}) in (x * y).eq(z) # intersection + assert ({0, 1}, {1, 2}, {0}) in (x - y).eq(z) # difference + + +def test_subset_and_singletons(sets): + x, y = sets.vars('x y') + assert ({0, 2}, {0, 1, 2}) in x.subset(y) + assert ({0, 2}, {0, 1}) not in x.subset(y) + assert ({1},) in x.sing() + assert ({0, 1},) not in x.sing() + assert ({1}, {0, 1, 2}) in x.member_of(y) + + +def test_enumeration_yields_sets(sets): + x, y = sets.vars('x y') + solutions = list(islice(iter((x + y).eq({0, 1}) & x.subset(y)), 3)) + assert all(isinstance(a, set) and a | b == {0, 1} for a, b in solutions) diff --git a/tests/test_infinite_graphs.py b/tests/test_infinite_graphs.py index 1233921..d370990 100644 --- a/tests/test_infinite_graphs.py +++ b/tests/test_infinite_graphs.py @@ -8,8 +8,10 @@ """ import pytest -from autstr.arithmetic import decode, encode -from autstr.buildin.presentations import BuechiArithmeticZ +from autstr.arithmetic import BuechiArithmeticZ + +encode, decode = BuechiArithmeticZ.encode, BuechiArithmeticZ.decode +from autstr.arithmetic import BuechiArithmeticZ from autstr.infinite_graphs import InfiniteGraph from autstr.symbolic import FunctionCodec diff --git a/tests/test_interpretations.py b/tests/test_interpretations.py index 0cbdef2..4fd7b50 100644 --- a/tests/test_interpretations.py +++ b/tests/test_interpretations.py @@ -3,8 +3,10 @@ import pytest -from autstr.arithmetic import decode, encode -from autstr.buildin.presentations import BuechiArithmeticZ +from autstr.arithmetic import BuechiArithmeticZ + +encode, decode = BuechiArithmeticZ.encode, BuechiArithmeticZ.decode +from autstr.arithmetic import BuechiArithmeticZ from autstr.infinite_graphs import IntegerGrid, InfiniteGraph from autstr.interpretations import interpret from autstr.symbolic import FunctionCodec diff --git a/tests/test_ordinals.py b/tests/test_ordinals.py index af2a9f2..24400ce 100644 --- a/tests/test_ordinals.py +++ b/tests/test_ordinals.py @@ -132,7 +132,7 @@ def test_repr(self, omega_squared): def test_omega_is_the_naturals(self, omega): # dimension 1: the interpretation is a definable reduct of ℕ, so the # order relation is the source's own, minimized - from autstr.buildin.presentations import BuechiArithmetic + from autstr.arithmetic import BuechiArithmetic assert omega.presentation.automata['Lt'].num_states == \ BuechiArithmetic().automata['Lt'].num_states diff --git a/tests/test_presentations.py b/tests/test_presentations.py index 5298437..18c28a1 100644 --- a/tests/test_presentations.py +++ b/tests/test_presentations.py @@ -10,10 +10,9 @@ import pytest -from autstr.buildin.automata import zero -from autstr.buildin.presentations import ( - BuechiArithmetic, BuechiArithmeticZ, MSO0, -) +from autstr.utils.automata_tools import zero +from autstr.arithmetic import BuechiArithmetic, BuechiArithmeticZ +from autstr.powerset import MSO0 from autstr.interpretations import interpret from autstr.presentations import AutomaticPresentation diff --git a/tests/test_serialization.py b/tests/test_serialization.py index bc863cf..6ebfcbf 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -1,6 +1,6 @@ import numpy as np -from autstr.buildin.presentations import BuechiArithmeticZ +from autstr.arithmetic import BuechiArithmeticZ from autstr.sparse_automata import SparseDFA, SparseDFASerializer diff --git a/tests/test_skolem.py b/tests/test_skolem.py index e49e080..3c9a8e5 100644 --- a/tests/test_skolem.py +++ b/tests/test_skolem.py @@ -3,7 +3,7 @@ import pytest -from autstr.buildin.tree_presentations import SkolemArithmetic, _prime +from autstr.tree_arithmetic import SkolemArithmetic, _prime from autstr.sparse_tree_automata import Tree enc = SkolemArithmetic.encode diff --git a/tests/test_symbolic.py b/tests/test_symbolic.py index 4f0abe1..54ff991 100644 --- a/tests/test_symbolic.py +++ b/tests/test_symbolic.py @@ -3,7 +3,7 @@ import pytest -from autstr.buildin.presentations import BuechiArithmeticZ +from autstr.arithmetic import BuechiArithmeticZ from autstr.symbolic import ( CompileError, FunctionCodec, Signature, SymbolicSymbolError, graph_signature, order_signature, relational_signature, @@ -330,11 +330,19 @@ def test_bare_structure_needs_no_signature(): def test_constants_need_a_codec(): - S = BuechiArithmeticZ().symbolic() + # a signature that declares no codec cannot turn a Python value into an + # element, even over a structure whose own signature would + S = BuechiArithmeticZ().symbolic(Signature()) with pytest.raises(SymbolicSymbolError, match='no element codec'): S.const(3) +def test_the_structures_own_signature_brings_a_codec(): + S = BuechiArithmeticZ().symbolic() + x, = S.vars('x') + assert (3,) in x.eq(3) + + # ---------------------------------------------------------------------- # uniformly automatic classes # ---------------------------------------------------------------------- diff --git a/tests/test_symbolic_tree.py b/tests/test_symbolic_tree.py index 5c7fbf8..82f0300 100644 --- a/tests/test_symbolic_tree.py +++ b/tests/test_symbolic_tree.py @@ -8,7 +8,7 @@ import pytest -from autstr.buildin.tree_presentations import SkolemArithmetic +from autstr.tree_arithmetic import SkolemArithmetic from autstr.sparse_tree_automata import convolve_trees from autstr.symbolic import ( CompileError, FunctionCodec, Signature, SymbolicSymbolError, diff --git a/tests/test_tree_interpretations.py b/tests/test_tree_interpretations.py index 789a9b6..f47d4fa 100644 --- a/tests/test_tree_interpretations.py +++ b/tests/test_tree_interpretations.py @@ -16,7 +16,7 @@ import pytest -from autstr.buildin.tree_presentations import SkolemArithmetic +from autstr.tree_arithmetic import SkolemArithmetic from autstr.interpretations import _EQUIV, _tree_representatives, interpret from autstr.sparse_tree_automata import Tree, convolve_trees from autstr.tree_presentations import TreeAutomaticPresentation diff --git a/tests/test_tree_presentations.py b/tests/test_tree_presentations.py index d2019e6..e3965b3 100644 --- a/tests/test_tree_presentations.py +++ b/tests/test_tree_presentations.py @@ -3,7 +3,7 @@ import numpy as np import pytest -from autstr.buildin.presentations import BuechiArithmeticZ +from autstr.arithmetic import BuechiArithmeticZ from autstr.sparse_automata import SparseDFA from autstr.sparse_tree_automata import Tree from autstr.tree_presentations import TreeAutomaticPresentation From dec437a93cd8199b9cd02615bc76df39f97f6ccd Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Thu, 13 Aug 2026 20:40:02 +0200 Subject: [PATCH 67/76] Notebooks: sets as sets, and a formula that says what its label claims The abelian-groups cell was labelled "has an element of order 4" and asked for an element of order 2: `A(x,x,x)` spells x = 0, so `A(x,x,z) and A(z,z,z)` says 2x = 0. Z/2 alone satisfied it. The corrected formula asks for y = 2x, z = 2y = 0 with x and y both nonzero, which separates the two groups of order 4 -- Z/4 has such an element, Z/2 + Z/2 does not. That cell is also where the symbolic interface earns its place: with `+` and `.eq` the query reads as the mathematics, and a class has no constant symbols, so the identity is named by what defines it (t + t = t). MSO0 gains its codec's payoff: union, intersection and difference as `+`, `*`, `-`, membership and subset as methods, and solutions that come back as Python sets -- the eight ways to split {0,1,2} in two, enumerated from the automaton. Co-Authored-By: Claude Opus 5 --- notebooks/arithmetic_and_algebra.ipynb | 48 ++++++++++++++++++++++++- notebooks/groups.ipynb | 49 ++++++++++++++++++++++---- 2 files changed, 90 insertions(+), 7 deletions(-) diff --git a/notebooks/arithmetic_and_algebra.ipynb b/notebooks/arithmetic_and_algebra.ipynb index a2d2057..ec53ce8 100644 --- a/notebooks/arithmetic_and_algebra.ipynb +++ b/notebooks/arithmetic_and_algebra.ipynb @@ -415,7 +415,11 @@ "and order on singletons, form the structure MSO0. A finite set is a $\\{0,1\\}$-word\n", "(position $i$ set iff $i$ is a member), and — by Büchi's theorem — first-order logic\n", "over MSO0 is precisely **monadic second-order** logic over $(\\mathbb{N}, <)$: the\n", - "definable sets are exactly the regular ones." + "definable sets are exactly the regular ones.\n", + "\n", + "The encoding is *canonical*: the universe rejects trailing zeros, so $\\{0\\}$ is the\n", + "word `1` and $\\emptyset$ is the empty word. The structure's codec knows this, so\n", + "ordinary Python sets go in and come out." ] }, { @@ -447,6 +451,48 @@ " mso0.check('exists x.(Sing(x) and all y.((not Sing(y)) or Leq_sing(x,y)))'))" ] }, + { + "cell_type": "markdown", + "id": "4fa93591", + "metadata": {}, + "source": [ + "### Sets as Python sets\n", + "\n", + "Symbolically the Boolean operations are the arithmetic ones: union is `+`, intersection\n", + "`*`, and set difference binary `-`. Membership, subset and singleton-hood are methods." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "72e189ba", + "metadata": {}, + "outputs": [], + "source": [ + "S = mso0.symbolic()\n", + "x, y, z = S.vars('x y z')\n", + "\n", + "print('{0,1} u {1,2} = {0,1,2}:', ({0, 1}, {1, 2}, {0, 1, 2}) in (x + y).eq(z))\n", + "print('{0,1} n {1,2} = {1}: ', ({0, 1}, {1, 2}, {1}) in (x * y).eq(z))\n", + "print('{0,1} \\\\ {1,2} = {0}: ', ({0, 1}, {1, 2}, {0}) in (x - y).eq(z))\n", + "print('{0,2} subset {0,1,2}: ', ({0, 2}, {0, 1, 2}) in x.subset(y))\n", + "\n", + "# Solutions come back as sets: the ways to split {0,1,2} into two disjoint pieces.\n", + "disjoint_cover = (x + y).eq({0, 1, 2}) & (x * y).eq(set())\n", + "for a, b in sorted(disjoint_cover, key=lambda p: (len(p[0]), sorted(p[0]))):\n", + " print(f' {a or set()} + {b or set()}')" + ] + }, + { + "cell_type": "markdown", + "id": "e25efbf0", + "metadata": {}, + "source": [ + "Each of those is a first-order query over MSO0, which is to say a **monadic\n", + "second-order** query over $(\\mathbb{N}, <)$ — quantifying over sets here costs no more\n", + "than quantifying over elements does elsewhere." + ] + }, { "cell_type": "markdown", "id": "3da49763", diff --git a/notebooks/groups.ipynb b/notebooks/groups.ipynb index 151f760..a271364 100644 --- a/notebooks/groups.ipynb +++ b/notebooks/groups.ipynb @@ -49,14 +49,51 @@ "from autstr.groups import FiniteAbelianGroups\n", "\n", "ab = FiniteAbelianGroups()\n", - "print('A((1,1),(1,2),(0,0)) in Z2+Z3:', ab.check('A(x,y,z)', [2, 3], x=(1, 1), y=(1, 2), z=(0, 0)))\n", + "print('A((1,1),(1,2),(0,0)) in Z2+Z3:', ab.check('A(x,y,z)', [2, 3], x=(1, 1), y=(1, 2), z=(0, 0)))" + ] + }, + { + "cell_type": "markdown", + "id": "fbb35bc7", + "metadata": {}, + "source": [ + "### The same queries without formula strings\n", "\n", - "# One compiled automaton, evaluated over many groups: \"has an element of order 4\".\n", - "phi = 'exists x.((not A(x,x,x)) and (exists z.(A(x,x,z) and A(z,z,z))))'\n", - "dfa, _ = ab.evaluate(phi)\n", - "for orders in ([4], [3], [2, 3], [5, 6]):\n", + "`symbolic()` hands out variables that compose with Python operators. For an abelian\n", + "class that is `+` and `.eq`; the class has no constants of its own, so the identity is\n", + "named by what defines it, $t + t = t$. One formula compiles **once for the class** and\n", + "then decides any member." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d985b332", + "metadata": {}, + "outputs": [], + "source": [ + "S = ab.symbolic()\n", + "x, y, z = S.vars('x y z')\n", + "\n", + "is_zero = lambda t: (t + t).eq(t) # a class has no constant symbols\n", + "\n", + "# \"has an element of order 4\": y = 2x, z = 2y = 0, with x and y both nonzero\n", + "order_four = ((x + x).eq(y) & (y + y).eq(z) & is_zero(z)\n", + " & ~is_zero(x) & ~is_zero(y))\n", + "\n", + "for orders in ([4], [2, 2], [3], [2, 3], [8]):\n", " print(f'orders={orders}: has an order-4 element =',\n", - " dfa.accepts([(s,) for s in ab.advice(orders)]))" + " S.check_member(order_four, ab.advice(orders)))" + ] + }, + { + "cell_type": "markdown", + "id": "5b736df5", + "metadata": {}, + "source": [ + "The two groups of order 4 come apart here: $\\mathbb{Z}_4$ has such an element and\n", + "$\\mathbb{Z}_2 \\oplus \\mathbb{Z}_2$ does not — decided by running each group's advice\n", + "word through one automaton." ] }, { From e291b40241768832c720219481679b6a033ebd0e Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Thu, 13 Aug 2026 20:59:40 +0200 Subject: [PATCH 68/76] Interpretations, and three bugs that writing the notebook found The composition notebook becomes "building new structures from old": the integers constructed from the naturals as a 2-dimensional quotient interpretation, which is small enough to read and real enough to be worth saving. It carries the codec story -- the construction is correct but illegible until a codec says that the pair (a, b) denotes a - b -- and closes on the shipped example of the same idea, `autstr.ordinals`. A new notebook, `infinite_structures`, covers what had none: the integer grid, the regular tree, Turing configuration graphs and level 2 collapsible pushdown graphs. The last two are the point. Both present computation as a graph and both have a decidable first-order theory, but reachability is the halting problem for one and a relation of the other -- so `Reach` is there to be queried, and transitivity over all runs of all lengths is decided in under a second. Writing those two turned up three real defects, none of which any test saw: - ENUMERATION WAS BROKEN for every interpreted structure of dimension > 1, including the shipped `Ordinal(2)`. `iterate_language` built each tape by string-concatenating letters, which holds only while a letter is one character; over a product alphabet a letter is a tuple, and str() flattened ('0','1') into text no codec could read. Tapes are tuples of letters now. Membership worked throughout, which is why this went unseen. - SERIALIZING SUCH A PRESENTATION could not be reloaded: JSON has no tuples, so the alphabet came back as a set of lists (unhashable) and the padding symbol as a list. Both are restored on load. - The v2 back-compat loader lost its only test when the `.autstr` artifacts went away -- `test_the_legacy_flat_payload_still_loads` was left building a presentation and querying it, touching no legacy payload at all. It now constructs a v2 payload by hand and reads it. Also: the abelian-groups notebook cell and `is_deterministic`'s docstring, which still said reachability was not built. Co-Authored-By: Claude Opus 5 --- .claude/settings.json | 460 ++++++++++++++++++++++++++++ autstr/collapsible.py | 5 +- autstr/presentations.py | 8 +- autstr/sparse_automata.py | 15 +- autstr/utils/automata_tools.py | 16 +- notebooks/composition.ipynb | 216 ++++++++++++- notebooks/infinite_structures.ipynb | 275 +++++++++++++++++ tests/test_serialization.py | 84 ++++- 8 files changed, 1060 insertions(+), 19 deletions(-) create mode 100644 .claude/settings.json create mode 100644 notebooks/infinite_structures.ipynb diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..be571f1 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,460 @@ +{ + "permissions": { + "allow": [ + "Bash(PYTHONPATH=/var/home/faried/projects/AutStr timeout 1200 /tmp/claude-1000/-var-home-faried-projects-AutStr/200ddc4a-a4d7-45bc-8b9e-c7c87eb6ef00/scratchpad/venv-nojax/bin/python -c ' *)", + "WebSearch", + "Bash(PYTHONPATH=. timeout 1800 /tmp/claude-1000/-var-home-faried-projects-AutStr/200ddc4a-a4d7-45bc-8b9e-c7c87eb6ef00/scratchpad/venv-nojax/bin/python -m pytest tests/ -q)", + "Bash(git add *)", + "Bash(git commit -m ' *)", + "Bash(git push *)", + "Bash(awk '{print $2}')", + "Read(//home/faried/.config/pypoetry/**)", + "Bash(poetry config *)", + "Bash(/tmp/claude-1000/-var-home-faried-projects-AutStr/200ddc4a-a4d7-45bc-8b9e-c7c87eb6ef00/scratchpad/venv-nojax/bin/pip install *)", + "Bash(git tag *)", + "Bash(git checkout *)", + "Bash(git merge *)", + "Bash(poetry publish *)", + "Bash(curl -s https://pypi.org/pypi/autstr/json)", + "Bash(python3 -m venv /tmp/claude-1000/-var-home-faried-projects-AutStr/200ddc4a-a4d7-45bc-8b9e-c7c87eb6ef00/scratchpad/venv-pypi)", + "Bash(/tmp/claude-1000/-var-home-faried-projects-AutStr/200ddc4a-a4d7-45bc-8b9e-c7c87eb6ef00/scratchpad/venv-pypi/bin/pip install *)", + "Bash(ls /tmp/claude-1000/-var-home-faried-projects-AutStr/200ddc4a-a4d7-45bc-8b9e-c7c87eb6ef00/scratchpad/venv/bin/python /tmp/claude-1000/-var-home-faried-projects-AutStr/200ddc4a-a4d7-45bc-8b9e-c7c87eb6ef00/scratchpad/venv-nojax/bin/python)", + "Bash(ulimit -v 6000000)", + "Bash(PYTHONPATH=/var/home/faried/projects/AutStr timeout 600 /tmp/claude-1000/-var-home-faried-projects-AutStr/200ddc4a-a4d7-45bc-8b9e-c7c87eb6ef00/scratchpad/venv-nojax/bin/python -u /tmp/claude-1000/-var-home-faried-projects-AutStr/200ddc4a-a4d7-45bc-8b9e-c7c87eb6ef00/scratchpad/probe_3col.py)", + "Bash(mkdir -p /var/home/faried/projects/AutStr/benchmarks)", + "Read(//tmp/claude-1000/-var-home-faried-projects-AutStr/200ddc4a-a4d7-45bc-8b9e-c7c87eb6ef00/tasks/**)", + "Bash(grep -v \"^$\")", + "Bash(rm -f abelian_even_order_curve.png pathwidth_curves.png)", + "Bash(rm -rf __pycache__)", + "Bash(systemd-run --user --scope -p MemoryMax=5G -p MemorySwapMax=0 --quiet env PYTHONPATH=/var/home/faried/projects/AutStr /tmp/claude-1000/-var-home-faried-projects-AutStr/200ddc4a-a4d7-45bc-8b9e-c7c87eb6ef00/scratchpad/venv-nojax/bin/python -u /tmp/claude-1000/-var-home-faried-projects-AutStr/200ddc4a-a4d7-45bc-8b9e-c7c87eb6ef00/scratchpad/readme_examples.py)", + "Bash(git reset *)", + "Bash(git restore *)", + "Bash(git commit -q -m ' *)", + "Bash(python3 -m venv /tmp/claude-1000/-var-home-faried-projects-AutStr/200ddc4a-a4d7-45bc-8b9e-c7c87eb6ef00/scratchpad/venv-nojax)", + "Bash(awk '{print $3, $4, $11, $12, $13}')", + "Bash(pkill -f \"pytest /var/home/faried/projects/AutStr/tests/test_tree_automata\")", + "Bash(systemd-run --user --scope -p MemoryMax=4G --quiet env PYTHONPATH=/var/home/faried/projects/AutStr timeout -s ABRT 25 /tmp/claude-1000/-var-home-faried-projects-AutStr/200ddc4a-a4d7-45bc-8b9e-c7c87eb6ef00/scratchpad/venv-nojax/bin/python -X faulthandler -c ' *)", + "Bash(timeout 20 /tmp/claude-1000/-var-home-faried-projects-AutStr/200ddc4a-a4d7-45bc-8b9e-c7c87eb6ef00/scratchpad/venv-nojax/bin/python -c ' *)", + "Bash(env PYTHONPATH=/var/home/faried/projects/AutStr timeout 45 /tmp/claude-1000/-var-home-faried-projects-AutStr/200ddc4a-a4d7-45bc-8b9e-c7c87eb6ef00/scratchpad/venv-nojax/bin/python -m pytest /var/home/faried/projects/AutStr/tests/test_tree_automata.py -x -v)", + "Bash(systemd-run --user --scope -p MemoryMax=4G --quiet env PYTHONPATH=/var/home/faried/projects/AutStr timeout 600 /tmp/claude-1000/-var-home-faried-projects-AutStr/200ddc4a-a4d7-45bc-8b9e-c7c87eb6ef00/scratchpad/venv-nojax/bin/python -u -c ' *)", + "Bash(awk '{print $3, $4}')", + "Bash(awk '{print $3, $4, $10}')", + "Bash(python3 -)", + "Bash(systemd-run --user --scope -q -p MemoryMax=4G -p MemorySwapMax=0 /tmp/claude-1000/-var-home-faried-projects-AutStr/200ddc4a-a4d7-45bc-8b9e-c7c87eb6ef00/scratchpad/venv-nojax/bin/python -u -c ' *)", + "Bash(grep -v \"def \\\\|class \\\\|-> \\\\|:.*SparseTreeAutomaton$\")", + "Read(//tmp/claude-1000/-var-home-faried-projects-AutStr/**)", + "Bash(python3 -c \"import numpy, nltk, pytest; print\\('ok'\\)\")", + "Bash(/tmp/claude-1000/-var-home-faried-projects-AutStr/200ddc4a*/scratchpad/venv/bin/python -c \"import numpy,nltk,pytest;print\\('venv ok', numpy.__version__\\)\")", + "Bash(./venv/bin/python -c \"import numpy,nltk,pytest;print\\('venv ok',numpy.__version__\\)\")", + "Bash(PYTHONPATH=. /tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/python -m pytest tests/test_tree_automata.py tests/test_tree_tools.py tests/test_tree_presentations.py tests/test_skolem.py tests/test_tree_uniform.py tests/test_tree_graphs.py tests/test_tree_groups.py -q)", + "Bash(PYTHONPATH=. /tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/python -m pytest tests/test_skolem.py -q)", + "Bash(/tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/pip install *)", + "Bash(cat head.py body.py)", + "Bash(PYTHONPATH=/var/home/faried/projects/AutStr ./venv/bin/python -c ' *)", + "Bash(PYTHONPATH=. timeout 300 /tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/python -m pytest tests/test_tree_automata.py -q)", + "Bash(PYTHONPATH=. timeout 600 /tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/python -m pytest tests/test_tree_tools.py -q)", + "Bash(PYTHONPATH=. /tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/python -m pytest tests/test_tree_tools.py --collect-only -q)", + "Bash(PYTHONPATH=. timeout 900 /tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/python -m pytest tests/test_tree_presentations.py tests/test_skolem.py -q)", + "Bash(PYTHONPATH=. timeout 900 /tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/python -m pytest tests/test_tree_uniform.py tests/test_tree_graphs.py tests/test_tree_groups.py -q)", + "Bash(PYTHONPATH=. timeout 900 /tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/python -m pytest tests/ -q)", + "Bash(systemd-run --user --scope -q -p MemoryMax=4G -p MemorySwapMax=0 env PYTHONPATH=. AUTSTR_HEAVY=1 timeout 1200 /tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/python -m pytest tests/test_tree_graphs.py -q)", + "Bash(sed -i 's/len\\(grp.presentation.base_alphabet\\)/len\\(sta.base_alphabet\\)/' /tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/func3.py)", + "Bash(systemd-run --user --scope -q -p MemoryMax=5G -p MemorySwapMax=0 env PYTHONPATH=. timeout 540 /tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/python /tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/func3.py)", + "Bash(pkill -f heavy.py)", + "Bash(pkill -f prof.py)", + "Bash(echo \"exit=$?\")", + "Bash(/tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/python -m pyflakes autstr/sparse_automata.py)", + "Bash(PYTHONPATH=. timeout 600 /tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/python -m pytest tests/test_arithmetic.py -q)", + "Bash(PYTHONPATH=. timeout 1200 /tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/python -m pytest tests/ -q)", + "Bash(PYTHONPATH=. timeout 1200 /tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/python -m pytest tests/ -q --durations=8)", + "Bash(/tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/python -m pyflakes autstr/mtbdd.py autstr/sparse_automata.py autstr/sparse_tree_automata.py autstr/utils/automata_tools.py autstr/utils/tree_automata_tools.py)", + "Bash(PYTHONPATH=. timeout 600 /tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/python -m pytest tests/test_tree_tools.py tests/test_tree_presentations.py tests/test_tree_uniform.py tests/test_tree_graphs.py -q)", + "Bash(git commit *)", + "Bash(/tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/python -m pyflakes autstr/mtbdd.py autstr/sparse_automata.py)", + "Bash(./venv/bin/python -c \"import numpy,nltk,pytest,graphviz,networkx;print\\('venv rebuilt'\\)\")", + "Bash(sed -n '/def _quotient_tree_nfa/,/^ # ---- rebuild/p' autstr/utils/tree_automata_tools.py)", + "Bash(PYTHONPATH=. timeout 600 /tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/python /tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/congr.py)", + "Bash(PYTHONPATH=. timeout 900 /tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/python /tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/min2.py)", + "Bash(systemd-run --user --scope -q -p MemoryMax=3G -p MemorySwapMax=0 env PYTHONPATH=. timeout 540 /tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/python -m pytest tests/test_clique_width.py -q)", + "Bash(PYTHONPATH=. /tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/python -c ' *)", + "Bash(systemd-run --user --scope -q -p MemoryMax=3G -p MemorySwapMax=0 env PYTHONPATH=.. timeout 540 /tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/python tree_class_benchmark.py --profile light --max-exp 4)", + "Read(//var/home/faried/projects/**)", + "Bash(PYTHONPATH=.. timeout 420 /tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/python uniform_graph_benchmark.py --profile light --max-exp 3)", + "Bash(sed -n '/^## Compiling 3-colourability/,/^## /p' benchmarks/README.md)", + "Bash(/tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/python -)", + "Bash(PYTHONPATH=.. timeout 900 /tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/jupyter nbconvert --to notebook --execute --inplace tree_classes.ipynb)", + "Bash(python3 -c ' *)", + "Bash(distrobox list *)", + "Bash(podman ps *)", + "Bash(timeout 300 distrobox enter devbox -- sh -c 'command -v dot || \\(sudo dnf install -y graphviz >/dev/null 2>&1; command -v dot\\)')", + "Bash(timeout 300 distrobox enter devbox -- sh -c 'PYTHONPATH=. /tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/python -c \" *)", + "Bash(timeout 1800 distrobox enter devbox -- sh -c 'cd __TRACKED_VAR__/notebooks && for nb in arithmetic mso0 treedepth_graphs; do echo \"--- $nb\"; PYTHONPATH=.. timeout 900 /tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/jupyter nbconvert --to notebook --execute --inplace $nb.ipynb > /tmp/nbout 2>&1 && echo '\\\\'' ok'\\\\'' || { echo '\\\\'' FAILED'\\\\''; tail -5 /tmp/nbout; }; done')", + "Bash(timeout 600 distrobox enter devbox -- env PYTHONPATH=.. /tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/jupyter nbconvert --to notebook --execute --inplace arithmetic.ipynb)", + "Bash(echo \"rc=$?\")", + "Read(//tmp/**)", + "Bash(pkill -9 -f jupyter-nbconvert)", + "Bash(pkill -9 -f ipykernel_launcher)", + "Bash(git status *)", + "Bash(PYTHONPATH=. /tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/python -c 'import autstr; print\\('\\\\''autstr'\\\\'', autstr.__version__\\)')", + "Bash(/tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/python -m pyflakes autstr/mtbdd.py autstr/sparse_automata.py tests/test_closure.py)", + "Bash(/tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/python -m pyflakes autstr/closure.py tests/test_closure.py)", + "Bash(/tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/python -m pyflakes autstr/closure.py autstr/presentations.py)", + "Bash(git mv *)", + "Bash(/tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/python -m pyflakes autstr/algebra.py autstr/groups.py autstr/composition.py tests/test_algebra.py tests/test_groups.py tests/test_composition.py)", + "Bash(PYTHONPATH=. /tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/python -c \"import autstr; print\\('version', autstr.__version__\\)\")", + "Bash(/tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/sphinx-apidoc -f -o docs/source/api autstr)", + "Bash(/tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/sphinx-build -b html docs/source docs/build/html)", + "Bash(/tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/python -c ' *)", + "Bash(env -u PYTHONPATH timeout 1200 /tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/pytest -q)", + "Bash(git check-ignore *)", + "Bash(git fetch *)", + "Bash(git rev-list *)", + "Bash(git ls-remote *)", + "Bash(env -u PYTHONPATH /tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/venv/bin/twine check dist/*)", + "Bash(grep -E \"LICENSE|pyproject|composition|\\\\.autstr$\")", + "Bash(env)", + "Bash(command -v gh)", + "Bash(gh auth *)", + "Bash(rm -rf /tmp/smoke)", + "Bash(env -u PYTHONPATH bash -e /tmp/smoke_step.sh)", + "WebFetch(domain:docs.github.com)", + "Bash(git ls-tree *)", + "Bash(timeout 200 distrobox enter devbox -- sh -c 'ls ~/.config/gh 2>/dev/null && cat ~/.config/gh/hosts.yml 2>/dev/null | sed \"s/oauth_token:.*/oauth_token: /\"')", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(' latest:', d['info']['version'], '| 3.0.0 present:', '3.0.0' in d['releases']\\)\")", + "Bash(timeout 200 distrobox enter devbox -- sh -c 'cd '\\\\''__TRACKED_VAR__'\\\\'' && gh repo view --json nameWithOwner,defaultBranchRef -q '\\\\''.nameWithOwner + \" default=\" + .defaultBranchRef.name'\\\\'' && echo '\\\\''--- existing releases:'\\\\'' && gh release list --limit 5 && echo '\\\\''--- release for v3.0.0?'\\\\'' && \\(gh release view v3.0.0 >/dev/null 2>&1 && echo EXISTS || echo '\\\\''none \\(good\\)'\\\\''\\)')", + "Bash(timeout 300 distrobox enter devbox -- sh -c 'cd '\\\\''__TRACKED_VAR__'\\\\'' && gh release create v3.0.0 --verify-tag --title '\\\\''AutStr 3.0.0 — tree-automatic structures, MTBDD transitions, composition'\\\\'' --notes-file '\\\\''/tmp/claude-1000/-var-home-faried-projects-AutStr/2d526722-67e6-40eb-831e-8c7cfd3aa6ed/scratchpad/RELEASE_NOTES_3.0.0.md'\\\\''')", + "Bash(timeout 200 distrobox enter devbox -- sh -c 'cd '\\\\''__TRACKED_VAR__'\\\\'' && gh run list --workflow=publish.yaml --limit 3')", + "Bash(timeout 200 distrobox enter devbox -- sh -c 'cd '\\\\''__TRACKED_VAR__'\\\\'' && gh run list --limit 6 --json databaseId,name,event,status,conclusion,headBranch --template '\\\\''{{range .}}{{.databaseId}} {{.name}} [{{.event}}] {{.status}} {{.conclusion}}{{\"\\\\n\"}}{{end}}'\\\\''')", + "Bash(timeout 250 distrobox enter devbox -- sh -c 'cd '\\\\''__TRACKED_VAR__'\\\\'' && echo '\\\\''--- runs with event=release:'\\\\'' && gh run list --event release --limit 5 && echo '\\\\''--- workflows known to GitHub:'\\\\'' && gh workflow list --all && echo '\\\\''--- release object:'\\\\'' && gh release view v3.0.0 --json isDraft,isPrerelease,publishedAt,tagName -q '\\\\''.tagName + \" draft=\" + \\(.isDraft|tostring\\) + \" prerelease=\" + \\(.isPrerelease|tostring\\) + \" publishedAt=\" + \\(.publishedAt|tostring\\)'\\\\''')", + "Bash(timeout 250 distrobox enter devbox -- sh -c 'cd '\\\\''__TRACKED_VAR__'\\\\'' && echo '\\\\''--- workflow object:'\\\\'' && gh api repos/fariedabuzaid/AutStr/actions/workflows/310732983 -q '\\\\''.name + \" state=\" + .state + \" path=\" + .path'\\\\'' && echo '\\\\''--- any run at all for it:'\\\\'' && gh api '\\\\''repos/fariedabuzaid/AutStr/actions/workflows/310732983/runs'\\\\'' -q '\\\\''.total_count'\\\\'' && echo '\\\\''--- most recent 3 runs overall \\(any workflow\\):'\\\\'' && gh api '\\\\''repos/fariedabuzaid/AutStr/actions/runs?per_page=3'\\\\'' -q '\\\\''.workflow_runs[] | .name + \" | \" + .event + \" | \" + .conclusion'\\\\''')", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(' latest:', d['info']['version'], '| 3.0.0 present:', '3.0.0' in d['releases']\\)\")", + "Bash(awk '/refs\\\\/tags\\\\/v3.0.0\\\\^\\\\{\\\\}/{print \" \" substr\\($1,1,8\\)}')", + "Bash(curl -s https://pypi.org/simple/autstr/)", + "Bash(curl -s -H 'Cache-Control: no-cache' \"https://pypi.org/pypi/autstr/json\")", + "WebFetch(domain:csl2027.github.io)", + "WebFetch(domain:www.fsttcs.org.in)", + "Bash(./venv/bin/python -c \"import numpy, nltk, pytest; print\\('venv ok'\\)\")", + "Bash(PYTHONPATH=. /tmp/claude-1000/-var-home-faried-projects-AutStr/5da92948-4a90-4ba2-a6be-bb695acdc5d4/scratchpad/venv/bin/python -m pytest tests/test_groups.py -k CutRank -x -q)", + "Bash(/tmp/claude-1000/-var-home-faried-projects-AutStr/5da92948-4a90-4ba2-a6be-bb695acdc5d4/scratchpad/venv/bin/pip install *)", + "Bash(PYTHONPATH=. /tmp/claude-1000/-var-home-faried-projects-AutStr/5da92948-4a90-4ba2-a6be-bb695acdc5d4/scratchpad/venv/bin/python -m pytest -q)", + "Bash(PYTHONPATH=. /tmp/claude-1000/-var-home-faried-projects-AutStr/5da92948-4a90-4ba2-a6be-bb695acdc5d4/scratchpad/venv/bin/python -m pytest tests/test_tree_groups.py -k CutRank -x -q)", + "Bash(echo \"exit: $?\")", + "Bash(PYTHONPATH=. /tmp/claude-1000/-var-home-faried-projects-AutStr/5da92948-4a90-4ba2-a6be-bb695acdc5d4/scratchpad/venv/bin/python -m sphinx -b html -W --keep-going docs/source /tmp/claude-1000/-var-home-faried-projects-AutStr/5da92948-4a90-4ba2-a6be-bb695acdc5d4/scratchpad/docsbuild)", + "Bash(distrobox enter *)", + "Bash(podman pod *)", + "Bash(podman images *)", + "Bash(curl -sfL -o lipics.zip \"https://submission.dagstuhl.de/styles/download-tag/lipics/v2021.1.3/authors/zip\")", + "Bash(unzip -o -q lipics.zip)", + "Bash(curl -sfL -o lipics.tgz \"https://drops.dagstuhl.de/styles/lipics-v2021/lipics-v2021-authors.tgz\")", + "Bash(tar tzf *)", + "Bash(pdfinfo main.pdf)", + "Bash(python3 -m venv /tmp/claude-1000/-var-home-faried-projects-AutStr/5da92948-4a90-4ba2-a6be-bb695acdc5d4/scratchpad/venv)", + "Bash(PYTHONPATH=. /tmp/claude-1000/-var-home-faried-projects-AutStr/5da92948-4a90-4ba2-a6be-bb695acdc5d4/scratchpad/venv/bin/python -m pytest tests/test_cocycle_groups.py -x -q)", + "Bash(PYTHONPATH=. /tmp/claude-1000/-var-home-faried-projects-AutStr/5da92948-4a90-4ba2-a6be-bb695acdc5d4/scratchpad/venv/bin/python -m pytest tests/test_cocycle_groups.py -q --durations=6)", + "Bash(PYTHONPATH=. /tmp/claude-1000/-var-home-faried-projects-AutStr/5da92948-4a90-4ba2-a6be-bb695acdc5d4/scratchpad/venv/bin/python -m pytest tests/test_cocycle_groups.py -q)", + "Bash(podman run *)", + "Bash(pdftotext main.pdf -)", + "Bash(PYTHONPATH=. /tmp/claude-1000/-var-home-faried-projects-AutStr/5da92948-4a90-4ba2-a6be-bb695acdc5d4/scratchpad/venv/bin/python -c ' *)", + "Bash(PYTHONPATH=. timeout 590 /tmp/claude-1000/-var-home-faried-projects-AutStr/5da92948-4a90-4ba2-a6be-bb695acdc5d4/scratchpad/venv/bin/python -m pytest tests/test_cocycle_groups.py -k Automaton -x -q --durations=3)", + "Bash(ps -eo pid,rss,comm,args --no-headers)", + "Bash(awk '$3 ~ /python/ && $0 ~ /pytest|cocycle/ {print $1, $2, $3}')", + "Bash(SP=/tmp/claude-1000/-var-home-faried-projects-AutStr/5da92948-4a90-4ba2-a6be-bb695acdc5d4/scratchpad && cat $SP/../tasks/bvtn4phrx.output 2>/dev/null | tail -5; echo ---; tail -4 $SP/heavy_run.log 2>/dev/null)", + "Read(//tmp/claude-1000/-var-home-faried-projects-AutStr/5da92948-4a90-4ba2-a6be-bb695acdc5d4/tasks/**)", + "Bash(systemctl --user list-units --type=scope --no-legend)", + "Bash(SP=/tmp/claude-1000/-var-home-faried-projects-AutStr/5da92948-4a90-4ba2-a6be-bb695acdc5d4/scratchpad && cat $SP/heavy_run.log; echo \"===\"; cat $SP/../tasks/bvtn4phrx.output 2>/dev/null | tail -3; cat $SP/../tasks/bp55opj0b.output 2>/dev/null | tail -3)", + "Bash(grep -n 'every layout compatible\\\\|total rank of the alternating\\\\|inverses are\\\\|suppressing structural\\\\|row bases of $F_y\\\\|a row of $F_g\\(J\\)$\\\\|hat Q'\\\\''\\\\|co-located\\\\|p + 2$ letters\\\\|prod\\\\\\\\text{-terms\\\\|the run is the word\\\\|dihedral and quaternion\\\\|finitely supported\\\\|right inverse\\\\|n/2 - O\\\\|n/2 -\\\\|width $\\\\\\\\ge n/2' main.tex)", + "Bash(pdftotext -f 1 -l 1 main.pdf -)", + "Bash([ -s $SP/../tasks/b5t7mh2n0.output ])", + "Bash(SP=/tmp/claude-1000/-var-home-faried-projects-AutStr/5da92948-4a90-4ba2-a6be-bb695acdc5d4/scratchpad && cat $SP/../tasks/b5t7mh2n0.output)", + "Bash(pkill -f twosided_search.py)", + "Bash(pdfinfo scratch-chainring.pdf)", + "Bash(pdftotext scratch-chainring.pdf -)", + "Bash(cd /var/home/faried/projects/AutStr/paper && rm -f main.aux main.bbl main.blg main.out main.pdf main.fdb_latexmk main.fls main.log main.vtc && echo \"cleaned\")", + "Bash(grep -viE \"^$\")", + "Bash(timeout 150 poetry run python -c ' *)", + "Bash(timeout 60 poetry run python -c ' *)", + "Bash(poetry run *)", + "Bash(echo \"EXIT=$? DONE\")", + "Bash(grep -nE \"^#{1,4} \" README.md)", + "Bash(sed -n '/\\\\[build-system\\\\]/,/^\\\\[/p' pyproject.toml)", + "Bash(sed -n '/dependencies = \\\\[/,/\\\\]/p' pyproject.toml)", + "Bash(git archive *)", + "Bash(timeout 200 poetry run python benchmarks/implicit_vs_explicit.py --budget-seconds 130 --mem-gb 16 --explicit-timeout 18 --implicit-timeout 15 --max-n-word 3 --max-n-tree 2 --out /tmp/claude-1000/-var-home-faried-projects-AutStr/bf14bf1b-57bf-4218-a192-43611006ec2b/scratchpad/bench_smoke3.md)", + "Bash(git commit -q -m 'Benchmark: sweep width r, center k and depth d; add 4-quantifier query *)", + "Bash(git -C /var/home/faried/projects/AutStr status)", + "Bash(python -m pytest tests/test_groups.py tests/test_tree_groups.py tests/test_cocycle_groups.py -x -q)", + "Bash(ls /var/home/faried/projects/AutStr/.venv/bin 2>/dev/null | head -5; which python3; ls ~/.venvs 2>/dev/null)", + "Read(//home/faried/**)", + "Bash(ls -d /tmp/claude-1000/-var-home-faried-projects-AutStr/*/scratchpad/venv 2>/dev/null | head; ls /tmp/claude-1000/-var-home-faried-projects-AutStr/ 2>/dev/null | head)", + "Bash(python3 -m venv /tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv)", + "Bash(/tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/pip -q install nltk pytest numpy)", + "Bash(/tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/python -c \"import numpy, nltk, pytest; print\\('deps ok'\\)\")", + "Bash(PYTHONPATH=/var/home/faried/projects/AutStr /tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/python -m pytest tests/test_groups.py tests/test_tree_groups.py tests/test_cocycle_groups.py -q)", + "Bash(/tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/pip -q install graphviz)", + "Bash(/tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/python -c \"import ast; ast.parse\\(open\\('autstr/cocycle_groups.py'\\).read\\(\\)\\); print\\('syntax ok'\\)\")", + "Bash(/tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/python /tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/bytecompat.py)", + "Bash(PYTHONPATH=/var/home/faried/projects/AutStr /tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/python -m pytest tests/test_cocycle_groups.py -q)", + "Bash(/tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/python /tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/ringcheck.py)", + "Bash(/tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/python -)", + "Bash(ps -eo pid,etime,pcpu,args --no-headers)", + "Bash(awk '$4 ~ /python/ && /scratchpad/ {print $1, $2, $3}')", + "Bash(timeout 280 /tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/python -u -)", + "Bash(PYTHONPATH=/var/home/faried/projects/AutStr /tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/python -m pytest tests/ -q)", + "Bash(/tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/pip -q install networkx)", + "Bash(PYTHONPATH=/var/home/faried/projects/AutStr /tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/python -m pytest tests/test_groups.py tests/test_implicit.py -q)", + "Bash(PYTHONPATH=/var/home/faried/projects/AutStr /tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/python -m pytest tests/test_groups.py::TestCutRankGroups::test_validation -q)", + "Bash(PYTHONPATH=/var/home/faried/projects/AutStr /tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/python -m pytest tests/test_groups.py -q)", + "Bash(PYTHONPATH=/var/home/faried/projects/AutStr /tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/python -m pytest tests/test_tree_groups.py tests/test_cocycle_groups.py tests/test_implicit.py -q)", + "Bash(PYTHONPATH=/var/home/faried/projects/AutStr /tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/python -m pytest tests/test_tree_groups.py::TestCutRankTreeGroups::test_width_guard -q)", + "Bash(PYTHONPATH=/var/home/faried/projects/AutStr /tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/python -m pytest tests/test_tree_groups.py -q)", + "Bash(PYTHONPATH=/var/home/faried/projects/AutStr /tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/python -m pytest tests/ -q -W error::Warning --co -q)", + "Bash(PYTHONPATH=/var/home/faried/projects/AutStr /tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/python -m pytest tests/test_arithmetic.py -q)", + "Bash(git -C /var/home/faried/projects/AutStr status --short)", + "Bash(git -C /var/home/faried/projects/AutStr diff --stat)", + "Bash(PYTHONPATH=/var/home/faried/projects/AutStr /tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/python -m pytest tests/test_implicit.py -q)", + "Bash(PYTHONPATH=/var/home/faried/projects/AutStr /tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/python -m pytest tests/test_rank_width.py -q)", + "Bash(timeout 200 /tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/python -u -)", + "Bash(grep -v \"^\\\\s*$\")", + "Bash(ps -eo pid,etime,pcpu,comm --no-headers)", + "Bash(awk '/sphinx|python3/ && $3 > 50 {print}')", + "Bash(grep -n '`s\\\\b\\\\|`s ' autstr/implicit.py)", + "Bash(git -C /var/home/faried/projects/AutStr remote -v)", + "Bash(git -C /var/home/faried/projects/AutStr branch -a)", + "Bash(/tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/python -c \"import yaml,sys; d=yaml.safe_load\\(open\\('.github/workflows/run_docs.yaml'\\)\\); print\\('yaml ok — jobs:', list\\(d['jobs']\\)\\); print\\('perms:', d['true'] if 'true' in d else d.get\\('permissions'\\)\\)\")", + "Bash(/tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/pip -q install pyyaml)", + "Bash(/tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/python -c ' *)", + "Bash(pdfinfo paper/main.pdf)", + "Bash(/tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/python -c \"import re; d=open\\('paper/main.log','rb'\\).read\\(\\).decode\\('utf8','ignore'\\); import re; m=re.findall\\(r'Output written on main.pdf \\\\\\(\\(\\\\d+\\) page', d\\); print\\('pages:', m\\)\")", + "Bash(git -C /var/home/faried/projects/AutStr add paper/main.tex)", + "Bash(git -C /var/home/faried/projects/AutStr commit -m ' *)", + "Bash(git -C /var/home/faried/projects/AutStr log --oneline -1)", + "Bash(timeout 500 /tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/python -u -)", + "Bash(timeout 400 /tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/python -u -)", + "Bash(timeout 250 /tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/python -u -)", + "Bash(git -C /var/home/faried/projects/AutStr add paper/main.tex autstr/groups.py tests/test_groups.py FINALIZING.md)", + "Bash(timeout 550 /tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/python -u -)", + "Bash(/tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/python -u -)", + "Bash(PYTHONPATH=/var/home/faried/projects/AutStr /tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/python -m pytest tests/test_tree_groups.py tests/test_cocycle_groups.py tests/test_implicit.py tests/test_groups.py -q)", + "Bash(git -C /var/home/faried/projects/AutStr add autstr/tree_groups.py tests/test_tree_groups.py FINALIZING.md)", + "Bash(PYTHONPATH=/var/home/faried/projects/AutStr /tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/python -m pytest tests/ -q -x --ignore=tests/test_cocycle_groups.py)", + "Bash(git -C /var/home/faried/projects/AutStr add autstr/cocycle_groups.py tests/test_cocycle_groups.py paper/main.tex FINALIZING.md)", + "Bash(git -C /var/home/faried/projects/AutStr add README.md)", + "Bash(git -C /var/home/faried/projects/AutStr log --oneline -12)", + "Bash(git -C /var/home/faried/projects/AutStr ls-files paper/)", + "Bash(mkdir -p /var/home/faried/projects/rankwidth-groups-paper)", + "Bash(cp /var/home/faried/projects/AutStr/paper/.gitignore /var/home/faried/projects/AutStr/paper/cc-by.pdf /var/home/faried/projects/AutStr/paper/lipics-logo-bw.pdf /var/home/faried/projects/AutStr/paper/lipics-v2021.cls /var/home/faried/projects/AutStr/paper/main.tex /var/home/faried/projects/AutStr/paper/orcid.pdf /var/home/faried/projects/AutStr/paper/refs.bib /var/home/faried/projects/AutStr/paper/scratch-chainring.tex /var/home/faried/projects/AutStr/paper/theorem3-notes.md /var/home/faried/projects/rankwidth-groups-paper/)", + "Bash(echo \"scratch-chainring.pdf\")", + "Bash(PYTHONPATH=/var/home/faried/projects/AutStr /tmp/claude-1000/-var-home-faried-projects-AutStr/a44e92b8-fc14-40a7-8f08-315be4b8cd46/scratchpad/venv/bin/python -m pytest tests/test_groups.py tests/test_chain_ring.py -q)", + "Bash(git -C /var/home/faried/projects/AutStr rm --cached -q .claude/settings.json)", + "Bash(git -C /var/home/faried/projects/AutStr commit --amend --no-edit -q)", + "Bash(git -C /var/home/faried/projects/AutStr show --stat HEAD)", + "Bash(git -C /var/home/faried/projects/AutStr add autstr/cocycle_groups.py)", + "Bash(distrobox enter devbox -- sh -c 'cd ~/projects/AutStr && git add poetry.lock uv.lock pyproject.toml && git commit -q -m \"Rebuild lock files; reconcile sphinx-autodoc-typehints pin *)", + "Bash(python -)", + "Bash(python -c ' *)", + "Bash(python -m pytest tests/test_chain_ring.py tests/test_cocycle_groups.py tests/test_tree_groups.py tests/test_rank_width.py -q)", + "Bash(/var/home/faried/projects/AutStr/.venv/bin/python -m pytest tests/test_chain_ring.py tests/test_cocycle_groups.py tests/test_tree_groups.py tests/test_rank_width.py -q)", + "Bash(~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -m pytest tests/test_chain_ring.py tests/test_cocycle_groups.py tests/test_tree_groups.py tests/test_rank_width.py tests/test_tree_graphs.py -q)", + "Bash(~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -m pytest tests/test_chain_ring.py -q)", + "Bash(~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -m pytest tests/test_tree_groups.py tests/test_rank_width.py -q -x)", + "Bash(git config *)", + "Read(//var/home/faried/**)", + "Bash(flatpak list *)", + "Bash(rpm -q code)", + "Bash(git -C /var/home/faried/projects/AutStr branch --show-current)", + "Bash(timeout 300 .venv/bin/python -c ' *)", + "Bash(python3 -c \"import numpy\")", + "Read(//var/home/faried/.cache/pypoetry/virtualenvs/**)", + "Bash(timeout 600 ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -c ' *)", + "Bash(timeout 900 ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -c ' *)", + "Bash(timeout 120 ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -c ' *)", + "Bash(timeout 900 ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python /tmp/claude-1000/-var-home-faried-projects-AutStr/b85bf2ed-1f4c-4f2e-8358-3348f99e2d8d/scratchpad/t1.py)", + "Bash(timeout 900 ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -u /tmp/claude-1000/-var-home-faried-projects-AutStr/b85bf2ed-1f4c-4f2e-8358-3348f99e2d8d/scratchpad/t1.py)", + "Bash(timeout 1800 ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -m pytest tests/test_symbolic.py -q)", + "Bash(timeout 600 ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -u -c ' *)", + "Bash(timeout 900 ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -u -c ' *)", + "Bash(timeout 1800 ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -m pytest tests/test_arithmetic.py tests/test_symbolic.py -q)", + "Bash(timeout 3000 ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -m pytest tests/ -q -x --timeout=600)", + "Bash(timeout 3000 ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -m pytest tests/ -q)", + "Bash(timeout 60 ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -c ' *)", + "Bash(timeout 60 ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -)", + "Bash(timeout 900 ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -m pytest tests/test_symbolic.py -q)", + "Bash(timeout 900 ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -m pytest tests/test_symbolic.py tests/test_arithmetic.py -q)", + "Bash(timeout 900 ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python scripts/gen_readme_media.py)", + "Bash(awk '{print \"still running, cpu-time:\", $10}')", + "Bash(awk '{print \"cpu-time:\", $10}')", + "Bash(timeout 300 uv sync --dry-run)", + "Bash(timeout 900 uv sync)", + "Bash(timeout 900 uv sync --python 3.13)", + "Bash(.venv/bin/python -c ' *)", + "Bash(timeout 900 .venv/bin/python -m pytest tests/test_symbolic.py tests/test_arithmetic.py -q)", + "Bash(.venv/bin/python -c \"import jax\")", + "Bash(command -v dot)", + "Bash(rm -rf /tmp/uvcheck)", + "Bash(.venv/bin/python -c \"import sys,numpy,nltk;print\\('python',sys.version.split\\(\\)[0],'| numpy',numpy.__version__\\)\")", + "Bash(timeout 60 .venv/bin/python -c ' *)", + "Bash(timeout 900 .venv/bin/python -u -c ' *)", + "Bash(chmod +x drive.sh)", + "Bash(./drive.sh)", + "Bash(tee sweep.csv)", + "Bash(python3 -c \"import autstr\")", + "Bash(chmod +x drive_b.sh)", + "Bash(./drive_b.sh)", + "Bash(tee sweep_b.csv)", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=3G -p MemorySwapMax=0 -- timeout 180 env PYTHONPATH=/var/home/faried/projects/AutStr /var/home/faried/projects/AutStr/.venv/bin/python3 /tmp/claude-1000/-var-home-faried-projects-AutStr/2d9a6131-5471-4026-977d-846f68bb33e8/scratchpad/verify_instance.py)", + "Bash(python3 /tmp/claude-1000/-var-home-faried-projects-AutStr/2d9a6131-5471-4026-977d-846f68bb33e8/scratchpad/edit_nb.py)", + "Bash(.venv/bin/python3 -m pytest --version)", + "Bash(.venv/bin/python3 -c \"import pytest_timeout; print\\('pytest-timeout available'\\)\")", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=3G -p MemorySwapMax=0 -- timeout 2400 .venv/bin/python3 -m pytest tests/ -v --durations=20 -p no:cacheprovider)", + "Bash(echo \"EXIT=$?\")", + "Bash(.venv/bin/python3 -c \"import nbstripout; print\\('installed'\\)\")", + "Bash(timeout 180 .venv/bin/python3 -m pre_commit run --all-files)", + "Read(//var/home/linuxbrew/.linuxbrew/Cellar/git-lfs/3.7.1/bin/**)", + "Read(//home/faried/.linuxbrew/bin/**)", + "Bash(git-lfs version *)", + "Bash(git lfs *)", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=3G -p MemorySwapMax=0 -- timeout 600 .venv/bin/python3 -m pytest tests/test_symbolic_tree.py -x -q -p no:cacheprovider)", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=3G -p MemorySwapMax=0 -- timeout 900 .venv/bin/python3 -m pytest tests/test_symbolic_tree.py -q -p no:cacheprovider)", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=3G -p MemorySwapMax=0 -- timeout 2400 .venv/bin/python3 -m pytest tests/ -q -p no:cacheprovider)", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=3G -p MemorySwapMax=0 -- timeout 900 .venv/bin/python3 -m pytest tests/test_tree_automata.py -q -p no:cacheprovider)", + "Bash(.venv/bin/python3 -c ' *)", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=2G -- timeout 300 .venv/bin/python3 -m pytest tests/test_symbolic_tree.py -q -p no:cacheprovider)", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=2G -- timeout 300 .venv/bin/python3 -m pytest tests/test_symbolic_tree.py tests/test_tree_automata.py -q -p no:cacheprovider)", + "Bash(sed -i 's/ The tree engine has no counterpart yet for enumeration, which raises with\\\\n the reason rather than quietly answering a different question./X/' autstr/symbolic/backends.py)", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=3G -p MemorySwapMax=0 -- timeout 600 .venv/bin/python3 -c ' *)", + "Bash(sed -i '52s/.*/from autstr.sparse_tree_automata import SparseTreeAutomaton, Tree/' autstr/utils/tree_automata_tools.py)", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=3G -p MemorySwapMax=0 -- timeout 300 .venv/bin/python3 -c ' *)", + "Bash(sed -i 's/^from autstr.sparse_tree_automata import \\(/from autstr.utils.tree_automata_tools import _shortlex_key, iterate_trees\\\\nfrom autstr.sparse_tree_automata import \\(/' tests/test_tree_automata.py)", + "Bash(sed -i 's/ for letter in sorted\\(alphabet\\) if letter != padding_symbol/ for letter in sorted\\(alphabet\\)/' autstr/utils/tree_automata_tools.py)", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=3G -p MemorySwapMax=0 -- timeout 600 .venv/bin/python3 -m pytest tests/test_symbolic_tree.py -q -p no:cacheprovider)", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=2G -- timeout 300 .venv/bin/python3 -m pytest tests/test_tree_tools.py -q -p no:cacheprovider)", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=3G -p MemorySwapMax=0 -- timeout 900 .venv/bin/python3 -m pytest tests/test_tree_uniform.py -q -p no:cacheprovider)", + "Bash(sed -i 's/^from autstr.sparse_tree_automata import SparseTreeAutomaton, Tree$/from autstr.sparse_tree_automata import SparseTreeAutomaton, Tree\\\\nfrom autstr.symbolic import FunctionCodec, Signature/' tests/test_tree_uniform.py)", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=3G -p MemorySwapMax=0 -- timeout 900 .venv/bin/python3 -c ' *)", + "Bash(.venv/bin/python3 -c \"from autstr.symbolic import operation_signature, EQUALITY_SYMBOLS; print\\('exports ok', EQUALITY_SYMBOLS\\)\")", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=3G -p MemorySwapMax=0 -- timeout 900 .venv/bin/python3 -m pytest tests/test_symbolic.py tests/test_symbolic_tree.py -q -p no:cacheprovider)", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=3G -p MemorySwapMax=0 -- timeout 1500 .venv/bin/python3 -c ' *)", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=3G -- timeout 600 .venv/bin/python3 -c ' *)", + "Bash(.venv/bin/python3 -c \"from autstr.symbolic import operation_signature, EQUALITY_SYMBOL; print\\('ok', EQUALITY_SYMBOL\\)\")", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=3G -- timeout 900 .venv/bin/python3 -c ' *)", + "Bash(.venv/bin/python3 -c \"import autstr.tree_graphs, autstr.graphs, autstr.algebra; print\\('modules import ok'\\)\")", + "Bash(.venv/bin/python3 -c \"import autstr.tree_graphs, autstr.graphs, autstr.algebra, autstr.groups; print\\('modules import ok'\\)\")", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=3G -p MemorySwapMax=0 -- timeout 900 .venv/bin/python3 -m pytest tests/test_equality.py -q -p no:cacheprovider)", + "Bash(.venv/bin/python3 -c \"import sphinx, myst_nb; print\\('sphinx', sphinx.__version__\\)\")", + "Bash(.venv/bin/python3 -c \"import sphinx; print\\('sphinx', sphinx.__version__\\)\")", + "Bash(.venv/bin/python3 -)", + "Bash(which uv *)", + "Read(//home/linuxbrew/.linuxbrew/bin/**)", + "Bash(uv venv *)", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=3G -p MemorySwapMax=0 -- timeout 900 env PYTHONPATH=/var/home/faried/projects/AutStr /tmp/claude-1000/-var-home-faried-projects-AutStr/2d9a6131-5471-4026-977d-846f68bb33e8/scratchpad/docsvenv/bin/python -m sphinx.ext.apidoc -f -o /tmp/claude-1000/-var-home-faried-projects-AutStr/2d9a6131-5471-4026-977d-846f68bb33e8/scratchpad/apidoc autstr)", + "Bash(echo \"apidoc EXIT=$?\")", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=3G -p MemorySwapMax=0 -- timeout 1200 env PYTHONPATH=/var/home/faried/projects/AutStr /tmp/claude-1000/-var-home-faried-projects-AutStr/2d9a6131-5471-4026-977d-846f68bb33e8/scratchpad/docsvenv/bin/python -m sphinx -b html --keep-going -D nb_execution_mode=off docs/source /tmp/claude-1000/-var-home-faried-projects-AutStr/2d9a6131-5471-4026-977d-846f68bb33e8/scratchpad/docsbuild)", + "Bash(PYTHONPATH=/var/home/faried/projects/AutStr /tmp/claude-1000/-var-home-faried-projects-AutStr/2d9a6131-5471-4026-977d-846f68bb33e8/scratchpad/docsvenv/bin/python -c 'import autstr.algebra')", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=3G -- timeout 900 env PYTHONPATH=/var/home/faried/projects/AutStr /tmp/claude-1000/-var-home-faried-projects-AutStr/2d9a6131-5471-4026-977d-846f68bb33e8/scratchpad/docsvenv/bin/python -m sphinx -b html --keep-going -D nb_execution_mode=off docs/source /tmp/claude-1000/-var-home-faried-projects-AutStr/2d9a6131-5471-4026-977d-846f68bb33e8/scratchpad/docsbuild3)", + "Bash(grep WARNING __TRACKED_VAR__/docsbuild3.log)", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=3G -p MemorySwapMax=0 -- timeout 900 .venv/bin/python3 -m pytest tests/test_infinite_extraspecial.py -q -p no:cacheprovider)", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=3G -- timeout 600 .venv/bin/python3 -m pytest tests/test_infinite_extraspecial.py -q -p no:cacheprovider)", + "WebFetch(domain:arxiv.org)", + "WebFetch(domain:www.irif.fr)", + "WebFetch(domain:d-nb.info)", + "Bash(pdftotext webfetch-1786476185248-qqkzxh.pdf -)", + "Bash(pdftotext webfetch-1786476190531-ui46ls.pdf -)", + "Bash(git switch *)", + "Bash(git pull *)", + "Bash(.venv/bin/python3 -c \"from autstr.symbolic import graph_signature; print\\('exported:', graph_signature.__name__\\)\")", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=3G -p MemorySwapMax=0 -- timeout 600 .venv/bin/python3 -m pytest tests/test_infinite_graphs.py -q -p no:cacheprovider)", + "Bash(.venv/bin/python3 -c \"from autstr.arithmetic import encode, decode; print\\('codec fns ok:', encode\\(7\\), decode\\(encode\\(7\\)\\)\\)\")", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=3G -p MemorySwapMax=0 -- timeout 900 .venv/bin/python3 -m pytest tests/test_integer_grid.py -q -p no:cacheprovider)", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=3G -p MemorySwapMax=0 -- timeout 600 .venv/bin/python3 -m pytest tests/test_integer_grid.py -q -p no:cacheprovider)", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=3G -p MemorySwapMax=0 -- timeout 900 .venv/bin/python3 -m pytest tests/test_interpretations.py -q -p no:cacheprovider)", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=3G -- timeout 500 .venv/bin/python3 -c ' *)", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=3G -- timeout 400 .venv/bin/python3 -c ' *)", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=3G -p MemorySwapMax=0 -- timeout 900 .venv/bin/python3 -m pytest tests/test_interpretations.py tests/test_automata_tools.py -q -p no:cacheprovider)", + "Bash(kill %1)", + "Bash(pkill -f \"pytest tests/test_interpretations\")", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=3G -p MemorySwapMax=0 -- timeout 400 .venv/bin/python3 -m pytest tests/test_interpretations.py tests/test_automata_tools.py -q -p no:cacheprovider)", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=3G -- timeout 300 .venv/bin/python3 -c ' *)", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=3G -p MemorySwapMax=0 -- timeout 600 .venv/bin/python3 -m pytest tests/test_interpretations.py tests/test_automata_tools.py tests/test_integer_grid.py -q -p no:cacheprovider)", + "Bash(systemd-run --user --scope --quiet -p MemoryMax=3G -- timeout 300 .venv/bin/python3 /tmp/claude-1000/-var-home-faried-projects-AutStr/2d9a6131-5471-4026-977d-846f68bb33e8/scratchpad/fold_diag_repro.py)", + "Bash(ls /tmp/claude-1000/-var-home-faried-projects-AutStr/*/scratchpad/ 2>/dev/null)", + "Bash(python /tmp/claude-1000/-var-home-faried-projects-AutStr/cbe3da38-e60d-426a-b319-7761c21ba7e9/scratchpad/batch_repro.py)", + "Read(//var/home/faried/.local/share/uv/python/cpython-3.13.14-linux-x86_64-gnu/bin/**)", + "Bash(.venv/bin/python /tmp/claude-1000/-var-home-faried-projects-AutStr/cbe3da38-e60d-426a-b319-7761c21ba7e9/scratchpad/batch_repro.py)", + "Bash(.venv/bin/python /tmp/claude-1000/-var-home-faried-projects-AutStr/cbe3da38-e60d-426a-b319-7761c21ba7e9/scratchpad/kdim_repro.py)", + "Bash(.venv/bin/python /tmp/claude-1000/-var-home-faried-projects-AutStr/cbe3da38-e60d-426a-b319-7761c21ba7e9/scratchpad/kdim_quot.py)", + "Bash(sed -i 's/from autstr.buildin.presentations import BuechiArithmeticN, BuechiArithmeticZ/from autstr.buildin.presentations import BuechiArithmetic/; s/^SRC = SRC$/SRC = BuechiArithmetic/' /tmp/claude-1000/-var-home-faried-projects-AutStr/cbe3da38-e60d-426a-b319-7761c21ba7e9/scratchpad/kdim_quot.py)", + "Bash(sed -i 's/^SRC = BuechiArithmeticN$/SRC = BuechiArithmetic/' /tmp/claude-1000/-var-home-faried-projects-AutStr/cbe3da38-e60d-426a-b319-7761c21ba7e9/scratchpad/kdim_quot.py)", + "Bash(.venv/bin/python -m pytest tests/ -q -p no:randomly)", + "Bash(.venv/bin/python /tmp/claude-1000/-var-home-faried-projects-AutStr/cbe3da38-e60d-426a-b319-7761c21ba7e9/scratchpad/scan.py)", + "Bash(.venv/bin/python /tmp/claude-1000/-var-home-faried-projects-AutStr/cbe3da38-e60d-426a-b319-7761c21ba7e9/scratchpad/scan2.py 2418 2456)", + "Bash(.venv/bin/python /tmp/claude-1000/-var-home-faried-projects-AutStr/cbe3da38-e60d-426a-b319-7761c21ba7e9/scratchpad/scan2.py 2380 2418)", + "Bash(.venv/bin/python -)", + "Bash(.venv/bin/python /tmp/claude-1000/-var-home-faried-projects-AutStr/cbe3da38-e60d-426a-b319-7761c21ba7e9/scratchpad/scan2.py 2316 2362)", + "Bash(.venv/bin/python /tmp/claude-1000/-var-home-faried-projects-AutStr/cbe3da38-e60d-426a-b319-7761c21ba7e9/scratchpad/diag_bug.py)", + "Bash(.venv/bin/python /tmp/claude-1000/-var-home-faried-projects-AutStr/cbe3da38-e60d-426a-b319-7761c21ba7e9/scratchpad/diag_bug2.py)", + "Bash(.venv/bin/python /tmp/claude-1000/-var-home-faried-projects-AutStr/cbe3da38-e60d-426a-b319-7761c21ba7e9/scratchpad/witness.py)", + "Bash(.venv/bin/python /tmp/claude-1000/-var-home-faried-projects-AutStr/cbe3da38-e60d-426a-b319-7761c21ba7e9/scratchpad/witness2.py)", + "Bash(.venv/bin/python /tmp/claude-1000/-var-home-faried-projects-AutStr/cbe3da38-e60d-426a-b319-7761c21ba7e9/scratchpad/src_level.py)", + "Bash(.venv/bin/python /tmp/claude-1000/-var-home-faried-projects-AutStr/cbe3da38-e60d-426a-b319-7761c21ba7e9/scratchpad/universe.py)", + "Bash(.venv/bin/python /tmp/claude-1000/-var-home-faried-projects-AutStr/cbe3da38-e60d-426a-b319-7761c21ba7e9/scratchpad/kdim_fresh.py)", + "Bash(.venv/bin/python /tmp/claude-1000/-var-home-faried-projects-AutStr/cbe3da38-e60d-426a-b319-7761c21ba7e9/scratchpad/compare.py)", + "Bash(.venv/bin/python scripts/gen_builtin_presentations.py)", + "Bash(timeout 600 .venv/bin/python /tmp/claude-1000/-var-home-faried-projects-AutStr/cbe3da38-e60d-426a-b319-7761c21ba7e9/scratchpad/quot2.py)", + "Bash(timeout 900 .venv/bin/python -m pytest tests/test_presentations.py tests/test_interpretations.py -q -p no:cacheprovider)", + "Bash(timeout 2400 .venv/bin/python -m pytest tests/ -q -p no:cacheprovider)", + "Read(//home/faried/.claude/**)", + "Bash(~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -m pytest tests/ -q -x --timeout=600)", + "Bash(~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -m pytest tests/ -q)", + "Bash(~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -c ' *)", + "Bash(time ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -c ' *)", + "Bash(time ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -m pytest tests/test_ordinals.py -q)", + "Bash(~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -m pytest tests/test_ordinals.py -q)", + "Bash(timeout 300 ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -m pytest tests/test_regular_tree.py -q)", + "Bash(timeout 600 ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -m pytest tests/test_presentations.py -q)", + "Bash(~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -)", + "Bash(timeout 300 ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -c ' *)", + "Bash(timeout 600 ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -m pytest tests/test_turing.py -q)", + "Bash(timeout 300 ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -m pytest tests/test_regular_tree.py tests/test_integer_grid.py tests/test_infinite_graphs.py -q)", + "Bash(ps -eo pid,stat,etime,time,%cpu,%mem,rss,comm --sort=-%mem)", + "Bash(timeout 600 ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -m pytest tests/test_automata_tools.py -q)", + "Bash(timeout 900 ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python /tmp/claude-1000/-var-home-faried-projects-AutStr/8ea117be-ee5c-4752-bcca-214821a7c09a/scratchpad/probe_sentences.py)", + "Bash(~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -m pyflakes autstr/interpretations.py)", + "Bash(timeout 900 ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -m pytest tests/test_tree_tools.py -q)", + "Bash(timeout 900 ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -m pytest tests/test_tree_interpretations.py -q)", + "Bash(timeout 900 ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -m pytest tests/test_tree_interpretations.py tests/test_interpretations.py tests/test_tree_presentations.py tests/test_symbolic_tree.py -q)", + "Bash(timeout 900 ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -m pytest tests/test_tree_presentations.py -q)", + "WebFetch(domain:eiche.theoinf.tu-ilmenau.de)", + "Bash(timeout 900 ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -m pytest tests/test_tree_interpretations.py tests/test_interpretations.py -q)", + "Bash(timeout 900 ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -)", + "Bash(timeout 900 ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -m pytest tests/test_ordinals.py -q)", + "Bash(timeout 900 ~/.cache/pypoetry/virtualenvs/autstr-Ff0G8Q62-py3.13/bin/python -m pytest tests/test_tree_algebra.py -q)", + "Bash(git branch *)", + "Bash(gh pr *)", + "WebFetch(domain:ar5iv.labs.arxiv.org)", + "Bash(curl -sL -o k.tar.gz \"https://arxiv.org/e-print/1303.2453\")", + "Bash(mkdir -p kartzow)", + "Bash(tar xzf *)", + "Bash(git rebase *)", + "Bash(ps -eo pid,etime,args --no-headers)", + "Bash(awk '$3 ~ /pytest|python/ && $0 !~ /awk/')", + "Bash(pdftotext -layout kw.pdf kw.txt)", + "Bash(git cherry-pick *)", + "Bash(awk -F: '$1 > 1600 && $1 < 2400')", + "Bash(curl -sL -o thesis.tar.gz \"https://arxiv.org/e-print/1202.0137\")", + "Bash(mkdir -p thesis)", + "Bash(awk -F: '$1 > 639 && $1 < 2396')", + "Bash(timeout 300 distrobox enter devbox -- bash -lc 'PYTHONPATH=. .venv/bin/python -u /tmp/claude-1000/-var-home-faried-projects-AutStr/f653607e-ac8b-43c1-8960-acd739422fab/scratchpad/summary_check.py')", + "Bash(timeout 900 distrobox enter devbox -- bash -lc 'PYTHONPATH=. .venv/bin/python -u /tmp/claude-1000/-var-home-faried-projects-AutStr/f653607e-ac8b-43c1-8960-acd739422fab/scratchpad/random_check.py')", + "Bash(sed -i 's/for trial in range\\(60\\):/for trial in range\\(200\\):/; s/60 systems/200 systems/' random_check.py)", + "Bash(timeout 1800 distrobox enter devbox -- bash -lc 'PYTHONPATH=. .venv/bin/python -u /tmp/claude-1000/-var-home-faried-projects-AutStr/f653607e-ac8b-43c1-8960-acd739422fab/scratchpad/random_check2.py')", + "Bash(awk '{print} /missing/ && !/0 missing, 0 extra/ {bad++} END {print \"systems with disagreements:\", bad+0}')", + "Bash(timeout 2400 distrobox enter devbox -- bash -lc 'PYTHONPATH=. .venv/bin/python -u /tmp/claude-1000/-var-home-faried-projects-AutStr/f653607e-ac8b-43c1-8960-acd739422fab/scratchpad/d_check.py')" + ], + "additionalDirectories": [ + "/var/home/faried/projects/AutStr", + "/var/home/faried/projects/AutStr/paper", + "/dev", + "/var/home/faried/projects/rankwidth-groups-paper" + ] + } +} diff --git a/autstr/collapsible.py b/autstr/collapsible.py index aadfc2b..7bed383 100644 --- a/autstr/collapsible.py +++ b/autstr/collapsible.py @@ -1028,8 +1028,9 @@ def get_relation_symbols(self): return self.presentation.get_relation_symbols() def is_deterministic(self) -> bool: - """Whether every configuration has at most one successor — first-order, - and so decidable, even though reachability is not built here.""" + """Whether every configuration has at most one successor — a + first-order property of the one-step relation, so it costs nothing + beyond `E`.""" return self.presentation.check( 'all x.(all y.(all z.((E(x,y) & E(x,z)) -> Eq(y,z))))') diff --git a/autstr/presentations.py b/autstr/presentations.py index eda24a2..c0fe5e5 100644 --- a/autstr/presentations.py +++ b/autstr/presentations.py @@ -105,10 +105,14 @@ def _parse_payload(cls, payload: bytes): dfa_bytes = bytes(dfa_bytes_list) automata[name] = SparseDFASerializer.from_bytes(dfa_bytes) - # Reconstruct presentation + # Reconstruct presentation. Over a product alphabet the padding symbol + # is a tuple, and JSON has no tuples, so it comes back as a list. + padding_symbol = metadata["padding_symbol"] + if isinstance(padding_symbol, list): + padding_symbol = tuple(padding_symbol) return AutomaticPresentation( automata, - padding_symbol=metadata["padding_symbol"], + padding_symbol=padding_symbol, enforce_consistency=False ) diff --git a/autstr/sparse_automata.py b/autstr/sparse_automata.py index 3cf8a94..2c4b065 100644 --- a/autstr/sparse_automata.py +++ b/autstr/sparse_automata.py @@ -49,6 +49,17 @@ def step(states, symbols): # - Acceptance array # - Transition diagrams: var / lo / hi / term arrays, then one root per state +def _alphabet_from_json(raw) -> set: + """The base alphabet as stored in a payload. + + JSON has no tuples, so a product alphabet's letters -- which is what an + interpreted structure of dimension > 1 has -- come back as lists. Restore + them, or the set() below raises on the first unhashable letter. + """ + return {tuple(letter) if isinstance(letter, list) else letter + for letter in json.loads(raw)} + + class SparseDFASerializer: VERSION = 3 # diagram payload (v2 stored flat exception rows) HEADER_FORMAT = "4sB3sII" @@ -132,7 +143,7 @@ def _parse_payload(cls, payload: bytes, version: int = None) -> 'SparseDFA': num_states, num_nodes, start_state, symbol_arity, alphabet_len = \ struct.unpack(cls.METADATA_FORMAT, payload[:cls.METADATA_SIZE]) offset = cls.METADATA_SIZE - base_alphabet = set(json.loads(payload[offset:offset + alphabet_len])) + base_alphabet = _alphabet_from_json(payload[offset:offset + alphabet_len]) offset += alphabet_len is_accepting = np.frombuffer(payload, dtype=np.uint8, count=num_states, @@ -157,7 +168,7 @@ def _parse_payload_v2(cls, payload: bytes) -> 'SparseDFA': num_states, max_exceptions, start_state, symbol_arity, alphabet_len = \ struct.unpack(cls.METADATA_FORMAT, payload[:cls.METADATA_SIZE]) offset = cls.METADATA_SIZE - base_alphabet = set(json.loads(payload[offset:offset + alphabet_len])) + base_alphabet = _alphabet_from_json(payload[offset:offset + alphabet_len]) offset += alphabet_len defaults = np.frombuffer(payload, dtype=np.uint32, count=num_states, diff --git a/autstr/utils/automata_tools.py b/autstr/utils/automata_tools.py index 27fd973..f80ecf7 100644 --- a/autstr/utils/automata_tools.py +++ b/autstr/utils/automata_tools.py @@ -538,15 +538,19 @@ def iterate_language(dfa: SparseDFA, decoder: Callable = None, heap = LengthLexHeap() for state in start_set: if state in nonempty: - # Represent words as tuple of empty strings - heap.push((tuple(["" for _ in range(arity)]), state)) - + # One tape per argument, each a tuple of letters. Letters are + # kept as they are rather than concatenated into a string: an + # interpreted structure's alphabet is a product alphabet, whose + # letters are themselves tuples, and str() would flatten them + # into unparseable text. + heap.push((tuple([() for _ in range(arity)]), state)) + def cat(word, symbol): - """Concatenate symbol to word based on direction.""" + """Extend a tape by one letter, on the side we are building from.""" if backward: - return str(symbol) + word + return (symbol,) + word else: - return word + str(symbol) + return word + (symbol,) def push(heap, word_tuple, sym_enc, next_state): """Push a new word onto the heap with the given extension symbol and next state.""" diff --git a/notebooks/composition.ipynb b/notebooks/composition.ipynb index 122edbf..00d94f5 100644 --- a/notebooks/composition.ipynb +++ b/notebooks/composition.ipynb @@ -5,14 +5,17 @@ "id": "dc56966b", "metadata": {}, "source": [ - "# Composing presentations\n", + "# Building new structures from old\n", "\n", "Automatic structures over a shared signature are closed under **disjoint union**\n", "and **direct products**; uniformly automatic classes are closed under **union**\n", - "and under taking all **finite direct products** of their members.\n", + "and under taking all **finite direct products** of their members. They are also\n", + "closed under **first-order interpretation** — defining one structure inside\n", + "another by formulas.\n", "\n", - "`autstr.composition` performs these constructions: each takes presentations and\n", - "returns a presentation, so the result is queried exactly like any other." + "`autstr.composition` and `autstr.interpretations` perform these constructions:\n", + "each takes presentations and returns a presentation, so the result is queried\n", + "exactly like any other." ] }, { @@ -181,6 +184,211 @@ "the cyclic groups; it predates the module, which is a good sign the abstraction\n", "is the right one." ] + }, + { + "cell_type": "markdown", + "id": "a0f1d87e", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## First-order interpretations\n", + "\n", + "An interpretation defines a structure *inside* another one. You give\n", + "\n", + "- a **domain formula** $\\delta(\\bar x)$ saying which tuples of the source are\n", + " elements of the new structure — so an element is a $k$-tuple, and $k$ is the\n", + " interpretation's **dimension**;\n", + "- a formula per **relation**, over $k$ coordinates per argument;\n", + "- optionally an **equivalence** $\\varepsilon(\\bar x, \\bar y)$, and then the\n", + " elements are its classes.\n", + "\n", + "`interpret` does not merely record this: it *computes* the presentation, so the\n", + "result carries real automata and answers queries at full speed.\n", + "\n", + "The textbook example is the construction of the integers from the naturals. A\n", + "pair $(a, b)$ stands for $a - b$, two pairs denote the same integer when\n", + "$a + d = c + b$, and the order is $a + d < c + b$. That is a 2-dimensional\n", + "quotient interpretation of $(\\mathbb{N}, +, <)$ — and here it is." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ff03cfd1", + "metadata": {}, + "outputs": [], + "source": [ + "from autstr.arithmetic import BuechiArithmetic\n", + "from autstr.interpretations import interpret\n", + "\n", + "N = BuechiArithmetic() # (N, +, <, |2); A(x,y,z) is x + y = z\n", + "pair = ['x0', 'x1', 'y0', 'y1']\n", + "\n", + "SAME = 'exists z.(A(x0,y1,z) and A(y0,x1,z))' # a + d = c + b\n", + "LESS = ('exists s.(exists t.(A(x0,y1,s) and A(y0,x1,t) '\n", + " 'and Lt(s,t)))') # a + d < c + b\n", + "\n", + "Z = interpret(\n", + " N,\n", + " domain=('Eq(x0,x0) and Eq(x1,x1)', ['x0', 'x1']), # every pair is an element\n", + " relations={'Lt': (LESS, pair), 'Eq': (SAME, pair)},\n", + " dimension=2,\n", + " quotient=(SAME, pair),\n", + ")\n", + "\n", + "print('relations:', Z.get_relation_symbols())\n", + "print('no least element (unlike N):', Z.check('exists x.(all y.((not Lt(y,x))))'))\n", + "print('discrete, not dense: ',\n", + " not Z.check('all x.(all y.(Lt(x,y) -> exists z.(Lt(x,z) and Lt(z,y))))'))" + ] + }, + { + "cell_type": "markdown", + "id": "a772b548", + "metadata": {}, + "source": [ + "### A codec says what the elements are\n", + "\n", + "The construction is now correct but illegible: an element of `Z` is a *pair of\n", + "naturals*, and queries hand back the words encoding those pairs. A **codec** is\n", + "the missing half — a pair of functions between Python values and the words that\n", + "represent them. Give one in a `Signature`, and the structure speaks integers.\n", + "\n", + "Every structure the library ships declares its own this way, which is why\n", + "`symbolic()` needs no argument on them. For a structure you build yourself,\n", + "this is what you write." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "53a41a45", + "metadata": {}, + "outputs": [], + "source": [ + "from autstr.symbolic import FunctionCodec, Signature\n", + "\n", + "PAD = '*'\n", + "\n", + "def encode(n):\n", + " \"\"\"n as the pair (a, b) with n = a - b, folded into pair letters.\"\"\"\n", + " a, b = (n, 0) if n >= 0 else (0, -n)\n", + " wa, wb = N.encode(a), N.encode(b) # the source structure's own codec\n", + " width = max(len(wa), len(wb))\n", + " return [(wa[i] if i < len(wa) else PAD, wb[i] if i < len(wb) else PAD)\n", + " for i in range(width)]\n", + "\n", + "def decode(word):\n", + " return (N.decode([letter[0] for letter in word])\n", + " - N.decode([letter[1] for letter in word]))\n", + "\n", + "S = Z.symbolic(Signature(codec=FunctionCodec(encode, decode))\n", + " .operator('lt', 'Lt')\n", + " .operator('eq', 'Eq'))\n", + "x, y = S.vars('x y')\n", + "\n", + "print('-2 < 3 :', (-2, 3) in x.lt(y))\n", + "print(' 3 < -2:', (3, -2) in x.lt(y))\n", + "print('solutions of x < 2:', [t for t, _ in zip(iter(x.lt(2)), range(7))])" + ] + }, + { + "cell_type": "markdown", + "id": "87aa2f42", + "metadata": {}, + "source": [ + "The quotient picks one representative per class — over words, the shortlex-least\n", + "member — so each integer is enumerated once. Over *trees* no order is\n", + "well-founded, and the representative is instead the least **description** of the\n", + "class (Kuske & Weidner); `interpret` handles both, since a tree presentation\n", + "goes in and a tree presentation comes out.\n", + "\n", + "This is how `autstr.ordinals` is built: the ordinals below $\\omega^n$ are three\n", + "formulas over Büchi arithmetic — the reverse-lexicographic order on Cantor\n", + "coefficients — with no automaton authored by hand, and a codec that writes an\n", + "ordinal as its coefficient tuple." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e8051d42", + "metadata": {}, + "outputs": [], + "source": [ + "from autstr.ordinals import Ordinal\n", + "\n", + "w2 = Ordinal(2) # the ordinals below omega^2\n", + "a, b = w2.symbolic().vars('a b')\n", + "\n", + "# an ordinal is its Cantor coefficients, most significant first:\n", + "# (1, 0) is omega, (0, 5) is 5, (1, 1) is omega + 1\n", + "print('5 < omega: ', ((0, 5), (1, 0)) in a.lt(b))\n", + "print('omega < 5: ', ((1, 0), (0, 5)) in a.lt(b))\n", + "print('omega+1 < omega*2:', ((1, 1), (2, 0)) in a.lt(b))\n", + "print('below omega + 2: ', [t for t, _ in zip(iter(a.lt((1, 2))), range(5))])" + ] + }, + { + "cell_type": "markdown", + "id": "3123dc4e", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## Saving what you built\n", + "\n", + "Every construction on this page *computes* automata, and some of them are not\n", + "cheap. A presentation therefore serializes: `automatic_presentation_to_file`\n", + "writes the whole thing — universe, every relation, the alphabet — and\n", + "`automatic_presentation_from_file` reads it back.\n", + "\n", + "The payload stores each automaton's **transition diagrams** rather than a flat\n", + "`symbol → target` table, so a relation over a convolution alphabet too wide to\n", + "enumerate still writes out in the size of its diagrams." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "46b0fc0c", + "metadata": {}, + "outputs": [], + "source": [ + "import tempfile, os\n", + "from autstr.presentations import AutomaticPresentation\n", + "\n", + "path = os.path.join(tempfile.mkdtemp(), 'integers.autstr')\n", + "Z.automatic_presentation_to_file(path)\n", + "print(f'{os.path.getsize(path)} bytes on disk')\n", + "\n", + "reloaded = AutomaticPresentation.automatic_presentation_from_file(path)\n", + "print('relations:', reloaded.get_relation_symbols())\n", + "\n", + "# the reloaded structure answers the same questions\n", + "R = reloaded.symbolic(Signature(codec=FunctionCodec(encode, decode))\n", + " .operator('lt', 'Lt').operator('eq', 'Eq'))\n", + "a, b = R.vars('a b')\n", + "print('-2 < 3 after a round trip:', (-2, 3) in a.lt(b))\n", + "print('still discrete:',\n", + " not reloaded.check('all x.(all y.(Lt(x,y) -> exists z.(Lt(x,z) and Lt(z,y))))'))" + ] + }, + { + "cell_type": "markdown", + "id": "2bda71d3", + "metadata": {}, + "source": [ + "What a file does **not** carry is the signature: a codec is Python code, not\n", + "data, so a reloaded presentation is a bare structure until you hand it one\n", + "again. Structures that ship with the library declare their own, which is why\n", + "`BuechiArithmeticZ().symbolic()` needs no argument while this one does.\n", + "\n", + "Serialization is a *string*-engine facility. Tree presentations — Skolem\n", + "arithmetic, the atomless Boolean algebra, the collapsible pushdown graphs —\n", + "have no file format yet; rebuild those from their constructors." + ] } ], "metadata": { diff --git a/notebooks/infinite_structures.ipynb b/notebooks/infinite_structures.ipynb new file mode 100644 index 0000000..def8d32 --- /dev/null +++ b/notebooks/infinite_structures.ipynb @@ -0,0 +1,275 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "722dd063", + "metadata": {}, + "source": [ + "# Infinite graphs, and how far first-order logic reaches\n", + "\n", + "The structures in the other notebooks are infinite but tame: arithmetic, orders,\n", + "lattices. This one is about infinite **graphs** — the Cayley graph of\n", + "$\\mathbb{Z}^n$, the infinite $k$-regular tree, the configuration graph of a\n", + "Turing machine, and the configuration graph of a level 2 collapsible pushdown\n", + "system.\n", + "\n", + "The last two are the interesting pair. Both present *computation* as a graph:\n", + "vertices are machine configurations, edges are single steps. Both have a\n", + "decidable first-order theory. But **reachability** — is there a run of any\n", + "length from here to there — is undecidable for the Turing machine and decidable\n", + "for the collapsible pushdown system, and that difference is visible in what the\n", + "library can hand you.\n", + "\n", + "> **Variable naming.** The formula parser treats a name as a variable only if it\n", + "> matches `[a-df-z][0-9]*` — a single lowercase letter (never `e`, which is the\n", + "> parser's event marker), optionally followed by digits." + ] + }, + { + "cell_type": "markdown", + "id": "c6515bc9", + "metadata": {}, + "source": [ + "## The integer grid\n", + "\n", + "$\\mathbb{Z}^n$ with its Cayley graph: vertices are integer points, and two are\n", + "adjacent when they differ by one in a single coordinate. It is built as an\n", + "**asynchronous product** of $n$ copies of $(\\mathbb{Z}, +)$ — the product that\n", + "advances exactly one coordinate and holds the rest, which is precisely a grid\n", + "step." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c26dad9f", + "metadata": {}, + "outputs": [], + "source": [ + "from autstr.infinite_graphs import IntegerGrid\n", + "\n", + "grid = IntegerGrid(2)\n", + "S = grid.symbolic()\n", + "x, y = S.vars('x y')\n", + "\n", + "print('(0,0) — (0,1):', ((0, 0), (0, 1)) in x.adj(y))\n", + "print('(0,0) — (1,1):', ((0, 0), (1, 1)) in x.adj(y), ' (diagonals are not edges)')\n", + "print('symmetric: ', grid.is_symmetric())\n", + "\n", + "neighbours = [b for (a, b), _ in zip(iter(x.eq((0, 0)) & x.adj(y)), range(4))]\n", + "print('neighbours of the origin:', neighbours)" + ] + }, + { + "cell_type": "markdown", + "id": "1b5bde03", + "metadata": {}, + "source": [ + "Vertices are Python tuples because the structure ships a **codec**: the points\n", + "go in and come out as `(a, b)`, while underneath each is a word over a product\n", + "alphabet." + ] + }, + { + "cell_type": "markdown", + "id": "bc7fa921", + "metadata": {}, + "source": [ + "## The regular tree\n", + "\n", + "The infinite $k$-regular tree $T_k$: vertices are finite words over $k$ letters,\n", + "each with $k$ children. It carries the child relation, the successors `S0`…`S{k-1}`\n", + "individually, and the **prefix order** — an ancestor relation, which is what makes\n", + "it more than a graph." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a3213a81", + "metadata": {}, + "outputs": [], + "source": [ + "from autstr.infinite_graphs import RegularTree\n", + "\n", + "tree = RegularTree(2)\n", + "print('relations:', tree.get_relation_symbols())\n", + "\n", + "T = tree.symbolic()\n", + "u, v = T.vars('u v')\n", + "print('root — its 0-child: ', ((), (0,)) in u.adj(v))\n", + "print('root is an ancestor: ', tree.check('exists u.(all v.(Prefix(u,v)))'))\n", + "print('nobody is their own child:',\n", + " tree.check('all u.(all v.(Child(u,v) -> (not Eq(u,v))))'))" + ] + }, + { + "cell_type": "markdown", + "id": "19a84ad5", + "metadata": {}, + "source": [ + "## Turing machines: where first-order logic stops\n", + "\n", + "A configuration is a state, a tape and a head position; one step is an edge. The\n", + "step relation is automatic — it rewrites a bounded window of the tape — so the\n", + "whole configuration graph is an automatic structure and its first-order theory\n", + "is decidable.\n", + "\n", + "What you may **not** ask is whether one configuration reaches another. That is\n", + "the halting problem, and no amount of cleverness puts it inside first-order logic\n", + "over this graph. So `autstr.turing` gives you `E` and `Halt`, and stops there." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "eacf10c5", + "metadata": {}, + "outputs": [], + "source": [ + "from autstr.turing import TuringMachine, Configuration\n", + "\n", + "# two states, flipping a bit and stepping right, then back\n", + "flipper = TuringMachine({('a', '0'): ('b', '1', 'R'),\n", + " ('a', '1'): ('b', '0', 'R'),\n", + " ('b', '0'): ('a', '0', 'L'),\n", + " ('b', '1'): ('a', '1', 'L')}, blank='0')\n", + "\n", + "machine = flipper.configuration_graph()\n", + "print('relations: ', machine.get_relation_symbols())\n", + "print('deterministic:', machine.is_deterministic())\n", + "\n", + "start = Configuration('a', ('1', '0'), 0)\n", + "after = flipper.step(start)\n", + "print('one step:', start.state, start.tape, start.head, '->',\n", + " after.state, after.tape, after.head)\n", + "\n", + "M = machine.symbolic()\n", + "p, q = M.vars('p q')\n", + "print('and that step is an edge:', (start, after) in p.adj(q))" + ] + }, + { + "cell_type": "markdown", + "id": "74a3bcf4", + "metadata": {}, + "source": [ + "Determinism *is* first-order — \"every configuration has at most one\n", + "successor\" quantifies over one step, not over runs — so it costs nothing beyond\n", + "`E`. Reachability would quantify over runs of unbounded length, and that is\n", + "exactly the line first-order logic does not cross." + ] + }, + { + "cell_type": "markdown", + "id": "c742b33d", + "metadata": {}, + "source": [ + "## Collapsible pushdown graphs: where reachability comes back\n", + "\n", + "A **level 2 collapsible pushdown system** has a stack of stacks. It can clone\n", + "the topmost word, push a letter carrying a *collapse link* back to the stack it\n", + "was created over, pop, and **collapse** — jump straight back to that stack,\n", + "however far away it now is. These systems generate the level 2 graphs of the\n", + "pushdown hierarchy.\n", + "\n", + "Their configuration graphs are **tree**-automatic rather than string-automatic\n", + "(Kartzow), and that is not a technicality: their MSO theory is undecidable, so\n", + "the tree encoding is the only automatic route to them. A stack of stacks is a\n", + "tree of blocks, and each stack operation is a bounded rewrite at the end of the\n", + "tree's last path.\n", + "\n", + "The payoff is the contrast with the Turing machine above. Here **reachability is\n", + "first-order definable** — a relation of the graph like any other — so you may\n", + "ask about runs of unbounded length." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3e6a9790", + "metadata": {}, + "outputs": [], + "source": [ + "from autstr.collapsible import Level2CPS\n", + "\n", + "# clone the topmost word, then pop it back off\n", + "system = Level2CPS([('0', None, 'c', '1', 'clone'),\n", + " ('1', None, 'o', '0', 'pop 2')], symbols=('a',))\n", + "graph = system.configuration_graph()\n", + "\n", + "print('relations:', graph.get_relation_symbols())" + ] + }, + { + "cell_type": "markdown", + "id": "82d062e7", + "metadata": {}, + "source": [ + "`Reach` is in that list but not yet built: it is **exponential in the number of\n", + "control states**, so it is declared up front and compiled the first time a query\n", + "mentions it. That deferral is the opt-out — nothing pays for it until it is\n", + "asked for.\n", + "\n", + "Kartzow's construction is a decomposition rather than a fixpoint. Every run\n", + "splits into four stretches — whole words come off the stack, then letters come\n", + "off, then letters go back on, then words go back on — and each stretch is its\n", + "own automaton. All four relations are *reflexive*, so composing them excludes\n", + "nothing, and reachability is the first-order formula\n", + "\n", + "$$\\mathrm{Reach}(x,y) \\;=\\; \\exists d\\, \\exists f\\, \\exists g.\\;\n", + "A(x,d) \\wedge B(d,f) \\wedge C(f,g) \\wedge D(g,y)$$\n", + "\n", + "over the four — no fifth automaton, and no fixpoint over trees." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d1dc4b5b", + "metadata": {}, + "outputs": [], + "source": [ + "import time\n", + "\n", + "t = time.perf_counter()\n", + "reach = graph.presentation.relation('Reach') # built on first use\n", + "print(f'Reach: {reach.num_states} states, built in {time.perf_counter() - t:.1f}s')\n", + "\n", + "# properties of runs of ANY length -- the questions the Turing graph cannot answer\n", + "print('reflexive: ', graph.check('all x.(Reach(x,x))'))\n", + "print('contains one step:', graph.check('all x.(all y.(E(x,y) -> Reach(x,y)))'))\n", + "print('transitive: ', graph.check(\n", + " 'all x.(all y.(all z.((Reach(x,y) & Reach(y,z)) -> Reach(x,z))))'))" + ] + }, + { + "cell_type": "markdown", + "id": "daec2974", + "metadata": {}, + "source": [ + "Transitivity is worth pausing on. It is a statement about *all* configurations\n", + "and *all* runs between them, decided by automata operations in well under a\n", + "second — and it is not a test that was passed, it is a theorem that was proved.\n", + "\n", + "Constraining the labels a run may read is `reach_along`, which builds Kartzow's\n", + "$\\mathrm{Reach}_L$ by putting the label automaton into a **product system**\n", + "whose ordinary reachability is the answer. An $\\varepsilon$-contraction — any\n", + "number of silent steps followed by one visible one — is the special case of that\n", + "with a two-state label automaton." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tests/test_serialization.py b/tests/test_serialization.py index 6ebfcbf..c6fdf72 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -1,3 +1,7 @@ +import json +import struct +import zlib + import numpy as np from autstr.arithmetic import BuechiArithmeticZ @@ -52,6 +56,80 @@ def test_round_trip_of_a_multi_tape_automaton(self): assert np.array_equal(reloaded.dense_next(), dfa.dense_next()) def test_the_legacy_flat_payload_still_loads(self): - """BuechiArithmeticZ ships a version-2 file (flat exception rows).""" - presentation = BuechiArithmeticZ() - assert presentation.check('exists x.(A(x,x,x))') # 0 + 0 = 0 + """Version 2 stored a default target plus flat exception rows, before + transitions became shared diagrams. Files written by those versions + must still load, so the payload is built here by hand -- the library + no longer ships one to read. + """ + # a two-state automaton over {0, 1, *}: accepts words ending in 1 + alphabet = sorted({'*', '0', '1'}) + code = {letter: i for i, letter in enumerate(alphabet)} + num_states, max_exceptions = 2, 1 + defaults = np.array([0, 0], dtype=np.uint32) # on anything else + ex_symbols = np.array([[code['1']], [code['1']]], dtype=np.int32) + ex_states = np.array([[1], [1]], dtype=np.int32) + is_accepting = np.array([0, 1], dtype=np.uint8) + + alphabet_json = json.dumps(alphabet).encode('utf-8') + payload = b''.join([ + struct.pack("IIIII", num_states, max_exceptions, 0, 1, + len(alphabet_json)), + alphabet_json, + defaults.tobytes(), + ex_symbols.tobytes(), + ex_states.tobytes(), + is_accepting.tobytes(), + ]) + data = struct.pack("4sB3sII", b'SDFA', 2, b'\0\0\0', + zlib.crc32(payload), len(payload)) + payload + + dfa = SparseDFASerializer.from_bytes(data) + + assert dfa.num_states == 2 + assert dfa.base_alphabet == {'*', '0', '1'} + assert dfa.accepts([('1',)]) + assert dfa.accepts([('0',), ('1',)]) + assert not dfa.accepts([('1',), ('0',)]) + + +class TestProductAlphabet: + """A presentation whose letters are tuples. + + An interpretation of dimension k > 1 encodes an element as a word over the + k-fold product alphabet, so both the alphabet's letters and the padding + symbol are tuples. JSON has no tuples: without restoring them on load, the + alphabet is a set of lists (unhashable) and the padding symbol is a list. + """ + + def _integers_from_naturals(self): + from autstr.arithmetic import BuechiArithmetic + from autstr.interpretations import interpret + pair = ['x0', 'x1', 'y0', 'y1'] + same = 'exists z.(A(x0,y1,z) and A(y0,x1,z))' + less = 'exists s.(exists t.(A(x0,y1,s) and A(y0,x1,t) and Lt(s,t)))' + return interpret( + BuechiArithmetic(), + domain=('Eq(x0,x0) and Eq(x1,x1)', ['x0', 'x1']), + relations={'Lt': (less, pair), 'Eq': (same, pair)}, + dimension=2, quotient=(same, pair)) + + def test_round_trip_through_a_file(self, tmp_path): + from autstr.presentations import AutomaticPresentation + presentation = self._integers_from_naturals() + path = str(tmp_path / 'integers.autstr') + presentation.automatic_presentation_to_file(path) + + reloaded = AutomaticPresentation.automatic_presentation_from_file(path) + + assert set(reloaded.get_relation_symbols()) == \ + set(presentation.get_relation_symbols()) + assert isinstance(reloaded.padding_symbol, tuple) + assert all(isinstance(letter, tuple) for letter in reloaded.sigma) + # the order it presents is still the order of the integers: + # no least element, and discrete rather than dense + assert not reloaded.check( + 'exists x.(all y.((not Lt(y,x)) and (not Eq(x,y))))') + assert not reloaded.check( + 'all x.(all y.(Lt(x,y) -> exists z.(Lt(x,z) and Lt(z,y))))') + assert _same_language(reloaded.automata['Lt'], + presentation.automata['Lt'], max_length=3) From 5a74b35bdd600380c5a9d746a90884710d5ee17e Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Thu, 13 Aug 2026 21:03:04 +0200 Subject: [PATCH 69/76] Notebooks: the atomless Boolean algebra, and MSO without the paren soup The countable atomless Boolean algebra had no notebook coverage. It sits next to the finite Boolean algebras, where the contrast is the point: finite ones are powersets and full of atoms, and dropping finiteness leaves exactly one countable algebra in which every nonzero element splits forever. Its elements are trees, so it is the tree engine's turn. Bipartiteness in the graphs notebook now appears twice: as the formula string, and built from `symbolic()` with `.implies`, `.all` and `.drop`. A set variable is just a variable, which is what makes the query monadic second-order, and the symbolic form makes that legible instead of nesting six parentheses. Checked to agree with the string form on all three of that cell's graphs. `infinite_structures` joins the documentation's notebook toctree. Co-Authored-By: Claude Opus 5 --- docs/source/index.rst | 1 + notebooks/arithmetic_and_algebra.ipynb | 57 ++++++++++++++++++++++++++ notebooks/graphs.ipynb | 31 ++++++++++++++ 3 files changed, 89 insertions(+) diff --git a/docs/source/index.rst b/docs/source/index.rst index 522f437..de3e520 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -18,6 +18,7 @@ reference is generated from the docstrings. :caption: Notebooks notebooks/arithmetic_and_algebra + notebooks/infinite_structures notebooks/graphs notebooks/groups notebooks/composition diff --git a/notebooks/arithmetic_and_algebra.ipynb b/notebooks/arithmetic_and_algebra.ipynb index ec53ce8..a824141 100644 --- a/notebooks/arithmetic_and_algebra.ipynb +++ b/notebooks/arithmetic_and_algebra.ipynb @@ -348,6 +348,63 @@ " dfa.accepts([(s,) for s in advice]))" ] }, + { + "cell_type": "markdown", + "id": "77fa08cf", + "metadata": {}, + "source": [ + "## The countable atomless Boolean algebra\n", + "\n", + "Every *finite* Boolean algebra is a powerset, so it is full of **atoms** —\n", + "minimal nonzero elements. Drop finiteness and something else becomes possible: a\n", + "Boolean algebra in which every nonzero element splits, forever. There is exactly\n", + "one countable such algebra up to isomorphism, and it is the clopen algebra of\n", + "Cantor space.\n", + "\n", + "Its elements are *sets of binary strings* — the cylinders a clopen set contains —\n", + "so an element is a finite tree, and the presentation lives on the **tree**\n", + "engine. Meet, join and complement are `*`, `+` and unary `-`; the lattice order\n", + "is `.leq`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26a35f81", + "metadata": {}, + "outputs": [], + "source": [ + "from autstr.tree_algebra import AtomlessBooleanAlgebra\n", + "\n", + "B = AtomlessBooleanAlgebra()\n", + "S = B.symbolic()\n", + "x, y, z = S.vars('x y z')\n", + "\n", + "# the cylinder \"strings starting 0\" and its complement partition the space\n", + "print('0 join 1 = everything:', ({'0'}, {'1'}, {''}) in (x + y).eq(z))\n", + "print('complement of 0 is 1: ', ({'0'}, {'1'}) in (-x).eq(y))\n", + "print('00 <= 0: ', ({'00'}, {'0'}) in x.leq(y))" + ] + }, + { + "cell_type": "markdown", + "id": "de601520", + "metadata": {}, + "source": [ + "The defining property is a *sentence*, so it is something to decide rather than\n", + "to assert: every nonzero element strictly contains a nonzero element." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3e249058", + "metadata": {}, + "outputs": [], + "source": [ + "print('atomless:', B.is_atomless())" + ] + }, { "cell_type": "markdown", "id": "f9cf091f", diff --git a/notebooks/graphs.ipynb b/notebooks/graphs.ipynb index 97f7b9a..87c5dc1 100644 --- a/notebooks/graphs.ipynb +++ b/notebooks/graphs.ipynb @@ -115,6 +115,37 @@ " print(f'{name:30s} bipartite = {cls.check(bipartite, TreeDepthGraph.from_networkx(G))}')" ] }, + { + "cell_type": "markdown", + "id": "b4aff901", + "metadata": {}, + "source": [ + "The same query without the parenthesis soup: `symbolic()` hands out variables and\n", + "relation symbols, and `.implies`, `.all` and `.drop` build the quantifiers. A set\n", + "variable is just a variable — `Subset(x, c)` reads it as a set, which is what makes\n", + "this monadic *second*-order rather than first." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "41259a4b", + "metadata": {}, + "outputs": [], + "source": [ + "S = cls.symbolic()\n", + "c, x, y = S.vars('c x y')\n", + "E, Subset = S.rel('E'), S.rel('Subset')\n", + "\n", + "bipartite_sym = (E(x, y).implies((Subset(x, c) & ~Subset(y, c))\n", + " | (~Subset(x, c) & Subset(y, c)))\n", + " ).all([x, y]).drop(c)\n", + "\n", + "for name, G in cases.items():\n", + " print(f'{name:30s} bipartite = '\n", + " f'{S.check_member(bipartite_sym, cls.advice(TreeDepthGraph.from_networkx(G)))}')" + ] + }, { "cell_type": "markdown", "id": "5826682f", From af1b5a417768b506ad718fcbc977fcae35c4eda8 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Thu, 13 Aug 2026 21:09:01 +0200 Subject: [PATCH 70/76] v4.0.0: the changelog moves to a file of its own The README carried the whole release history, which made it long and made the history shallow -- there was never room to say what a release actually changed. CHANGELOG.md takes it over, with v4.0.0 written out properly: what was added, what moved, what was fixed, and an upgrade path from 3.x. The docs build copies it in, so it has a page on the site too. What stays in the README is what only the README can say: the AI-assisted algorithm engineering narrative, the gource animation, the highlights of the newest version, and a link to the rest. The quick start now opens on the symbolic interface -- which is the honest entry point, and was also the one broken example in the file, since it still imported a class that the arithmetic rewrite deleted a release ago. Version 4.0.0 rather than 3.2: `autstr.buildin` is gone, `autstr.arithmetic` holds something else, and the old term-algebra front end has no replacement of the same shape. Co-Authored-By: Claude Opus 5 --- .gitignore | 1 + CHANGELOG.md | 270 ++++++++++++++++++++++++++++++++++++++++++ README.md | 151 ++++++++++------------- autstr/_version.py | 2 +- docs/source/conf.py | 4 + docs/source/index.rst | 6 + pyproject.toml | 2 +- 7 files changed, 345 insertions(+), 91 deletions(-) create mode 100644 CHANGELOG.md diff --git a/.gitignore b/.gitignore index 89c79c8..bcc1ca9 100644 --- a/.gitignore +++ b/.gitignore @@ -141,3 +141,4 @@ docs/source/api/ docs/source/notebooks/ docs/source/jupyter_execute/ docs/source/_media/ +docs/source/changelog.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d0d8c39 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,270 @@ +# Changelog + +All notable changes to AutStr are recorded here. Versions follow +[semantic versioning](https://semver.org): the major number changes when +existing code has to be edited to keep working. + +Each release since v2.0 is also a snapshot of an experiment in AI-assisted +algorithm engineering — see the [README](https://github.com/fariedabuzaid/AutStr#readme) for that story. + +--- + +## v4.0.0 — August 2026 + +**The symbolic release.** Queries stop being strings, structures start +declaring their own vocabulary, and the package stops pretending some of its +structures are more "built in" than others. + +### Added + +**A generic first-order layer — `autstr.symbolic`.** +Variables, relation symbols and function symbols that compose with ordinary +Python operators, compiled against either engine and against uniformly +automatic classes: + +```python +x, y, z = BuechiArithmeticZ().symbolic().vars('x y z') +phi = (x + y).eq(z) & z.lt(100) +phi.check() # satisfiable? +(3, 4, 7) in phi # membership +list(phi.drop(z)) # enumerate, smallest first +``` + +- `Signature` declares which relations are function graphs, which Python + operator each binds to, and — via an `ElementCodec` — how Python values + encode as elements. Terms nest (`((x + y) + z).eq(10)`), arities come from + the automata, and variable names survive into results. +- Quantifiers (`.all`, `.drop`, `.exinf`), Boolean connectives, `is_finite`, + membership, and lazy enumeration in length-lexicographic order. +- The same expressions compile against a class, where `check_member` and + `evaluate_member` decide one member — explicitly or implicitly. +- Ready-made signature builders: `operation_signature`, `order_signature`, + `graph_signature`, `relational_signature`. + +**Every structure declares its own vocabulary.** +`symbolic()` now takes no argument on every structure the library ships: +each provides `default_signature`, so operators and a codec are there from the +start. Büchi arithmetic over ℕ and over ℤ and the MSO0 powerset structure +gained theirs in this release; MSO0's is new capability rather than +convenience — union, intersection and difference as `+`, `*`, `-`, with +solutions returned as Python sets. + +**First-order interpretations — `autstr.interpretations`.** +`interpret` *computes* the interpreted presentation from a domain formula, a +formula per relation, an optional dimension *k* (elements are *k*-tuples) and +an optional definable equivalence: + +- quotients over words take the shortlex-least member of a class as its + representative; +- quotients over **trees** take the least *description*, since no order on + trees is well-founded (Kuske & Weidner) — the same idea made to work without + a well-order; +- `representatives` exposes the chosen representative of each class. + +**Infinite graphs — `autstr.infinite_graphs`, `autstr.turing`.** +`InfiniteGraph` is the shared surface (edge relation, symmetry check, codec); +concrete members are `IntegerGrid` (the Cayley graph of ℤⁿ, built as an +asynchronous product), `RegularTree` (T_k with successors and the prefix +order), and `ConfigurationGraph` for a Turing machine — whose first-order +theory is decidable while reachability, being halting, is not. + +**Ordinals — `autstr.ordinals`.** The ordinals below ω^ω on the string engine +and below ω^(ω^n) on the tree engine, each *interpreted* in arithmetic rather +than authored: three formulas over Büchi arithmetic, with the +reverse-lexicographic order on Cantor coefficients. + +**The countable atomless Boolean algebra — `autstr.tree_algebra`.** The clopen +algebra of Cantor space, with meet, join and complement as terms, and +`is_atomless` as a decided sentence rather than an assertion. + +**Level 2 collapsible pushdown graphs — `autstr.collapsible`.** +Stacks of stacks with collapse links, presented by Kartzow's tree encoding: +blocks hang off each other as right children, links are recovered from the +tree's shape rather than stored, and every stack operation is a bounded rewrite +at the end of the last path. MSO over these graphs is undecidable, so the tree +encoding is the only automatic route to them. + +**Reachability for those graphs — `autstr.collapsible_reach`.** +`Reach` is a relation of the configuration graph, so a first-order formula may +ask about runs of *any* length — the question a Turing machine's configuration +graph cannot answer. Following Kartzow's decomposition, every run splits into +four stretches and reachability is the first-order formula + + Reach(x,y) = ∃d ∃f ∃g. A(x,d) ∧ B(d,f) ∧ C(f,g) ∧ D(g,y) + +over four reflexive relations — no fifth automaton and no fixpoint over trees. +Underneath, `Summaries` computes per-word returns and loops by saturation +(the paper's own effectiveness argument routes through µ-calculus model +checking, which is not needed), and the nondeterministic guesses of Kartzow's +automata are carried on annotation tapes that are projected away — the move +MONA makes. `reach_along` builds the label-constrained `Reach_L` by putting +the label automaton into a product system; ε-contraction is a special case. + +**Deferred relations.** A presentation may declare a relation now and build it +on first use. `get_relation_symbols()` lists it, a query that mentions it +triggers the build. This is how `Reach` avoids costing anything until asked +for, and it is the opt-out for every expensive relation. + +**Smaller additions.** `partial_dfa` / `partial_tree_automaton`; +`restrict_alphabet`; `fold_tapes` for tree automata; `tree_order` and +`domain_within`; sentences usable as operands of a connective; an `Eq` +relation on every presentation. + +### Changed + +- **`autstr.buildin` is gone.** Every structure the library ships is built in, + so the name carved nothing. Its contents moved to where their subject lives: + + | was | now | + |---|---| + | `autstr.buildin.presentations` | `autstr.arithmetic` (Büchi ℕ and ℤ) and `autstr.powerset` (MSO0) | + | `autstr.buildin.tree_presentations` | `autstr.tree_arithmetic` (Skolem arithmetic) | + | `autstr.buildin.automata` | `autstr.utils.automata_tools` | + +- **`autstr.arithmetic` is a different module.** It holds the two Büchi + presentations themselves; the old term-algebra front end + (`VariableETerm` and friends) is replaced by the symbolic layer. +- `encode` / `decode` are now static methods on the presentations that own + them. +- Documentation builds execute every notebook, and the repository keeps them + output-free. + +### Fixed + +- **Enumeration over a product alphabet.** Every interpreted structure of + dimension > 1 — including the shipped `Ordinal(2)` — produced unusable + results from `iter(...)`: tapes were built by string-concatenating letters, + which holds only while a letter is a single character, so a tuple letter was + flattened into text no codec could read. Membership was unaffected, which is + why it went unseen. +- **Serializing such a presentation.** JSON has no tuples, so a reloaded + product alphabet was a set of lists (unhashable) and the padding symbol a + list. Both are restored on load. +- A universal quantifier that silently became an existential. +- Sentence markers that dropped the structure's alphabet. +- Relation automata are restricted to the universe on *every* tape, not all but + the last: a relation that still held of a non-element made universal + sentences false on encodings that were not elements at all. +- Spliced relations are restored even when a query fails. + +### Removed + +- **The precompiled `.autstr` artifacts.** They saved 0.03 s, 0.23 s and + 0.44 s against building from scratch, and the built presentations are + identical to the loaded ones relation by relation. Serialization itself is + unchanged and fully supported — see the composition notebook. +- `scripts/gen_builtin_presentations.py`, which generated them. + +### Upgrading from 3.x + +```python +# 3.x +from autstr.buildin.presentations import BuechiArithmeticZ, MSO0 +from autstr.buildin.tree_presentations import skolem_arithmetic +from autstr.arithmetic import integers, encode, decode + +# 4.0 +from autstr.arithmetic import BuechiArithmeticZ # .encode / .decode on it +from autstr.powerset import MSO0 +from autstr.tree_arithmetic import skolem_arithmetic +Z = BuechiArithmeticZ().symbolic() # was integers() +``` + +--- + +## v3.1.0 — July 2026 + +The rank-width release: new mathematics on top of the v3 engines. + +### Added + +- **Class-2 groups of bounded rank-width.** `CutRankGroups` (linear layouts), + `CutRankTreeGroups` (tree layouts) and `CocycleRankWidthGroups` (distributed + centers, microcode advice) — the advice spells out rank-≤r factorizations of + the commutation form's crossing blocks, cut by cut. +- **The chain-ring extension** (`autstr.chain_ring`). Everything generalizes + from F_p to R = ℤ/pᵈ: Smith normal form, saturated interfaces, the two-sided + factorization lemma. Centers of exponent pᵈ, widths measured as module + cut-rank, byte-identical at d = 1. +- **Factored advice letters.** Beyond ~20000 flat letters the cut-rank classes + stream one ring entry per letter through accumulator states, making width + r ≥ 2 over the ring representable (q+1 advice letters). +- **Implicit evaluation** (`autstr.implicit`). `check_implicit` / + `evaluate_implicit` on every class: first-order model checking and + satisfying-set computation that never build a query — or even a base — + automaton, reaching members (ℤ/8 and ℤ/9 words, ℤ/4 trees, the + distributed-center protocol) whose automata are infeasible. `ImplicitClass` / + `ImplicitTreeClass` are presentations given purely by transition functions. +- **Graphs of bounded rank-width.** `RankWidthClass` — rank decompositions as + advice, adjacency as a bilinear form on r-bit interface vectors, full MSO. + +### Changed + +- `show_diagram` no longer opens an external image viewer by default; pass + `view=True` for the old behavior. + +--- + +## v3.0.0 — July 2026 + +From strings to trees, with the transition representation replaced underneath +both engines. + +### Added + +- **Tree-automatic structures.** `autstr.sparse_tree_automata` (bottom-up tree + automata), `autstr.tree_presentations` and `autstr.tree_uniform` — the tree + counterparts of the whole stack. New members: **Skolem arithmetic** (ℕ, ·), + graphs of bounded **tree-width** and **clique-width** with full MSO, and + tree-indexed **extraspecial p-groups**. Cross-validated by embedding the + string engine's Büchi arithmetic into the tree engine and re-deciding every + sentence through both. +- **Composing presentations** (`autstr.composition`): disjoint union, + synchronous and asynchronous direct products of automatic structures, union + of uniformly automatic classes, and the direct-product closure of a class. + Composed, they present every finite direct product of index-≤2 cyclic groups + and extraspecial p-groups, from either family, and decide that such a product + is abelian exactly when all of its factors are. + +### Changed + +- **Transitions are shared multi-terminal BDDs** over the symbol's digits, in + both engines (`autstr.mtbdd`). `expand` became a variable renaming, + `complement` stopped touching diagrams at all, and `minimize` became one + `apply` per state per round. Queries impossible for lack of alphabet width + now compile: an arity-5 relation over a 14-letter alphabet (14⁵ = 537 824 + flat symbols) went from *infeasible* to 0.2 s; tree-depth-4 bipartiteness + from 17 s to 0.4 s. The test suite went from ~2 min to ~35 s. + +--- + +## v2.0.0 — July 2026 + +### Added + +- The whole uniformly-automatic layer: the generic advice machinery, bounded + tree-depth and pathwidth graphs with MSO, finite Boolean algebras, finite + abelian groups, the ℤ[1/p] presentations, and the non-abelian group classes — + each verified against exhaustive or exact ground-truth oracles. +- The [benchmark suite](https://github.com/fariedabuzaid/AutStr/tree/main/benchmarks) and the documentation site. + +### Changed + +- The automata core was profiled and rewritten as batched, sparsity-aware + NumPy: a **10²–10³× speedup** (the reference query dropped from 85 s to + 0.03 s), with linear memory. +- JAX went from a hard dependency to an optional accelerator; NumPy is the + canonical core. + +--- + +## v1.x — July 2025 + +Added the sparse-DFA backend, serialization, and the MSO0 finite-powerset +structure; modernized packaging. + +--- + +## v1.0 — 2022 + +The original library and its arithmetic front end. diff --git a/README.md b/README.md index cfeb484..4d70a81 100644 --- a/README.md +++ b/README.md @@ -43,27 +43,39 @@ from different angles. pip install autstr ``` -The friendliest entry point is the arithmetic package: relations over the -integers are first-class, exactly-represented **infinite** objects that you -combine with relational algebra. +Ask any structure for its **symbolic interface** and it hands you variables that +compose with ordinary Python operators. Relations are first-class, +exactly-represented **infinite** objects. ```python -from autstr.arithmetic import VariableETerm as Var +from autstr.arithmetic import BuechiArithmeticZ -x, y, z = Var('x'), Var('y'), Var('z') +Z = BuechiArithmeticZ().symbolic() # (ℤ, +, <, |₂); no setup required +x, y, z = Z.vars('x y z') R = (x + y + 3).lt(2 * x) # the infinite set { (x, y) : x + y + 3 < 2x } -R.isempty() # False +R.is_empty() # False (0, 4) in R # False — membership test band = (x + y).eq(z) & z.gt(0) & z.lt(3) # x + y = z ∧ 0 < z < 3 -solutions = band.drop(['z']) # project z away (an ∃ quantifier) -for s, _ in zip(solutions, range(3)): # enumerate lazily, smallest-first - print(s) # (0, 1), (1, 0), (1, 1), ... +for s, _ in zip(band.drop(z), range(3)): # ∃z, then enumerate smallest-first + print(s) # (0, 1), (1, 0), (1, 1) ``` Nothing is materialized until you iterate; `& | ~` are exact operations on -infinite sets. +infinite sets. Integers go in and come out because the structure ships a +**codec** along with its operators — and so does every other structure here, so +the same expressions work elsewhere unchanged: + +```python +from autstr.powerset import MSO0 + +a, b, c = MSO0().symbolic().vars('a b c') # finite sets of naturals +({0, 1}, {1, 2}, {0, 1, 2}) in (a + b).eq(c) # union — True +``` + +Where a formula reads better as text, `check` and `evaluate` still take +strings; the two are interchangeable. --- @@ -82,7 +94,7 @@ fragment these structures capture — and `evaluate` returns the automaton of *a satisfying assignments, which you can enumerate or reuse. ```python -from autstr.buildin.presentations import BuechiArithmeticZ +from autstr.arithmetic import BuechiArithmeticZ Z = BuechiArithmeticZ() # (ℤ, +, <, |) as automata Z.check('all x.(exists y.(A(x,y,x)))') # ∀x ∃y: x+y=x — True @@ -129,7 +141,7 @@ batch: | package | structures | |---------|------------| -| `autstr.arithmetic`, `autstr.buildin` | Presburger and Büchi arithmetic (ℤ, +, <, \|₂), Skolem arithmetic (ℕ, ·), the MSO0 finite-powerset structure | +| `autstr.arithmetic`, `autstr.tree_arithmetic`, `autstr.powerset` | Presburger and Büchi arithmetic (ℤ and ℕ, +, <, \|₂), Skolem arithmetic (ℕ, ·) on the tree engine, the MSO0 finite-powerset structure | | `autstr.algebra`, `autstr.tree_algebra` | the localizations **ℤ[1/p]**, finite **Boolean algebras**, and the countable **atomless Boolean algebra** | | `autstr.infinite_graphs`, `autstr.ordinals`, `autstr.turing` | the **integer grid** ℤⁿ, the **regular tree** T_k, the **ordinals** below ω^ω and — on the tree engine — below ω^(ω^ω), **Turing-machine configuration graphs** | | `autstr.collapsible`, `autstr.collapsible_reach` | **level 2 collapsible pushdown graphs** — tree-automatic by Kartzow's encoding, and the one automatic route to them, since their MSO theory is undecidable. **Reachability** is a relation of the graph, so first-order formulas may ask about runs of any length — the question a Turing machine's configuration graph cannot answer | @@ -162,8 +174,8 @@ Four capabilities cut across all of these: Büchi's theorem to Rabin's. The executable notebooks in [`notebooks/`](notebooks/) work through all of it, -one per area — arithmetic & algebra, graphs, groups, composition, and implicit -evaluation. +one per area — arithmetic & algebra, infinite structures, graphs, groups, +composition & interpretations, and implicit evaluation. --- @@ -184,7 +196,7 @@ Requires Python 3.10–3.14. The core depends only on NumPy, nltk, and graphviz. --- -## Changelog & an experiment in AI-assisted algorithm engineering +## An experiment in AI-assisted algorithm engineering AutStr began in 2022 as a summer project — a hands-on realization of the automatic structures its author had studied during his PhD in algorithmic model theory. @@ -197,80 +209,41 @@ mathematical direction and review kept firmly human. alt="Gource animation of the AutStr commit history, showing the file tree growing across the human, DeepSeek and Claude phases">

-- **v1.0 (2022) — human.** The original library and arithmetic front-end. -- **v1.x (July 2025) — DeepSeek.** A vibe-coding session (with extensive human - testing and supervision) that added the sparse-DFA backend, serialization, and - the MSO0 finite-powerset structure, and modernized packaging. -- **v2.0 (July 2026) — Claude, Anthropic's Fable 5 model.** An intensive two-day - pair-programming session inside Claude Code that: - - profiled and rewrote the entire automata core as batched, sparsity-aware - NumPy — a **10²–10³× speedup** (the reference query dropped from 85 s to - 0.03 s), with linear memory; - - migrated the library from a hard JAX dependency to a NumPy-canonical core with - JAX as an optional accelerator; - - built the whole uniformly-automatic layer — the generic advice machinery, - bounded tree-depth and pathwidth graphs with MSO, finite Boolean algebras, - finite abelian groups, the ℤ[1/p] presentations, and the non-abelian group - classes — each verified against exhaustive or exact ground-truth oracles; - - added the [benchmark suite](benchmarks/) and these docs. - - The ideas realized in v2 include constructions the author had sketched a decade - earlier; several went from a whiteboard description to running, tested code - within hours. The code is the model's; the theory, the choices, and the - verification protocol were human. - -- **v3.0 (July 2026) — Claude, (various models)** A second session, in - the same protocol, that took the library from strings to trees and replaced the - transition representation underneath both: - - **tree-automatic structures.** `autstr.sparse_tree_automata` (bottom-up tree - automata), `autstr.tree_presentations`, and `autstr.tree_uniform` — the tree - counterparts of the whole stack. New members: **Skolem arithmetic** (ℕ, ·), - graphs of bounded **tree-width** and bounded **clique-width** with full MSO, - and tree-indexed **extraspecial p-groups**. Cross-validated by embedding the - string engine's Büchi arithmetic into the tree engine and re-deciding every - sentence through both. - - **transitions are shared multi-terminal BDDs** over the symbol's digits, in - both engines. `expand` became a variable renaming, `complement` stopped - touching diagrams at all, and `minimize` became one `apply` per state per - round. Queries that had been impossible for lack of alphabet width now - compile: an arity-5 relation over a 14-letter alphabet (14⁵ = 537 824 flat - symbols) went from *infeasible* to 0.2 s; tree-depth-4 bipartiteness from - 17 s to 0.4 s. The test suite went from ~2 min to ~35 s. - - - **composing presentations.** `autstr.composition`: disjoint union and - synchronous/asynchronous direct products of automatic structures, union of - uniformly automatic classes, and the direct-product closure of a class. - Composed, they present every finite direct product of index-≤2 cyclic groups - and extraspecial p-groups, drawn from either family — and decide that such a - product is abelian exactly when all of its factors are. - -- **v3.1 (July 2026) — Claude, Fable 5.** The rank-width release: new - mathematics on top of the v3 engines, in the same human-directed protocol. - - **class-2 groups of bounded rank-width.** `CutRankGroups` (linear layouts), - `CutRankTreeGroups` (tree layouts) and `CocycleRankWidthGroups` - (distributed centers, microcode advice) — the advice spells out rank-≤r - factorizations of the commutation form's crossing blocks, cut by cut. - - **the chain-ring extension.** Everything generalizes from F_p to - R = ℤ/pᵈ (`autstr.chain_ring`: Smith normal form, saturated interfaces, - the two-sided factorization lemma) — centers of exponent pᵈ, widths - measured as module cut-rank, byte-identical at d = 1. - - **factored advice letters.** Beyond ~20000 flat letters the cut-rank - classes stream one ring entry per letter through accumulator states, - making width r ≥ 2 over the ring representable (q+1 advice letters). - - **implicit evaluation.** `check_implicit` / `evaluate_implicit` on every - class: first-order model checking and satisfying-set computation that - never build a query — or even a base — automaton, reaching members (ℤ/8 - and ℤ/9 words, ℤ/4 trees, the distributed-center protocol) whose - automata are infeasible; `ImplicitClass` / `ImplicitTreeClass` are - presentations given purely by transition functions. - - **graphs of bounded rank-width.** `RankWidthClass` — rank decompositions - as advice, adjacency as a bilinear form on r-bit interface vectors, - full MSO; the graph face of the same linear algebra. - - Docs are built by CI with all notebooks **executed during the build** - (the repository keeps them output-free). - - One behavior change to a 3.0 API: `show_diagram` no longer opens an - external image viewer by default (headless builds must not spawn one); - pass `view=True` for the old behavior. +- **v1.0 (2022) — human.** The original library and arithmetic front end. +- **v1.x (2025) — DeepSeek.** A vibe-coding session, with extensive human + testing and supervision. +- **v2.0–v3.1 (2026) — Claude.** The automata core rewritten as batched, + sparsity-aware NumPy (10²–10³× faster); the uniformly-automatic layer; the + step from strings to trees; MTBDD transitions; the rank-width families. +- **v4.0 (2026) — Claude.** This release. + +Several of the constructions realized here were sketched by the author a decade +earlier and had never been implemented; some went from a whiteboard description +to running, tested code within hours. The code is the model's; the theory, the +choices, and the verification protocol are human. + +### What is new in v4.0 + +- **A symbolic first-order layer.** Write `(x + y).eq(z) & z.lt(100)` instead of + a formula string, over any structure or class, on either engine. Every + structure declares its own operators and its own **codec**, so Python values + go in and come out and `symbolic()` needs no arguments. +- **First-order interpretations** that *compute* the interpreted presentation, + quotients included — over trees too, where the representative of a class is + its least description rather than its least member. +- **Infinite graphs**: the integer grid, the regular tree, and Turing-machine + configuration graphs, where first-order logic stops exactly at reachability. +- **Level 2 collapsible pushdown graphs, with reachability.** Tree-automatic by + Kartzow's encoding, and `Reach` is a relation of the graph — so a first-order + formula may ask about runs of any length, which is precisely what the Turing + graph cannot be asked. +- **The countable atomless Boolean algebra**, and the ordinals below ω^ω and + ω^(ω^n). +- `autstr.buildin` is gone: every structure the library ships is built in, so + its contents moved to modules named for their subject. See the changelog for + the upgrade path. + +📜 **[Full changelog](CHANGELOG.md)** — every release, in detail. --- diff --git a/autstr/_version.py b/autstr/_version.py index e276047..0951e03 100644 --- a/autstr/_version.py +++ b/autstr/_version.py @@ -1,2 +1,2 @@ # autstr/_version.py -__version__ = "3.1.0" +__version__ = "4.0.0" diff --git a/docs/source/conf.py b/docs/source/conf.py index 0d0e027..bcdbe02 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -59,6 +59,10 @@ for _img in sorted((_here.parent / 'media').glob('*.png')): shutil.copyfile(_img, _media_dst / _img.name) +# Copy the changelog into the source tree so it has a page of its own; it +# lives at the repository root, where GitHub and PyPI look for it. +shutil.copyfile(_here.parent.parent / 'CHANGELOG.md', _here / 'changelog.md') + nb_execution_mode = 'force' nb_execution_timeout = 900 nb_execution_raise_on_error = True diff --git a/docs/source/index.rst b/docs/source/index.rst index de3e520..1e365c5 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -30,6 +30,12 @@ reference is generated from the docstrings. api/modules +.. toctree:: + :maxdepth: 1 + :caption: Project + + changelog + Indices and tables ================== diff --git a/pyproject.toml b/pyproject.toml index 1110853..9be0573 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "autstr" -version = "3.1.0" +version = "4.0.0" description = "Library for working with finitely presentable infinite structures" readme = "README.md" authors = [ From 0e89e50db53a125c5c506b0dd8f5d3e972f36536 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Thu, 13 Aug 2026 21:10:07 +0200 Subject: [PATCH 71/76] Regenerate the history animation through v4.0.0 Gource over the full commit history, now reaching the symbolic layer, the interpretations, the infinite graphs and the collapsible pushdown work. The sieve automaton is unchanged, byte for byte -- the example it renders did not move. Co-Authored-By: Claude Opus 5 --- docs/media/history.gif | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/media/history.gif b/docs/media/history.gif index 8fb7f47..1803b84 100644 --- a/docs/media/history.gif +++ b/docs/media/history.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e059a611435f7f5fc6b8705ad9d8c360ab217e8e1bb7ff6f4147cacd307f017d -size 2418080 +oid sha256:f1d687abb50e728c2c957280a88847079bbfd731df6d09e4d11e0c07572fcb3e +size 2831196 From c91796abafd8942f96582845b62de47ead1bbd85 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Thu, 13 Aug 2026 22:02:06 +0200 Subject: [PATCH 72/76] Serialization for the tree engine The string side had it and the tree side did not, which put the expensive constructions on the wrong side of the line: a tree relation is where the cost lives, and `Reach` for a collapsible pushdown graph is exponential in the system's control states. It turned out to be a short walk. A tree automaton's compiled form is a sorted table of child pairs with one decision-diagram root each, so the payload is the pair keys plus the shared sub-DAG below those roots -- and `STORE.export` / `import_nodes` are generic over roots, so the diagram half needed no new code. A relation over a convolution alphabet too wide to enumerate still writes out in the size of its diagrams, exactly as on the string side. Two deliberate differences from the string presentation serializer: each automaton's payload is stored as raw bytes with a length prefix rather than as a JSON list of integers (which costs about four bytes of file per byte of data), and the JSON-has-no-tuples fix moved to `utils.misc` instead of being copied, since both engines need it now. Measured on the case it exists for: the collapsible `Reach` presentation is 22709 bytes, reloads in 0.01s against 0.5s to rebuild, and the reloaded relation is still reflexive, still contains the edge relation, and is still transitive. Skolem arithmetic round-trips and answers identical queries; a flipped payload byte is caught by the checksum. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 11 ++- autstr/sparse_automata.py | 19 ++--- autstr/sparse_tree_automata.py | 126 +++++++++++++++++++++++++++- autstr/tree_presentations.py | 103 ++++++++++++++++++++++- autstr/utils/misc.py | 14 +++- notebooks/composition.ipynb | 8 +- notebooks/infinite_structures.ipynb | 32 +++++++ tests/test_serialization.py | 82 ++++++++++++++++++ 8 files changed, 374 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0d8c39..53d7793 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,15 @@ automata are carried on annotation tapes that are projected away — the move MONA makes. `reach_along` builds the label-constrained `Reach_L` by putting the label automaton into a product system; ε-contraction is a special case. +**Serialization for the tree engine.** `SparseTreeAutomatonSerializer` and +`TreeAutomaticPresentationSerializer`, with the same method names as the string +side (`automatic_presentation_to_file` / `..._from_file`). A tree automaton's +compiled form is a sorted table of child pairs with one decision-diagram root +each, so the payload is the pair keys plus the shared sub-DAG below those +roots — a relation over an alphabet too wide to enumerate still writes out in +the size of its diagrams. This is where it earns its keep: `Reach` is +exponential in the control states, and a reloaded copy is still reachability. + **Deferred relations.** A presentation may declare a relation now and build it on first use. `get_relation_symbols()` lists it, a query that mentions it triggers the build. This is how `Reach` avoids costing anything until asked @@ -139,7 +148,7 @@ relation on every presentation. why it went unseen. - **Serializing such a presentation.** JSON has no tuples, so a reloaded product alphabet was a set of lists (unhashable) and the padding symbol a - list. Both are restored on load. + list. Both are restored on load, on both engines. - A universal quantifier that silently became an existential. - Sentence markers that dropped the structure's alphabet. - Relation automata are restricted to the universe on *every* tape, not all but diff --git a/autstr/sparse_automata.py b/autstr/sparse_automata.py index 2c4b065..6636780 100644 --- a/autstr/sparse_automata.py +++ b/autstr/sparse_automata.py @@ -32,7 +32,9 @@ def step(states, symbols): from autstr.mtbdd import ( NONE, STORE, TOP, ComputedTable, NodeStore, bits_of, num_bits, var_tables, ) -from autstr.utils.misc import decode_symbol, encode_symbol +from autstr.utils.misc import ( + alphabet_from_json, decode_symbol, encode_symbol, +) @@ -49,17 +51,6 @@ def step(states, symbols): # - Acceptance array # - Transition diagrams: var / lo / hi / term arrays, then one root per state -def _alphabet_from_json(raw) -> set: - """The base alphabet as stored in a payload. - - JSON has no tuples, so a product alphabet's letters -- which is what an - interpreted structure of dimension > 1 has -- come back as lists. Restore - them, or the set() below raises on the first unhashable letter. - """ - return {tuple(letter) if isinstance(letter, list) else letter - for letter in json.loads(raw)} - - class SparseDFASerializer: VERSION = 3 # diagram payload (v2 stored flat exception rows) HEADER_FORMAT = "4sB3sII" @@ -143,7 +134,7 @@ def _parse_payload(cls, payload: bytes, version: int = None) -> 'SparseDFA': num_states, num_nodes, start_state, symbol_arity, alphabet_len = \ struct.unpack(cls.METADATA_FORMAT, payload[:cls.METADATA_SIZE]) offset = cls.METADATA_SIZE - base_alphabet = _alphabet_from_json(payload[offset:offset + alphabet_len]) + base_alphabet = alphabet_from_json(payload[offset:offset + alphabet_len]) offset += alphabet_len is_accepting = np.frombuffer(payload, dtype=np.uint8, count=num_states, @@ -168,7 +159,7 @@ def _parse_payload_v2(cls, payload: bytes) -> 'SparseDFA': num_states, max_exceptions, start_state, symbol_arity, alphabet_len = \ struct.unpack(cls.METADATA_FORMAT, payload[:cls.METADATA_SIZE]) offset = cls.METADATA_SIZE - base_alphabet = _alphabet_from_json(payload[offset:offset + alphabet_len]) + base_alphabet = alphabet_from_json(payload[offset:offset + alphabet_len]) offset += alphabet_len defaults = np.frombuffer(payload, dtype=np.uint32, count=num_states, diff --git a/autstr/sparse_tree_automata.py b/autstr/sparse_tree_automata.py index 3a1c4a9..929d9d8 100644 --- a/autstr/sparse_tree_automata.py +++ b/autstr/sparse_tree_automata.py @@ -20,13 +20,16 @@ acceptance and touches no diagram at all, and hash-consing makes two states with the same transition function share one node. """ +import json +import struct +import zlib from collections import defaultdict, deque from typing import Dict, List, Optional, Sequence, Set, Tuple import numpy as np from autstr.mtbdd import NONE, STORE, num_bits, var_tables -from autstr.utils.misc import encode_symbol +from autstr.utils.misc import alphabet_from_json, encode_symbol # ==================================================================== @@ -615,3 +618,124 @@ def __repr__(self): return (f"SparseTreeAutomaton({self.num_states} states, " f"{len(self.pair_keys)} pairs, {self.num_nodes} nodes, " f"default={self.default_state})") + + +# ==================================================================== +# Serialization +# ==================================================================== +# +# [Header] magic 'STAU', version, reserved, CRC32 of payload, payload size +# [Payload] metadata (24 bytes), base alphabet (JSON), acceptance array, +# the packed pair keys, then the shared sub-DAG of the transition +# diagrams: var / lo / hi / term arrays and one root per pair. +# +# The diagrams are what makes this worth storing: a relation over a +# convolution alphabet too wide to enumerate still writes out in the size of +# its decision diagrams, exactly as on the string side. + +class SparseTreeAutomatonSerializer: + """Binary serialization for `SparseTreeAutomaton`. + + The tree counterpart of `autstr.sparse_automata.SparseDFASerializer`, and + the same payload idea: the compiled form is a sorted table of child pairs + with one diagram root each, so storing it is storing the pair keys plus + the sub-DAG below their roots. + """ + + MAGIC = b'STAU' + VERSION = 1 + HEADER_FORMAT = "4sB3sII" + HEADER_SIZE = struct.calcsize(HEADER_FORMAT) + #: num_states, num_nodes, num_pairs, default_state, symbol_arity, alphabet + METADATA_FORMAT = "IIIIII" + METADATA_SIZE = struct.calcsize(METADATA_FORMAT) + + @classmethod + def serialize(cls, automaton: 'SparseTreeAutomaton', filename: str) -> None: + with open(filename, 'wb') as handle: + handle.write(cls.to_bytes(automaton)) + + @classmethod + def deserialize(cls, filename: str) -> 'SparseTreeAutomaton': + with open(filename, 'rb') as handle: + return cls.from_bytes(handle.read()) + + @classmethod + def to_bytes(cls, automaton: 'SparseTreeAutomaton') -> bytes: + payload = cls._create_payload(automaton) + header = struct.pack(cls.HEADER_FORMAT, cls.MAGIC, cls.VERSION, + b'\0\0\0', zlib.crc32(payload), len(payload)) + return header + payload + + @classmethod + def from_bytes(cls, data: bytes) -> 'SparseTreeAutomaton': + if len(data) < cls.HEADER_SIZE: + raise ValueError("Data too short for header") + magic, version, _, checksum, payload_size = struct.unpack( + cls.HEADER_FORMAT, data[:cls.HEADER_SIZE]) + if magic != cls.MAGIC: + raise ValueError("Invalid SparseTreeAutomaton format") + if version != cls.VERSION: + raise ValueError( + f"Unsupported SparseTreeAutomaton version: {version}") + payload = data[cls.HEADER_SIZE:cls.HEADER_SIZE + payload_size] + if len(payload) != payload_size: + raise ValueError("Payload size mismatch") + if zlib.crc32(payload) != checksum: + raise ValueError("SparseTreeAutomaton data corruption detected") + return cls._parse_payload(payload) + + @classmethod + def _create_payload(cls, automaton: 'SparseTreeAutomaton') -> bytes: + var, lo, hi, term, roots = STORE.export(automaton.pair_nodes.tolist()) + alphabet_json = json.dumps( + sorted(automaton.base_alphabet_frozen)).encode('utf-8') + metadata = struct.pack( + cls.METADATA_FORMAT, + automaton.num_states, + len(var), + len(automaton.pair_keys), + automaton.default_state, + automaton.symbol_arity, + len(alphabet_json), + ) + return b''.join([ + metadata, + alphabet_json, + np.asarray(automaton.is_accepting, dtype=np.uint8).tobytes(), + np.asarray(automaton.pair_keys, dtype=np.int64).tobytes(), + var.astype(np.int64).tobytes(), + lo.astype(np.int64).tobytes(), + hi.astype(np.int64).tobytes(), + term.astype(np.int64).tobytes(), + np.asarray(roots, dtype=np.int64).tobytes(), + ]) + + @classmethod + def _parse_payload(cls, payload: bytes) -> 'SparseTreeAutomaton': + (num_states, num_nodes, num_pairs, default_state, symbol_arity, + alphabet_len) = struct.unpack(cls.METADATA_FORMAT, + payload[:cls.METADATA_SIZE]) + offset = cls.METADATA_SIZE + base_alphabet = alphabet_from_json(payload[offset:offset + alphabet_len]) + offset += alphabet_len + + is_accepting = np.frombuffer(payload, dtype=np.uint8, count=num_states, + offset=offset).astype(bool) + offset += num_states + + arrays = [] + for count in (num_pairs, num_nodes, num_nodes, num_nodes, num_nodes, + num_pairs): + arrays.append(np.frombuffer(payload, dtype=np.int64, count=count, + offset=offset)) + offset += count * 8 + pair_keys, var, lo, hi, term, roots = arrays + + return SparseTreeAutomaton( + num_states, default_state, + is_accepting=is_accepting, + symbol_arity=symbol_arity, + base_alphabet=base_alphabet, + pair_keys=pair_keys, + pair_nodes=STORE.import_nodes(var, lo, hi, term, roots)) diff --git a/autstr/tree_presentations.py b/autstr/tree_presentations.py index c4100cb..e89fe3a 100644 --- a/autstr/tree_presentations.py +++ b/autstr/tree_presentations.py @@ -11,12 +11,17 @@ `project` — which produces the canonical (trimmed) language — is followed by re-saturation, the tree analog of the string pipeline's pad/unpad dance. """ +import json +import struct +import zlib from typing import Dict, Optional, Union from nltk.sem import logic from autstr.presentations import DeferredRelations -from autstr.sparse_tree_automata import SparseTreeAutomaton +from autstr.sparse_tree_automata import ( + SparseTreeAutomaton, SparseTreeAutomatonSerializer, +) from autstr.utils.logic import get_free_elementary_vars, optimize_query from autstr.utils.tree_automata_tools import ( attach_padding, expand, minimize, project, @@ -35,6 +40,87 @@ def tree_zero(symbol_arity: int = 1, base_alphabet=None) -> SparseTreeAutomaton: symbol_arity, base_alphabet or {0}) +class TreeAutomaticPresentationSerializer: + """Binary serialization for `TreeAutomaticPresentation`. + + Mirrors `autstr.presentations.AutomaticPresentationSerializer`, but stores + each automaton's payload as raw bytes with a length prefix rather than as + a JSON list of integers -- which costs roughly four bytes of file per byte + of data. + """ + + MAGIC = b'TPRS' + VERSION = 1 + HEADER_FORMAT = "4sB3sII" + HEADER_SIZE = struct.calcsize(HEADER_FORMAT) + + @classmethod + def serialize(cls, presentation, filename: str) -> None: + payload = cls._create_payload(presentation) + header = struct.pack(cls.HEADER_FORMAT, cls.MAGIC, cls.VERSION, + b'\0\0\0', zlib.crc32(payload), len(payload)) + with open(filename, 'wb') as handle: + handle.write(header) + handle.write(payload) + + @classmethod + def deserialize(cls, filename: str) -> 'TreeAutomaticPresentation': + with open(filename, 'rb') as handle: + data = handle.read() + magic, version, _, checksum, payload_size = struct.unpack( + cls.HEADER_FORMAT, data[:cls.HEADER_SIZE]) + if magic != cls.MAGIC: + raise ValueError("Invalid file format (bad magic number)") + if version != cls.VERSION: + raise ValueError(f"Unsupported version: {version}") + payload = data[cls.HEADER_SIZE:cls.HEADER_SIZE + payload_size] + if len(payload) != payload_size: + raise ValueError("Payload size mismatch") + if zlib.crc32(payload) != checksum: + raise ValueError("Data corruption detected (checksum mismatch)") + return cls._parse_payload(payload) + + @classmethod + def _create_payload(cls, presentation) -> bytes: + names = list(presentation.automata) + metadata = json.dumps({ + "padding_symbol": presentation.padding_symbol, + "max_states": presentation.max_states, + "names": names, + }).encode('utf-8') + chunks = [struct.pack("I", len(metadata)), metadata] + for name in names: + blob = SparseTreeAutomatonSerializer.to_bytes( + presentation.automata[name]) + chunks.append(struct.pack("I", len(blob))) + chunks.append(blob) + return b''.join(chunks) + + @classmethod + def _parse_payload(cls, payload: bytes) -> 'TreeAutomaticPresentation': + metadata_len, = struct.unpack("I", payload[:4]) + offset = 4 + metadata = json.loads(payload[offset:offset + metadata_len]) + offset += metadata_len + + automata = {} + for name in metadata["names"]: + size, = struct.unpack("I", payload[offset:offset + 4]) + offset += 4 + automata[name] = SparseTreeAutomatonSerializer.from_bytes( + payload[offset:offset + size]) + offset += size + + # over a product alphabet the padding symbol is a tuple, and JSON has + # no tuples + padding_symbol = metadata["padding_symbol"] + if isinstance(padding_symbol, list): + padding_symbol = tuple(padding_symbol) + return TreeAutomaticPresentation( + automata, padding_symbol=padding_symbol, + enforce_consistency=False, max_states=metadata["max_states"]) + + class TreeAutomaticPresentation(DeferredRelations): """A presentation of a structure by tree automata. @@ -82,6 +168,21 @@ def _domain_product(self, arity: int) -> SparseTreeAutomaton: minimize(expand(self.automata['U'], arity, [i])))) return domain + def automatic_presentation_to_file(self, filename: str) -> None: + """Write the presentation -- domain, every built relation, alphabet -- + to a file. Deferred relations that have not been built are not stored; + call `materialize` first to include them.""" + TreeAutomaticPresentationSerializer.serialize(self, filename) + + @classmethod + def automatic_presentation_from_file(cls, filename: str): + """Read a presentation written by `automatic_presentation_to_file`. + + The signature is not stored -- a codec is Python code, not data -- so + the result is a bare structure until `symbolic` is given one. + """ + return TreeAutomaticPresentationSerializer.deserialize(filename) + def _install_relation(self, name, definition): self.update(**{name: definition() if callable(definition) else definition}) diff --git a/autstr/utils/misc.py b/autstr/utils/misc.py index a8577cd..37d515a 100644 --- a/autstr/utils/misc.py +++ b/autstr/utils/misc.py @@ -1,3 +1,4 @@ +import json from functools import cmp_to_key from heapq import heapify, heappop, heappush from typing import FrozenSet, Set, List, Tuple, Union @@ -113,4 +114,15 @@ def complement(values, min_val: int, max_val: int) -> np.ndarray: values = np.asarray(values) if values.size == 0: return np.array([], dtype=values.dtype) - return np.setdiff1d(np.arange(min_val, max_val + 1), values) \ No newline at end of file + return np.setdiff1d(np.arange(min_val, max_val + 1), values) + + +def alphabet_from_json(raw) -> set: + """The base alphabet as stored in a serialized payload. + + JSON has no tuples, so a product alphabet's letters -- which is what an + interpreted structure of dimension > 1 has -- come back as lists. Restore + them, or the set() built from them raises on the first unhashable letter. + """ + return {tuple(letter) if isinstance(letter, list) else letter + for letter in json.loads(raw)} diff --git a/notebooks/composition.ipynb b/notebooks/composition.ipynb index 00d94f5..6fcf36e 100644 --- a/notebooks/composition.ipynb +++ b/notebooks/composition.ipynb @@ -385,9 +385,11 @@ "again. Structures that ship with the library declare their own, which is why\n", "`BuechiArithmeticZ().symbolic()` needs no argument while this one does.\n", "\n", - "Serialization is a *string*-engine facility. Tree presentations — Skolem\n", - "arithmetic, the atomless Boolean algebra, the collapsible pushdown graphs —\n", - "have no file format yet; rebuild those from their constructors." + "Tree presentations serialize the same way and with the same method names. There\n", + "the payload matters more, because tree relations are where the expensive\n", + "constructions live — the reachability relation of a collapsible pushdown graph\n", + "is exponential in the system's control states, and once built it is worth\n", + "keeping." ] } ], diff --git a/notebooks/infinite_structures.ipynb b/notebooks/infinite_structures.ipynb index def8d32..7ab7e9d 100644 --- a/notebooks/infinite_structures.ipynb +++ b/notebooks/infinite_structures.ipynb @@ -258,6 +258,38 @@ "number of silent steps followed by one visible one — is the special case of that\n", "with a two-state label automaton." ] + }, + { + "cell_type": "markdown", + "id": "5ab0a6b5", + "metadata": {}, + "source": [ + "### Keeping what was expensive\n", + "\n", + "`Reach` cost real work to build, and nothing about it depends on the session.\n", + "Tree presentations serialize exactly as string ones do, so it can be written\n", + "out and read back — the reloaded relation is still reachability." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1b4ac295", + "metadata": {}, + "outputs": [], + "source": [ + "import tempfile, os\n", + "from autstr.tree_presentations import TreeAutomaticPresentation\n", + "\n", + "path = os.path.join(tempfile.mkdtemp(), 'collapsible.autstr')\n", + "graph.presentation.automatic_presentation_to_file(path)\n", + "print(f'{os.path.getsize(path)} bytes on disk')\n", + "\n", + "reloaded = TreeAutomaticPresentation.automatic_presentation_from_file(path)\n", + "print('Reach is there:', 'Reach' in reloaded.get_relation_symbols())\n", + "print('and still transitive:', reloaded.check(\n", + " 'all x.(all y.(all z.((Reach(x,y) & Reach(y,z)) -> Reach(x,z))))'))" + ] } ], "metadata": { diff --git a/tests/test_serialization.py b/tests/test_serialization.py index c6fdf72..584eb0b 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -3,6 +3,7 @@ import zlib import numpy as np +import pytest from autstr.arithmetic import BuechiArithmeticZ from autstr.sparse_automata import SparseDFA, SparseDFASerializer @@ -133,3 +134,84 @@ def test_round_trip_through_a_file(self, tmp_path): 'all x.(all y.(Lt(x,y) -> exists z.(Lt(x,z) and Lt(z,y))))') assert _same_language(reloaded.automata['Lt'], presentation.automata['Lt'], max_length=3) + + +class TestTreeSerialization: + """The tree engine's counterpart. + + The compiled form of a tree automaton is a sorted table of child pairs with + one decision-diagram root each, so storing it is storing the pair keys plus + the shared sub-DAG below those roots -- the same payload idea as the string + side, and the same reason for it: a relation over a convolution alphabet + too wide to enumerate still writes out in the size of its diagrams. + """ + + def test_automaton_round_trip(self): + from autstr.sparse_tree_automata import SparseTreeAutomatonSerializer + from autstr.tree_arithmetic import skolem_arithmetic + + original = skolem_arithmetic().automata['M'] # x * y = z + reloaded = SparseTreeAutomatonSerializer.from_bytes( + SparseTreeAutomatonSerializer.to_bytes(original)) + + assert reloaded.num_states == original.num_states + assert reloaded.default_state == original.default_state + assert reloaded.symbol_arity == original.symbol_arity + assert reloaded.base_alphabet_frozen == original.base_alphabet_frozen + assert list(reloaded.is_accepting) == list(original.is_accepting) + assert list(reloaded.pair_keys) == list(original.pair_keys) + + def test_corruption_is_detected(self): + from autstr.sparse_tree_automata import SparseTreeAutomatonSerializer + from autstr.tree_arithmetic import skolem_arithmetic + + data = bytearray(SparseTreeAutomatonSerializer.to_bytes( + skolem_arithmetic().automata['U'])) + data[-1] ^= 0xFF # flip a byte of the payload + with pytest.raises(ValueError, match='corruption'): + SparseTreeAutomatonSerializer.from_bytes(bytes(data)) + + def test_presentation_round_trip_answers_the_same_questions(self, tmp_path): + from autstr.tree_arithmetic import skolem_arithmetic + from autstr.tree_presentations import TreeAutomaticPresentation + + skolem = skolem_arithmetic() + path = str(tmp_path / 'skolem.autstr') + skolem.automatic_presentation_to_file(path) + + reloaded = TreeAutomaticPresentation.automatic_presentation_from_file(path) + + assert set(reloaded.get_relation_symbols()) == \ + set(skolem.get_relation_symbols()) + assert reloaded.padding_symbol == skolem.padding_symbol + for phi in ('exists x.(all y.(M(x,y,y)))', # a multiplicative unit + 'all x.(exists y.(M(x,y,x)))'): + assert reloaded.check(phi) == skolem.check(phi) + + # the signature is not stored, so hand the reloaded structure the + # original's and check that elements still mean what they meant + S = reloaded.symbolic(skolem.default_signature()) + x, y, z = S.vars('x y z') + assert (6, 35, 210) in (x * y).eq(z) + assert (6, 35, 211) not in (x * y).eq(z) + + def test_an_expensive_relation_survives(self, tmp_path): + """The case this exists for: `Reach` costs real time to build, and a + reloaded copy must still be the reachability relation.""" + from autstr.collapsible import Level2CPS + from autstr.tree_presentations import TreeAutomaticPresentation + + system = Level2CPS([('0', None, 'c', '1', 'clone'), + ('1', None, 'o', '0', 'pop 2')], symbols=('a',)) + presentation = system.configuration_graph().presentation + presentation.relation('Reach') # build it + + path = str(tmp_path / 'reach.autstr') + presentation.automatic_presentation_to_file(path) + reloaded = TreeAutomaticPresentation.automatic_presentation_from_file(path) + + assert 'Reach' in reloaded.get_relation_symbols() + assert reloaded.check('all x.(Reach(x,x))') + assert reloaded.check('all x.(all y.(E(x,y) -> Reach(x,y)))') + assert reloaded.check( + 'all x.(all y.(all z.((Reach(x,y) & Reach(y,z)) -> Reach(x,z))))') From a4987f7e329078af6799f9c6d7fa07aa28933b5a Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Thu, 13 Aug 2026 22:02:17 +0200 Subject: [PATCH 73/76] References: four in the README, all of them in the documentation The README carried eleven references and the documentation none, which is backwards -- a reader of the docs meets the constructions and has nowhere to follow them. `references.md` now holds the bibliography, grouped and annotated with what each work IS in this library, and the README keeps the four it leans on hardest plus a link. Citing where it was due exposed a gap: the overview had no section on any of the v4 material. It gains two -- interpretations (with the tree quotient's least description, and the ordinals as the payoff) and infinite graphs (the grid, the tree, Turing configuration graphs, and the collapsible pushdown graphs where reachability comes back). Each names its source at the point of use: Kuske & Weidner and Colcombet & Loeding on tree quotients, Delhomme on why `Ordinal` takes an exponent, Kartzow on the encoding and the run decomposition, Courcelle on the linear-time claim, Abu Zaid/Graedel/Reinhardt on advice. Three entries had no bibliographic details anywhere in the repository and could not be verified offline (Delhomme 2004, Oum & Seymour 2006, Colcombet & Loeding 2007); they carry author, title and venue but no DOI, pending a check. Co-Authored-By: Claude Opus 5 --- README.md | 96 +++++++---------------- docs/source/index.rst | 1 + docs/source/overview.md | 85 +++++++++++++++++++-- docs/source/references.md | 157 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 262 insertions(+), 77 deletions(-) create mode 100644 docs/source/references.md diff --git a/README.md b/README.md index 4d70a81..75fd0b0 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,8 @@ choices, and the verification protocol are human. graph cannot be asked. - **The countable atomless Boolean algebra**, and the ordinals below ω^ω and ω^(ω^n). +- **Serialization for the tree engine**, so an expensive relation is built once + and kept. - `autstr.buildin` is gone: every structure the library ships is built in, so its contents moved to modules named for their subject. See the changelog for the upgrade path. @@ -249,81 +251,37 @@ choices, and the verification protocol are human. ## References -1. **Abu Zaid, F.** *Algorithmic Solutions via Model Theoretic Interpretations.* - Dissertation, RWTH Aachen University, 2016. - DOI: [10.18154/RWTH-2017-07663](https://doi.org/10.18154/RWTH-2017-07663) +AutStr implements a line of work that runs from Büchi's theorem to uniformly +automatic classes. The four below are the ones it leans on most; the +**[full bibliography](https://fariedabuzaid.github.io/AutStr/references.html)** +in the documentation lists the rest — Rabin, Courcelle, Delhommé, Kuske & +Weidner, Kartzow and others — and says for each what in the library it is. -2. **Abu Zaid, F.** *Uniformly Automatic Classes of Finite Structures.* - FSTTCS 2018, LIPIcs vol. 122, pp. 10:1–10:21. - DOI: [10.4230/LIPIcs.FSTTCS.2018.10](https://doi.org/10.4230/LIPIcs.FSTTCS.2018.10) - *The meta-theorems for finite Boolean algebras, finite groups, and graphs of - bounded tree-depth implemented by `autstr.uniform`, `autstr.graphs`, - `autstr.algebra`, and `autstr.groups`.* - -3. **Abu Zaid, F., Grädel, E., & Reinhardt, F.** *Advice Automatic Structures and - Uniformly Automatic Classes.* CSL 2017, LIPIcs vol. 82, pp. 35:1–35:20. - DOI: [10.4230/LIPIcs.CSL.2017.35](https://doi.org/10.4230/LIPIcs.CSL.2017.35) - *Introduces automatic presentations with advice — the foundation of the uniform - classes here; the ℤ[1/p] presentation follows its blueprint for (ℚ, +).* - -4. **Blumensath, A., & Grädel, E.** *Automatic Structures.* LICS 2000, pp. 51–62. - [Proceedings](https://lics.siglog.org/2000/Grdel-AutomaticStructures.html) +1. **Büchi, J. R.** *Weak Second-Order Arithmetic and Finite Automata.* + Zeitschrift für math. Logik und Grundlagen der Mathematik 6 (1960), 66–92. + DOI: [10.1002/malq.19600060105](https://doi.org/10.1002/malq.19600060105) + *Every `evaluate` call in this library is this construction.* -5. **Khoussainov, B., & Nerode, A.** *Automatic presentations of structures.* +2. **Khoussainov, B., & Nerode, A.** *Automatic presentations of structures.* LCC 1994, LNCS vol. 960, Springer. DOI: [10.1007/3-540-60178-3_93](https://doi.org/10.1007/3-540-60178-3_93) -6. **Khoussainov, B., Rubin, S., & Stephan, F.** *Automatic Structures: Richness - and Limitations.* LMCS 3(2), 2007. - arXiv: [cs/0703064](https://arxiv.org/abs/cs/0703064) · - DOI: [10.2168/LMCS-3(2:2)2007](https://doi.org/10.2168/LMCS-3%282%3A2%292007) - -### Foundations - -The idea that a logic can be decided by translating formulas into automata long -predates the term *automatic structure*; this library is a late implementation of -a line of work that runs through: +3. **Blumensath, A., & Grädel, E.** *Automatic Structures.* LICS 2000, pp. 51–62. + [Proceedings](https://lics.siglog.org/2000/Grdel-AutomaticStructures.html) + *Closure under first-order definability — the decidability `check` rests on.* -7. **Büchi, J. R.** *Weak Second-Order Arithmetic and Finite Automata.* - Zeitschrift für math. Logik und Grundlagen der Mathematik 6 (1960), 66–92. - DOI: [10.1002/malq.19600060105](https://doi.org/10.1002/malq.19600060105) - *Monadic second-order logic over (ℕ, +1) is decidable, by translation into - finite automata. Every `evaluate` call in this library is this construction.* - -8. **Rabin, M. O.** *Decidability of Second-Order Theories and Automata on - Infinite Trees.* Transactions of the AMS 141 (1969), 1–35. - DOI: [10.2307/1995086](https://doi.org/10.2307/1995086) - *The same programme over trees. `autstr.sparse_tree_automata` and the - tree-automatic presentations are the finite-tree fragment of this.* - -9. **Courcelle, B.** *The Monadic Second-Order Logic of Graphs I: Recognizable - Sets of Finite Graphs.* Information and Computation 85(1), 1990, 12–75. - DOI: [10.1016/0890-5401(90)90043-H](https://doi.org/10.1016/0890-5401%2890%2990043-H) - *MSO properties of graphs of bounded tree-width are decidable in linear time. - `autstr.tree_graphs.TreeWidthClass` builds the automaton the theorem promises.* - -10. **Courcelle, B., & Olariu, S.** *Upper Bounds to the Clique Width of Graphs.* - Discrete Applied Mathematics 101 (2000), 77–114. - DOI: [10.1016/S0166-218X(99)00184-5](https://doi.org/10.1016/S0166-218X%2899%2900184-5) - *The k-expressions that `autstr.tree_graphs.CliqueWidthClass` reads as advice.* - -11. **Makowsky, J. A.** *Algorithmic Uses of the Feferman–Vaught Theorem.* - Annals of Pure and Applied Logic 126 (2004), 159–213. - DOI: [10.1016/j.apal.2003.11.002](https://doi.org/10.1016/j.apal.2003.11.002) - *The composition method behind meta-theorems of this shape.* +4. **Abu Zaid, F.** *Uniformly Automatic Classes of Finite Structures.* + FSTTCS 2018, LIPIcs vol. 122, pp. 10:1–10:21. + DOI: [10.4230/LIPIcs.FSTTCS.2018.10](https://doi.org/10.4230/LIPIcs.FSTTCS.2018.10) + *The meta-theorems behind `autstr.uniform`, `autstr.graphs`, + `autstr.algebra` and `autstr.groups`.* ### Related tools -- **[MONA](https://www.brics.dk/mona/)** (Klarlund, Møller, Henriksen et al.) - decides WS1S and WS2S by translating formulas to automata whose transitions are - shared multi-terminal BDDs over the symbol's bits. AutStr's - [`autstr.mtbdd`](autstr/mtbdd.py) adopts exactly that representation, for - exactly MONA's reason: over a convolution alphabet, the flat - `symbol -> target` table is the bottleneck. -- **[Walnut](https://cs.uwaterloo.ca/~shallit/walnut.html)** (Mousavi, Shallit) - proves theorems about automatic sequences by deciding first-order statements - over (ℕ, +) with automata — the same decision procedure, aimed at combinatorics - on words rather than at presenting structures. - -Both are mature and fast, and neither targets *uniformly* automatic classes or -arbitrary automatic presentations, which is where AutStr sits. +**[MONA](https://www.brics.dk/mona/)** decides WS1S and WS2S with automata whose +transitions are shared multi-terminal BDDs over the symbol's bits; +[`autstr.mtbdd`](autstr/mtbdd.py) adopts exactly that representation, for +exactly MONA's reason. **[Walnut](https://cs.uwaterloo.ca/~shallit/walnut.html)** +proves theorems about automatic sequences by deciding first-order statements +over (ℕ, +). Both are mature and fast, and neither targets *uniformly* automatic +classes or arbitrary automatic presentations, which is where AutStr sits. diff --git a/docs/source/index.rst b/docs/source/index.rst index 1e365c5..38fbc99 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -35,6 +35,7 @@ reference is generated from the docstrings. :caption: Project changelog + references Indices and tables ================== diff --git a/docs/source/overview.md b/docs/source/overview.md index 8bd4eac..43574b7 100644 --- a/docs/source/overview.md +++ b/docs/source/overview.md @@ -25,8 +25,9 @@ Z.check('all x.(exists y.(A(x,y,x)))') # ∀x ∃y: x+y=x — True Z.check('exists x.(all y.(Lt(x,y)))') # a least integer? — False ``` -Because the first-order theory of an automatic structure is decidable, `check` -always terminates with a definite answer — a theorem prover for the fragment of +Because the first-order theory of an automatic structure is decidable +({ref}`Khoussainov & Nerode `, {ref}`Blumensath & Grädel +`), `check` always terminates with a definite answer — a theorem prover for the fragment of mathematics these structures capture. `evaluate` goes further and returns the automaton of *all* satisfying assignments, which you can enumerate or reuse. The {doc}`arithmetic & algebra notebook ` walks @@ -118,9 +119,10 @@ element of ℤ[1/2] is 2-divisible but not 3-divisible, and vice versa for ℤ[1 ## Uniformly automatic classes: one automaton for a whole family -A **uniformly automatic class** presents not one structure but an entire *family*, -by giving every automaton one extra tape that reads an **advice string** -synchronously with the elements. Fixing the advice instantiates one member; a +A **uniformly automatic class** ({ref}`Abu Zaid, Grädel & Reinhardt +`) presents not one structure but an entire *family*, by giving +every automaton one extra tape that reads an **advice string** synchronously +with the elements. Fixing the advice instantiates one member; a query is compiled once for the class and then decides any member by running its advice word through the resulting automaton. @@ -152,7 +154,8 @@ complex relations from primitives. The same machinery runs over **trees** rather than words. Where an automatic presentation encodes elements as strings and a word automaton reads them, a *tree-automatic* presentation encodes them as finite trees read by a bottom-up -tree automaton — exactly the step from Büchi's theorem to Rabin's. +tree automaton — exactly the step from {ref}`Büchi's theorem ` to +{ref}`Rabin's `. `autstr.tree_uniform` hosts the classes whose advice is naturally a tree: a tree decomposition (bounded tree-width) or a k-expression (bounded clique-width), and Skolem arithmetic (ℕ, ·) in `autstr.tree_arithmetic`, where a number @@ -221,6 +224,70 @@ cyclic groups, and it predates the module. The {doc}`composition notebook ` walks through all five operations. +## Defining one structure inside another + +Automatic structures are closed under **first-order interpretation**, and +`autstr.interpretations` does not merely record that — it *computes* the +interpreted presentation. You give a domain formula δ(x̄), a formula per +relation, a dimension *k* (an element is a *k*-tuple of source elements), and +optionally a definable equivalence ε whose classes become the elements: + +```python +Z = interpret(BuechiArithmetic(), # the integers from the naturals + domain=('Eq(x0,x0) and Eq(x1,x1)', ['x0', 'x1']), + relations={'Lt': (LESS, pair), 'Eq': (SAME, pair)}, + dimension=2, quotient=(SAME, pair)) +``` + +A quotient needs one representative per class. Over words that is the +shortlex-least member, and shortlex is a well-order, so it exists. Over +**trees** no automatic order is well-founded — growing a tree where two members +differ makes it *smaller* — so a class need have no least member at all, and +the representative is instead the least *description* +({ref}`Kuske & Weidner `; existence is +{ref}`Colcombet & Löding `). That construction is provably +exponential in the worst case, which is why `max_states` is worth passing. + +The **ordinals** are the payoff example. By Cantor normal form an ordinal below +ω^n is an n-tuple of naturals under reverse-lexicographic order, so +`autstr.ordinals` is three formulas over Büchi arithmetic and no automaton +authored by hand. How far this reaches is exactly +{ref}`Delhommé's theorem `: the word-automatic ordinals are those +below ω^ω and the tree-automatic ones those below ω^(ω^ω) — which is why +`Ordinal` and `TreeOrdinal` take an exponent instead of being single +structures. + +## Infinite graphs, and where first-order logic stops + +`autstr.infinite_graphs` presents the Cayley graph of ℤⁿ (an asynchronous +product) and the infinite k-regular tree with its prefix order. +`autstr.turing` presents a Turing machine's **configuration graph**: one step +is a bounded rewrite, so the graph is automatic and its first-order theory is +decidable. What you may not ask is whether one configuration *reaches* another — +that is halting, and no first-order formula over this graph can express it. + +Level 2 **collapsible pushdown systems** are where that changes. Their stacks +are stacks of stacks whose letters carry collapse links, and their configuration +graphs are tree-automatic by {ref}`Kartzow's encoding ` — blocks +hang off each other as a tree and the links are recovered from its shape rather +than stored. MSO over these graphs is undecidable, so this is the only +automatic route to them. + +For these graphs **reachability is a relation like any other**. Every run +decomposes into four stretches — words leaving the stack, letters leaving, +letters returning, words returning — each of them reflexive, so + +``` +Reach(x, y) = ∃d ∃f ∃g. A(x, d) ∧ B(d, f) ∧ C(f, g) ∧ D(g, y) +``` + +is a first-order formula over four automata rather than a fixpoint over trees. +It is exponential in the number of control states, so it is *declared* up front +and built the first time a query mentions it. The +{doc}`infinite structures notebook ` builds one +and decides that its reachability is transitive — a statement about all runs of +all lengths. + ## Bounded rank-width: groups and graphs from one linear algebra The width notions above bound how much *combinatorial* structure crosses a cut. @@ -357,12 +424,14 @@ exponents, and classes whose advice is inherently a tree. Evaluating a *fixed* formula on a structure is one linear pass of its advice through the query automaton, so on any class of bounded width every fixed MSO property is -decided in linear time — a constructive, streaming form of Courcelle's theorem. The +decided in linear time — a constructive, streaming form of +{ref}`Courcelle's theorem `. The cost lives entirely in *compiling* the automaton. A transition is not a `symbol -> target` table but a **decision diagram over the symbol's digits**, hash-consed and shared across states and automata -(`autstr.mtbdd`) — the representation MONA uses, for the same reason. A transition +(`autstr.mtbdd`) — the representation {doc}`MONA ` uses, for the +same reason. A transition that ignores a tape never tests that tape's variables, so cylindrification is a variable renaming rather than a duplication of every row once per letter of every new tape, complementation touches no diagram at all, and the alphabet's *width* diff --git a/docs/source/references.md b/docs/source/references.md new file mode 100644 index 0000000..57c1ab0 --- /dev/null +++ b/docs/source/references.md @@ -0,0 +1,157 @@ +# References + +Every construction in AutStr comes from somewhere. This page collects those +sources and says, for each, what in the library it is. The +{doc}`overview ` links here wherever a named theorem or construction +is used. + +## Foundations + +The idea that a logic can be decided by translating formulas into automata long +predates the term *automatic structure*. + +(buechi1960)= +**Büchi, J. R.** *Weak Second-Order Arithmetic and Finite Automata.* +Zeitschrift für math. Logik und Grundlagen der Mathematik 6 (1960), 66–92. +DOI: [10.1002/malq.19600060105](https://doi.org/10.1002/malq.19600060105) +: Monadic second-order logic over (ℕ, +1) is decidable, by translation into + finite automata. **Every `evaluate` call in this library is this + construction**, and `autstr.powerset.MSO0` is the structure that makes the + correspondence literal: first-order logic over it *is* MSO over (ℕ, <). + +(rabin1969)= +**Rabin, M. O.** *Decidability of Second-Order Theories and Automata on +Infinite Trees.* Transactions of the AMS 141 (1969), 1–35. +DOI: [10.2307/1995086](https://doi.org/10.2307/1995086) +: The same programme over trees. `autstr.sparse_tree_automata` and every + tree-automatic presentation here are the finite-tree fragment of this. + +(courcelle1990)= +**Courcelle, B.** *The Monadic Second-Order Logic of Graphs I: Recognizable +Sets of Finite Graphs.* Information and Computation 85(1), 1990, 12–75. +DOI: [10.1016/0890-5401(90)90043-H](https://doi.org/10.1016/0890-5401%2890%2990043-H) +: MSO properties of graphs of bounded tree-width are decidable in linear time. + `autstr.tree_graphs.TreeWidthClass` builds the automaton the theorem + promises — and, because the class is compiled once and each member is one + linear pass, in a constructive and streaming form. + +(makowsky2004)= +**Makowsky, J. A.** *Algorithmic Uses of the Feferman–Vaught Theorem.* +Annals of Pure and Applied Logic 126 (2004), 159–213. +DOI: [10.1016/j.apal.2003.11.002](https://doi.org/10.1016/j.apal.2003.11.002) +: The composition method behind meta-theorems of this shape, and behind + `autstr.composition`. + +## Automatic structures + +(khoussainov1994)= +**Khoussainov, B., & Nerode, A.** *Automatic presentations of structures.* +LCC 1994, LNCS vol. 960, Springer. +DOI: [10.1007/3-540-60178-3_93](https://doi.org/10.1007/3-540-60178-3_93) +: The definition the whole library implements: a structure presented by + automata for its domain and relations. + +(blumensath2000)= +**Blumensath, A., & Grädel, E.** *Automatic Structures.* LICS 2000, pp. 51–62. +[Proceedings](https://lics.siglog.org/2000/Grdel-AutomaticStructures.html) +: Closure under first-order definability, and hence the decidability that + `check` relies on. `autstr.interpretations` computes the closure under + interpretations that this theory guarantees. + +(khoussainov2007)= +**Khoussainov, B., Rubin, S., & Stephan, F.** *Automatic Structures: Richness +and Limitations.* LMCS 3(2), 2007. +arXiv: [cs/0703064](https://arxiv.org/abs/cs/0703064) · +DOI: [10.2168/LMCS-3(2:2)2007](https://doi.org/10.2168/LMCS-3%282%3A2%292007) +: Where the boundaries are — which structures cannot be automatic, and why + some of the constructions here need the tree engine. + +(delhomme2004)= +**Delhommé, C.** *Automaticité des ordinaux et des graphes homogènes.* +Comptes Rendus Mathématique 339(1), 2004, 5–10. +: The two lines `autstr.ordinals` sits between: the word-automatic ordinals are + exactly those below ω^ω, and the tree-automatic ones exactly those below + ω^(ω^ω). `Ordinal(n)` and `TreeOrdinal(n)` take an exponent rather than being + single structures precisely because neither boundary ordinal is reachable. + +(colcombet2007)= +**Colcombet, T., & Löding, C.** *Transforming structures by set +interpretations.* LMCS 3(2), 2007. +: Existence of injective presentations for tree-automatic quotients — the + theorem that makes `interpret(..., quotient=ε)` well posed over trees. + +(kuske2011)= +**Kuske, D., & Weidner, T.** *Size and computation of injective tree automatic +presentations.* MFCS 2011, LNCS vol. 6907. +: The construction `autstr.interpretations` uses for tree quotients. No + automatic order on trees is well-founded, so a class need have no least + member; the representative is instead the least *description*. §4 proves the + exponential blowup unavoidable, which is why `max_states` is worth passing. + +## Uniformly automatic classes + +(abuzaid2016)= +**Abu Zaid, F.** *Algorithmic Solutions via Model Theoretic Interpretations.* +Dissertation, RWTH Aachen University, 2016. +DOI: [10.18154/RWTH-2017-07663](https://doi.org/10.18154/RWTH-2017-07663) + +(abuzaid2017)= +**Abu Zaid, F., Grädel, E., & Reinhardt, F.** *Advice Automatic Structures and +Uniformly Automatic Classes.* CSL 2017, LIPIcs vol. 82, pp. 35:1–35:20. +DOI: [10.4230/LIPIcs.CSL.2017.35](https://doi.org/10.4230/LIPIcs.CSL.2017.35) +: Introduces automatic presentations with advice — the foundation of + `autstr.uniform` and every class in the library. The ℤ[1/p] presentation in + `autstr.algebra` follows its blueprint for (ℚ, +). + +(abuzaid2018)= +**Abu Zaid, F.** *Uniformly Automatic Classes of Finite Structures.* +FSTTCS 2018, LIPIcs vol. 122, pp. 10:1–10:21. +DOI: [10.4230/LIPIcs.FSTTCS.2018.10](https://doi.org/10.4230/LIPIcs.FSTTCS.2018.10) +: The meta-theorems for finite Boolean algebras, finite groups, and graphs of + bounded tree-depth implemented by `autstr.uniform`, `autstr.graphs`, + `autstr.algebra` and `autstr.groups`. + +## Width parameters + +(courcelle2000)= +**Courcelle, B., & Olariu, S.** *Upper Bounds to the Clique Width of Graphs.* +Discrete Applied Mathematics 101 (2000), 77–114. +DOI: [10.1016/S0166-218X(99)00184-5](https://doi.org/10.1016/S0166-218X%2899%2900184-5) +: The k-expressions that `autstr.tree_graphs.CliqueWidthClass` reads as advice. + +(oum2006)= +**Oum, S., & Seymour, P.** *Approximating clique-width and branch-width.* +Journal of Combinatorial Theory Series B 96(4), 2006, 514–528. +: Rank-width and rank decompositions — the advice of + `autstr.tree_graphs.RankWidthClass`, and the width measure the class-2 group + families in `autstr.groups`, `autstr.tree_groups` and + `autstr.cocycle_groups` are graded by. + +## Higher-order pushdown graphs + +(kartzow2013)= +**Kartzow, A.** *Collapsible Pushdown Graphs of Level 2 are Tree-Automatic.* +Logical Methods in Computer Science 9(1), 2013. +arXiv: [1303.2453](https://arxiv.org/abs/1303.2453) +: The encoding `autstr.collapsible` implements — blocks as a tree, collapse + links recovered from its shape rather than stored — and the reachability + decomposition `autstr.collapsible_reach` builds: §4 splits every run into + four stretches, §5 turns that into a relation of the graph. Since MSO over + these graphs is undecidable, this is the only automatic route to them. + +## Related tools + +**[MONA](https://www.brics.dk/mona/)** (Klarlund, Møller, Henriksen et al.) +: Decides WS1S and WS2S by translating formulas to automata whose transitions + are shared multi-terminal BDDs over the symbol's bits. + {py:mod}`autstr.mtbdd` adopts exactly that representation, for exactly MONA's + reason: over a convolution alphabet, the flat `symbol -> target` table is the + bottleneck. + +**[Walnut](https://cs.uwaterloo.ca/~shallit/walnut.html)** (Mousavi, Shallit) +: Proves theorems about automatic sequences by deciding first-order statements + over (ℕ, +) with automata — the same decision procedure, aimed at + combinatorics on words rather than at presenting structures. + +Both are mature and fast, and neither targets *uniformly* automatic classes or +arbitrary automatic presentations, which is where AutStr sits. From 58bf5e47bec8848886656bb9ec3949f64f1cec63 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Thu, 13 Aug 2026 22:24:50 +0200 Subject: [PATCH 74/76] Look up the references I could not verify offline All four now carry a DOI, and one annotation was wrong. Delhomme 2004 -> 10.1016/j.crma.2004.03.035 (CR Math. 339(1), 5-10) Oum & Seymour -> 10.1016/j.jctb.2005.10.006 (JCTB 96(4), 514-528) Colcombet & -> 10.2168/LMCS-3(2:4)2007 (LMCS 3(2:4), 1-36) Loeding Kuske & -> 10.1007/978-3-642-22993-0_39 (LNCS 6907, 424-435) Weidner Kartzow -> 10.2168/LMCS-9(1:12)2013 (issue was 9(1:12), not 9(1)) Oum & Seymour is the one that needed care. A publisher's abstract page claims rank-width is not defined there, which would have made it the wrong citation for `RankWidthClass`; Oum's own later survey settles it -- "Rank-width was introduced by Oum and Seymour [71]", where [71] is exactly this paper. The entry now says so, as the branch-width of the cut-rank function. Colcombet and Loeding's role is stated more carefully too: the existence of injective presentations for tree-automatic structures, which is what makes a quotient interpretation well posed over trees, with Kuske and Weidner making it effective. Co-Authored-By: Claude Opus 5 --- docs/source/references.md | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/source/references.md b/docs/source/references.md index 57c1ab0..ad3e50c 100644 --- a/docs/source/references.md +++ b/docs/source/references.md @@ -69,6 +69,7 @@ DOI: [10.2168/LMCS-3(2:2)2007](https://doi.org/10.2168/LMCS-3%282%3A2%292007) (delhomme2004)= **Delhommé, C.** *Automaticité des ordinaux et des graphes homogènes.* Comptes Rendus Mathématique 339(1), 2004, 5–10. +DOI: [10.1016/j.crma.2004.03.035](https://doi.org/10.1016/j.crma.2004.03.035) : The two lines `autstr.ordinals` sits between: the word-automatic ordinals are exactly those below ω^ω, and the tree-automatic ones exactly those below ω^(ω^ω). `Ordinal(n)` and `TreeOrdinal(n)` take an exponent rather than being @@ -76,13 +77,17 @@ Comptes Rendus Mathématique 339(1), 2004, 5–10. (colcombet2007)= **Colcombet, T., & Löding, C.** *Transforming structures by set -interpretations.* LMCS 3(2), 2007. -: Existence of injective presentations for tree-automatic quotients — the - theorem that makes `interpret(..., quotient=ε)` well posed over trees. +interpretations.* Logical Methods in Computer Science 3(2:4), 2007, 1–36. +DOI: [10.2168/LMCS-3(2:4)2007](https://doi.org/10.2168/LMCS-3%282%3A4%292007) +: Set interpretations, and their application to tree-automatic structures. + Every tree-automatic structure admits an *injective* presentation, which is + what makes `interpret(..., quotient=ε)` well posed over trees; Kuske and + Weidner below make that effective. (kuske2011)= -**Kuske, D., & Weidner, T.** *Size and computation of injective tree automatic -presentations.* MFCS 2011, LNCS vol. 6907. +**Kuske, D., & Weidner, T.** *Size and Computation of Injective Tree Automatic +Presentations.* MFCS 2011, LNCS vol. 6907, pp. 424–435. +DOI: [10.1007/978-3-642-22993-0_39](https://doi.org/10.1007/978-3-642-22993-0_39) : The construction `autstr.interpretations` uses for tree quotients. No automatic order on trees is well-founded, so a class need have no least member; the representative is instead the least *description*. §4 proves the @@ -122,7 +127,9 @@ DOI: [10.1016/S0166-218X(99)00184-5](https://doi.org/10.1016/S0166-218X%2899%290 (oum2006)= **Oum, S., & Seymour, P.** *Approximating clique-width and branch-width.* Journal of Combinatorial Theory Series B 96(4), 2006, 514–528. -: Rank-width and rank decompositions — the advice of +DOI: [10.1016/j.jctb.2005.10.006](https://doi.org/10.1016/j.jctb.2005.10.006) +: The paper that introduces **rank-width**, as the branch-width of the cut-rank + function. Rank decompositions are the advice of `autstr.tree_graphs.RankWidthClass`, and the width measure the class-2 group families in `autstr.groups`, `autstr.tree_groups` and `autstr.cocycle_groups` are graded by. @@ -131,8 +138,9 @@ Journal of Combinatorial Theory Series B 96(4), 2006, 514–528. (kartzow2013)= **Kartzow, A.** *Collapsible Pushdown Graphs of Level 2 are Tree-Automatic.* -Logical Methods in Computer Science 9(1), 2013. -arXiv: [1303.2453](https://arxiv.org/abs/1303.2453) +Logical Methods in Computer Science 9(1:12), 2013. +arXiv: [1303.2453](https://arxiv.org/abs/1303.2453) · +DOI: [10.2168/LMCS-9(1:12)2013](https://doi.org/10.2168/LMCS-9%281%3A12%292013) : The encoding `autstr.collapsible` implements — blocks as a tree, collapse links recovered from its shape rather than stored — and the reachability decomposition `autstr.collapsible_reach` builds: §4 splits every run into From 2c3c69341706fd7d83dd437d2ab3e3a87307a6c0 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Thu, 13 Aug 2026 23:07:22 +0200 Subject: [PATCH 75/76] Review: a dead disjunct in Min, and two import blocks I mangled `Min` carried a disjunct for "the empty set case" that no set satisfies: `forall z. not Subset(z, x)` is refuted by z = x, since Subset is reflexive. Confirmed twice over -- `exists x.(all z.(not Subset(z,x)))` is False over the structure, and removing the disjunct leaves the same language and the same five states. What it hid is worth writing down. Min(empty, y) is empty, which IS the right semantics -- the empty set has no least member -- but by accident rather than by the guard that claimed to arrange it. The comment now says so, and a test pins it, since the behaviour was neither documented nor asserted anywhere. `Max` never had the disjunct, so the two now agree. The other two: the buildin rename inserted an import above an existing one in two test modules, leaving a duplicate import with an assignment wedged between. One import block, assignment after it. (test_arithmetic.py had the same shape already correct.) Found by Copilot's review of #46. Co-Authored-By: Claude Opus 5 --- autstr/powerset.py | 8 ++++++-- tests/test_arithmetic.py | 15 +++++++++++++++ tests/test_infinite_graphs.py | 5 ++--- tests/test_interpretations.py | 5 ++--- 4 files changed, 25 insertions(+), 8 deletions(-) diff --git a/autstr/powerset.py b/autstr/powerset.py index 2f35394..618e4a4 100644 --- a/autstr/powerset.py +++ b/autstr/powerset.py @@ -235,8 +235,12 @@ def _build_finite_powerset() -> AutomaticPresentation: Eq_set="Subset(x, y) and Subset(y, x)", Leq_sing="Lt_sing(x, y) or (Eq_set(x, y) and Sing(x) and Sing(y))", Gt_sing="not Leq_sing(x, y) and Sing(x) and Sing(y)", - Min="(forall z. not Subset(z, x)) or " # Empty set case - "(Sing(y) and Subset(y, x) and forall z. (-(Sing(z) and Subset(z, x)) or Leq_sing(y, z)))", + # Min and Max name the least and greatest MEMBER of x, so both are + # empty on the empty set -- it has no members to be least. (An earlier + # disjunct here claimed to cover that case with `forall z. not + # Subset(z, x)`, which no set satisfies: Subset is reflexive, so z = x + # always refutes it.) + Min="Sing(y) and Subset(y, x) and forall z. (-(Sing(z) and Subset(z, x)) or Leq_sing(y, z))", Max="Sing(y) and Subset(y, x) and forall z. (-(Sing(z) and Subset(z, x)) or Leq_sing(z, y))", Intersect="forall a. (-Sing(a) or ((Subset(a, z) and (Subset(a, x) and Subset(a, y))) or (-Subset(a, z) and -(Subset(a, x) and Subset(a, y)))))", Union="forall a. (-Sing(a) or ((Subset(a, z) and (Subset(a, x) or Subset(a, y))) or (-Subset(a, z) and -(Subset(a, x) or Subset(a, y)))))", diff --git a/tests/test_arithmetic.py b/tests/test_arithmetic.py index 3dbc98d..a97a803 100644 --- a/tests/test_arithmetic.py +++ b/tests/test_arithmetic.py @@ -238,3 +238,18 @@ def test_enumeration_yields_sets(sets): x, y = sets.vars('x y') solutions = list(islice(iter((x + y).eq({0, 1}) & x.subset(y)), 3)) assert all(isinstance(a, set) and a | b == {0, 1} for a, b in solutions) + + +def test_min_and_max_are_empty_on_the_empty_set(sets): + """The empty set has no least or greatest member, so both relations are + empty there. This is pinned because the formula used to carry a disjunct + claiming to handle the case, which no set could satisfy: `Subset` is + reflexive, so `forall z. not Subset(z, x)` fails at z = x.""" + x, y = sets.vars('x y') + Min, Max = sets.rel('Min'), sets.rel('Max') + + assert (x.eq(set()) & Min(x, y)).is_empty() + assert (x.eq(set()) & Max(x, y)).is_empty() + # and they still name the least and greatest member of a nonempty set + assert ({1, 3}, {1}) in Min(x, y) + assert ({1, 3}, {3}) in Max(x, y) diff --git a/tests/test_infinite_graphs.py b/tests/test_infinite_graphs.py index d370990..0f9a88c 100644 --- a/tests/test_infinite_graphs.py +++ b/tests/test_infinite_graphs.py @@ -8,13 +8,12 @@ """ import pytest -from autstr.arithmetic import BuechiArithmeticZ - -encode, decode = BuechiArithmeticZ.encode, BuechiArithmeticZ.decode from autstr.arithmetic import BuechiArithmeticZ from autstr.infinite_graphs import InfiniteGraph from autstr.symbolic import FunctionCodec +encode, decode = BuechiArithmeticZ.encode, BuechiArithmeticZ.decode + @pytest.fixture(scope="module") def complete_graph(): diff --git a/tests/test_interpretations.py b/tests/test_interpretations.py index 4fd7b50..c1a66a9 100644 --- a/tests/test_interpretations.py +++ b/tests/test_interpretations.py @@ -3,14 +3,13 @@ import pytest -from autstr.arithmetic import BuechiArithmeticZ - -encode, decode = BuechiArithmeticZ.encode, BuechiArithmeticZ.decode from autstr.arithmetic import BuechiArithmeticZ from autstr.infinite_graphs import IntegerGrid, InfiniteGraph from autstr.interpretations import interpret from autstr.symbolic import FunctionCodec +encode, decode = BuechiArithmeticZ.encode, BuechiArithmeticZ.decode + # the ±1 edge on ℤ, as a first-order formula over (ℤ, +) _ONE = 'Pt(o) & (all p.(Pt(p) -> (not Lt(p,o))))' _STEP = f'exists o.(({_ONE}) & (A(x,o,y) | A(y,o,x)))' From 637f22f97549fa0315266323eb4b35567a5f06f9 Mon Sep 17 00:00:00 2001 From: Faried Abu Zaid Date: Thu, 13 Aug 2026 23:16:41 +0200 Subject: [PATCH 76/76] Run the suite in parallel The suite is CPU-bound and its tests are independent, and we were running it on one core of sixteen. `pytest-xdist` joins the test extra and CI runs `-n auto`. serial 11:06 -n auto (16) 3:54 -n 4 3:56 The two parallel figures being equal is the interesting part: past four workers there is nothing left to divide, because the wall time is one test. `TestClaimAndVerifyChainRing::test_check_implicit_ring` takes 194s of the 234s total -- nested existentials over Z/4 members through the functional-atom path, which is exactly the case that exists to be expensive. Next after it are 101s (`test_tree_multiplication_and_sentence_z4`) and 44s (a regular-reachability search), so the floor is set by a handful of deliberately heavy tests rather than by breadth. Co-Authored-By: Claude Opus 5 --- .github/workflows/run_tests.yaml | 5 ++++- pyproject.toml | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run_tests.yaml b/.github/workflows/run_tests.yaml index e2eddb7..1e040de 100644 --- a/.github/workflows/run_tests.yaml +++ b/.github/workflows/run_tests.yaml @@ -29,4 +29,7 @@ jobs: pip install -e ".[graphs,test]" - name: Run the test suite - run: pytest -q + # -n auto: one worker per core. The tests are independent and + # CPU-bound, so this divides wall time; the floor is the slowest + # single test (see `--durations`). + run: pytest -q -n auto diff --git a/pyproject.toml b/pyproject.toml index 9be0573..7a4e646 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,9 @@ graphs = ["networkx>=3.0"] # plotting for the runtime-curve benchmark (benchmarks/runtime_curves.py) benchmarks = ["matplotlib>=3.5"] # everything the test suite needs (networkx for the graph classes) -test = ["pytest>=7.4", "networkx>=3.0"] +# xdist: the suite is CPU-bound and its tests are independent, so `-n auto` +# is a straight division of wall time (11 min -> 4 min on 16 cores) +test = ["pytest>=7.4", "pytest-xdist>=3.5", "networkx>=3.0"] # building the documentation: API reference from the docstrings plus the # showcase notebooks, executed during the build (myst-nb needs a kernel) docs = ["sphinx>=7.2", "sphinx-book-theme>=1.1", "sphinx-autodoc-typehints>=1.25", @@ -53,6 +55,7 @@ docs = ["sphinx>=7.2", "sphinx-book-theme>=1.1", "sphinx-autodoc-typehints>=1.25 [dependency-groups] dev = [ "pytest~=7.4.0", + "pytest-xdist~=3.5", "pytest-cov~=4.1.0", "pytest-mock~=3.10.0", "pytest-asyncio>=0.22.0",