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/.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/.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/.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/.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]
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..53d7793
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,279 @@
+# 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.
+
+**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
+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, 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
+ 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 431f9fd..75fd0b0 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,8 +141,10 @@ 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.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 |
**Classes** — one automaton for a whole family, indexed by advice:
@@ -140,10 +154,17 @@ 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. 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
@@ -153,8 +174,8 @@ Three 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.
---
@@ -175,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.
@@ -188,160 +209,79 @@ 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).
+- **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.
+
+📜 **[Full changelog](CHANGELOG.md)** — every release, in detail.
---
## 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/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/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/arithmetic.py b/autstr/arithmetic.py
index 94646d7..6f67499 100644
--- a/autstr/arithmetic.py
+++ b/autstr/arithmetic.py
@@ -1,803 +1,335 @@
-from __future__ import annotations
-
-from abc import ABC, abstractmethod
-from copy import deepcopy, copy
-from typing import List, Union, Tuple, Dict
-import math
-
-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
-
-
-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)
-
- 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})
-
- self.subterm._substitute_inplace(**kw_rec)
- self.presentation = None
-
- return self
-
-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 + '({})'
-
- 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.
- """
-
- 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):
+"""Büchi arithmetic, over the naturals and over the integers.
+
+: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 = BuechiArithmeticZ().symbolic()
+ >>> x, y, z = Z.vars("x y z")
+ >>> ((x + y).eq(z) & z.lt(100)).check()
+ True
+
+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 typing import List
+
+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.
"""
- 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):
+ _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`.
"""
- 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
+ _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 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
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 24326b3..0000000
Binary files a/autstr/buildin/bin/buechi.autstr and /dev/null differ
diff --git a/autstr/buildin/bin/buechiZ.autstr b/autstr/buildin/bin/buechiZ.autstr
deleted file mode 100644
index 0ad4405..0000000
Binary files a/autstr/buildin/bin/buechiZ.autstr and /dev/null differ
diff --git a/autstr/buildin/bin/mso0.autstr b/autstr/buildin/bin/mso0.autstr
deleted file mode 100644
index 4ab5e17..0000000
Binary files a/autstr/buildin/bin/mso0.autstr and /dev/null differ
diff --git a/autstr/buildin/presentations.py b/autstr/buildin/presentations.py
deleted file mode 100644
index 480b9d9..0000000
--- a/autstr/buildin/presentations.py
+++ /dev/null
@@ -1,522 +0,0 @@
-from pathlib import Path
-import itertools as it
-from typing import Dict, List, Set, Tuple
-import numpy as np
-
-from autstr.sparse_automata import SparseDFA
-from autstr.presentations import AutomaticPresentation
-
-
-# Helper function to convert a symbol tuple to an integer encoding
-def encode_symbol(tup: Tuple[str], base_alphabet: Set[str]) -> 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/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/collapsible.py b/autstr/collapsible.py
new file mode 100644
index 0000000..7bed383
--- /dev/null
+++ b/autstr/collapsible.py
@@ -0,0 +1,1055 @@
+"""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), 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:
+
+* 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, 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)]
+ # `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}
+
+ # -- 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,
+ # 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))
+ 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 _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
+ 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 — 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))))')
+
+ 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/autstr/collapsible_reach.py b/autstr/collapsible_reach.py
new file mode 100644
index 0000000..fef65ac
--- /dev/null
+++ b/autstr/collapsible_reach.py
@@ -0,0 +1,1430 @@
+"""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**,
+ 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
+
+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, Sequence, Tuple
+
+from autstr.collapsible import PAD, SEP, Level2CPS
+from autstr.sparse_tree_automata import SparseTreeAutomaton, Tree
+from autstr.utils.tree_automata_tools import (
+ minimize, partial_tree_automaton,
+)
+
+#: 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
+ #: 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]}
+ # 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
+
+ # -- 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 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.
+
+ 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.
+ """
+ # 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)
+ 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"")
+
+
+# ----------------------------------------------------------------------
+# 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, 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.
+ """
+ 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(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 = '@-'
+ #: 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,
+ extra_states: Sequence[str] = ()) -> None:
+ from autstr.collapsible import _Encoding
+ self.system = system
+ 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
+ # 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]
+ # 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."""
+ 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 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.
+
+ 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}'
+
+ # 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
+ # 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'),
+ (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}'
+ 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,
+ 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,
+ [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}'
+
+ # 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] + agreed:
+ for right in [None] + agreed:
+ ends = right or left or f'same {annotation}'
+ table[(left, right, (label, label, annotation, self.NONE))] \
+ = 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'
+ 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
+ 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
+ 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
+ for kept in agreed:
+ table[(kept, 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'
+ # 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(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())
+ 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 -- 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)
+ 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}'
+
+ # 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, '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, 'buried'):
+ 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, '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, 'buried'):
+ 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, 'buried'):
+ table[(sprung, other, symbols)] = sprung
+ table[(other, sprung, symbols)] = sprung
+ else: # the region's root separator
+ for other in (None, 'buried'):
+ 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)
+ 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 — 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'}
+ if not any(level == 2 for _, level in pushes):
+ 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 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]
+ states = self.system.states
+
+ def walk(label, arrive, leave, kind):
+ return f'w|{label}|{arrive}|{leave}|{kind}'
+
+ 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')
+ # 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))
+ for entered in states:
+ if (arrive, entered) not in down:
+ continue
+ for came_back in states:
+ 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:
+ 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)
+ if summary is None:
+ continue
+ 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}'
+ 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))))')
+
+
+# ----------------------------------------------------------------------
+# 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/autstr/graphs.py b/autstr/graphs.py
index bdcbb14..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
@@ -494,6 +501,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/groups.py b/autstr/groups.py
index 477fbe8..74edd49 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,17 +581,26 @@ 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)."""
- def __init__(self):
+ #: addition, not multiplication -- these members are abelian by
+ #: 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, 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:
@@ -749,7 +760,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
@@ -1270,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/autstr/infinite_graphs.py b/autstr/infinite_graphs.py
new file mode 100644
index 0000000..74b8ae1
--- /dev/null
+++ b/autstr/infinite_graphs.py
@@ -0,0 +1,320 @@
+"""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, Sequence, Tuple
+
+from autstr.symbolic import FunctionCodec, graph_signature
+from autstr.utils.automata_tools import partial_dfa
+
+
+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"")
+
+
+# ----------------------------------------------------------------------
+# 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.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)))')
+ 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 BuechiArithmeticZ
+ encode_int = BuechiArithmeticZ.encode
+ 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 BuechiArithmeticZ
+ decode_int = BuechiArithmeticZ.decode
+ 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')
+
+
+# ----------------------------------------------------------------------
+# 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 = set(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 _words(alphabet, letters):
+ """The universe: every word over the child letters, the root included."""
+ 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 partial_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 partial_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 partial_dfa(alphabet, 2, table, initial='s',
+ final={'s', 'below'})
diff --git a/autstr/interpretations.py b/autstr/interpretations.py
new file mode 100644
index 0000000..69e1b0b
--- /dev/null
+++ b/autstr/interpretations.py
@@ -0,0 +1,316 @@
+"""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.
+
+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
+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.
+
+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
+
+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.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'
+_REP = '_Rep'
+
+@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
+ #: given the interpreted presentation carrying the equivalence as
+ #: `_EQUIV`, the automaton of one representative per class
+ representatives: 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),
+ representatives=_tree_representatives)
+
+ 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),
+ representatives=_string_representatives)
+
+
+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
+#: coordinates together), and may be omitted when the sorted order already is
+RelationSpec = Union[Formula, Tuple[Formula, Sequence[str]]]
+
+
+def interpret(source, domain: RelationSpec,
+ relations: Dict[str, RelationSpec],
+ dimension: int = 1,
+ quotient: Optional[RelationSpec] = None):
+ """The first-order interpretation of a structure in `source`.
+
+ :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
+ 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.
+ :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 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.
+ """
+ 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,
+ engine)
+ k = dimension
+
+ 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 = {'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, engine)
+
+ padding = source.padding_symbol if k == 1 \
+ else (source.padding_symbol,) * k
+ return engine.presentation(automata, padding)
+
+
+def _quotient(source, domain: RelationSpec,
+ relations: Dict[str, RelationSpec], dimension: int,
+ 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 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`.
+ """
+ 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(**{_REP: engine.representatives(raw)})
+
+ 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, (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` 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}
+
+ 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
+ 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):
+ """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
+ order = free if order is None else list(order)
+ if sorted(order) != sorted(free):
+ raise ValueError(
+ 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))):
+ dfa = engine.permute_tapes(dfa, permutation)
+ return engine.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/ordinals.py b/autstr/ordinals.py
new file mode 100644
index 0000000..283f3b4
--- /dev/null
+++ b/autstr/ordinals.py
@@ -0,0 +1,460 @@
+"""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>> 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 both structures have a definable least element but no greatest.
+
+`TreeOrdinal` reaches past the word barrier by nesting the same idea: an
+ordinal below :math:`\\omega^{\\omega^n}` is a finite sequence of blocks
+indexed by the leading exponent coefficient, each block an ordinal below
+:math:`\\omega^{\\omega^{n-1}}`, bottoming out at a natural number. That nesting
+is a tree, and it is authored rather than interpreted — see `TreeOrdinal`.
+"""
+from __future__ import annotations
+
+from typing import Dict, List, Mapping, Optional, Sequence, Tuple, Union
+
+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
+from autstr.tree_presentations import TreeAutomaticPresentation
+from autstr.utils.misc import encode_symbol
+
+#: 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""
+
+
+#: 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/autstr/powerset.py b/autstr/powerset.py
new file mode 100644
index 0000000..618e4a4
--- /dev/null
+++ b/autstr/powerset.py
@@ -0,0 +1,323 @@
+"""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 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)))))",
+ 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 731c837..c0fe5e5 100644
--- a/autstr/presentations.py
+++ b/autstr/presentations.py
@@ -3,13 +3,13 @@
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
+import re
import struct
import zlib
-from typing import Dict
class AutomaticPresentationSerializer:
MAGIC = b'APRS'
@@ -105,14 +105,83 @@ 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
)
-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 +212,34 @@ 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.
+ 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
+ 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`.
"""
- return list(self.automata.keys())
+ 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:
@@ -160,10 +250,15 @@ def update(self, **kwargs) -> 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()
@@ -180,6 +275,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.
@@ -190,21 +309,38 @@ 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], 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)
+ self._materialize_for(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))
@@ -213,13 +349,18 @@ def evaluate(self, phi: Union[str, logic.Expression], updates: Optional[Dict[str
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
@@ -255,7 +396,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')
@@ -274,7 +420,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
@@ -305,8 +452,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:
@@ -321,8 +468,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:
@@ -355,4 +502,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/sparse_automata.py b/autstr/sparse_automata.py
index 3cf8a94..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,
+)
@@ -132,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 = 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 +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 = 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/sparse_tree_automata.py b/autstr/sparse_tree_automata.py
index 74cfe6d..929d9d8 100644
--- a/autstr/sparse_tree_automata.py
+++ b/autstr/sparse_tree_automata.py
@@ -20,12 +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
# ====================================================================
@@ -531,7 +535,207 @@ 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, "
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/symbolic/__init__.py b/autstr/symbolic/__init__.py
new file mode 100644
index 0000000..7e696ad
--- /dev/null
+++ b/autstr/symbolic/__init__.py
@@ -0,0 +1,47 @@
+"""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, EQUALITY_SYMBOL, Function, FunctionCodec, Signature,
+ graph_signature, operation_signature, order_signature,
+ relational_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',
+ 'FunctionCodec', 'FunctionSymbol', 'Relation',
+ 'RelationSymbol', 'Signature', 'StructureBackend', 'SymbolicContext',
+ 'SymbolicSymbolError', 'Term', 'Var', 'graph_signature',
+ 'operation_signature', 'order_signature', 'relational_signature',
+]
diff --git a/autstr/symbolic/backends.py b/autstr/symbolic/backends.py
new file mode 100644
index 0000000..58dab12
--- /dev/null
+++ b/autstr/symbolic/backends.py
@@ -0,0 +1,324 @@
+"""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.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,
+ 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:
+ """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):
+ # `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):
+ 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())}"
+
+
+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`.
+
+ Elements are trees rather than words, so a signature's codec encodes
+ 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
+ restated in terms of node count and path depth.
+ """
+
+ 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.relation(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):
+ # 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):
+ return tree_automaton(tree, self.presentation.base_alphabet)
+
+ def longer_witness_automaton(self, k, references):
+ # "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]
+ # `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):
+ 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
+ # 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 "
+ f"{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.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
+
+ 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..ae3ed38
--- /dev/null
+++ b/autstr/symbolic/expr.py
@@ -0,0 +1,565 @@
+"""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 collections.abc import Mapping
+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):
+ if '*' in self.ctx.signature.operators:
+ return self._binop('*', other, swap=True)
+ 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
+
+
+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."""
+
+ __slots__ = ('value',)
+
+ 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 []
+
+ 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 _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, self._quantified(variables, 'drop'), self)
+
+ ex = drop
+
+ def all(self, variables) -> 'Formula':
+ """Universally quantify ``variables``."""
+ return Forall(self.ctx, self._quantified(variables, 'quantify'), 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, 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):
+ 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..45b48eb
--- /dev/null
+++ b/autstr/symbolic/signature.py
@@ -0,0 +1,212 @@
+"""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
+
+
+#: 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 `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``.
+ :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)
+ if equality in relations:
+ signature.operator('eq', equality)
+ 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
+ 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.
+ """
+ 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:
+ """Translation between Python values and element encodings.
+
+ 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) -> Any:
+ raise NotImplementedError
+
+ def decode(self, encoded: Any) -> Any:
+ raise NotImplementedError
+
+
+@dataclass(frozen=True)
+class FunctionCodec(ElementCodec):
+ """A codec built from two plain functions."""
+ encoder: Callable[[Any], Any]
+ decoder: Optional[Callable[[Any], Any]] = None
+
+ def encode(self, 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:
+ 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/tree_algebra.py b/autstr/tree_algebra.py
new file mode 100644
index 0000000..cea2ed4
--- /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
+ Büchi 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/autstr/buildin/tree_presentations.py b/autstr/tree_arithmetic.py
similarity index 83%
rename from autstr/buildin/tree_presentations.py
rename to autstr/tree_arithmetic.py
index d97ee82..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
@@ -52,11 +61,22 @@ 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):
+ """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/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 73d5045..9d862b4 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)."""
@@ -64,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)
@@ -199,7 +202,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 09add51..e89fe3a 100644
--- a/autstr/tree_presentations.py
+++ b/autstr/tree_presentations.py
@@ -11,11 +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.
"""
-from typing import Dict, Optional
+import json
+import struct
+import zlib
+from typing import Dict, Optional, Union
from nltk.sem import logic
-from autstr.sparse_tree_automata import SparseTreeAutomaton
+from autstr.presentations import DeferredRelations
+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,
@@ -34,7 +40,88 @@ def tree_zero(symbol_arity: int = 1, base_alphabet=None) -> SparseTreeAutomaton:
symbol_arity, base_alphabet or {0})
-class TreeAutomaticPresentation:
+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.
:param automata: 'U' is the domain automaton (symbol arity 1); every other
@@ -81,8 +168,48 @@ 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 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})
+
+ 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` 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.
+ :return: a `autstr.symbolic.SymbolicContext`.
+ """
+ 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
@@ -103,14 +230,72 @@ 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) -> 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)
+ self._materialize_for(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 _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):
@@ -155,10 +340,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/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/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/autstr/uniform.py b/autstr/uniform.py
index 332a1e1..c34fbbc 100644
--- a/autstr/uniform.py
+++ b/autstr/uniform.py
@@ -17,10 +17,11 @@
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
+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
@@ -91,7 +92,58 @@ def dfa_from_delta(sigma, states, arity, delta, initial, finals,
).minimize()
-class UniformlyAutomaticClass:
+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 = '*'
+
+ #: 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 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
+ `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(DeferredRelations):
"""A uniformly automatic presentation of a class of structures.
:param automata: dictionary of SparseDFAs. 'U' is reserved for the domain
@@ -126,9 +178,37 @@ 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
+ 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
+ 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:
@@ -198,6 +278,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']
diff --git a/autstr/utils/automata_tools.py b/autstr/utils/automata_tools.py
index 9e033a8..f80ecf7 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
@@ -109,6 +108,122 @@ 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.
+
+ 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.
+
+ `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:
@@ -423,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."""
@@ -628,4 +747,266 @@ 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()
+
+
+# --------------------------------------------------------------------------
+# 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/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/autstr/utils/tree_automata_tools.py b/autstr/utils/tree_automata_tools.py
index 38c3e5e..3971758 100644
--- a/autstr/utils/tree_automata_tools.py
+++ b/autstr/utils/tree_automata_tools.py
@@ -48,8 +48,8 @@
import numpy as np
-from autstr.mtbdd import NONE, ComputedTable, bits_of, var_tables
-from autstr.sparse_tree_automata import SparseTreeAutomaton
+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
@@ -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
# ====================================================================
@@ -285,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
# ====================================================================
@@ -342,6 +488,309 @@ 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 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 `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
+ 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 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:
+ """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/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
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 522f437..38fbc99 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
@@ -29,6 +30,13 @@ reference is generated from the docstrings.
api/modules
+.. toctree::
+ :maxdepth: 1
+ :caption: Project
+
+ changelog
+ references
+
Indices and tables
==================
diff --git a/docs/source/overview.md b/docs/source/overview.md
index 62736ea..43574b7 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
@@ -25,13 +25,77 @@ 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
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 BuechiArithmeticZ
+
+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
+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. 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.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)
+```
+
+`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:
+
+```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
@@ -55,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.
@@ -89,10 +154,11 @@ 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.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 `
@@ -158,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.
@@ -252,7 +382,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
@@ -294,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..ad3e50c
--- /dev/null
+++ b/docs/source/references.md
@@ -0,0 +1,165 @@
+# 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.
+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
+ single structures precisely because neither boundary ordinal is reachable.
+
+(colcombet2007)=
+**Colcombet, T., & Löding, C.** *Transforming structures by set
+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, 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
+ 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.
+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.
+
+## Higher-order pushdown graphs
+
+(kartzow2013)=
+**Kartzow, A.** *Collapsible Pushdown Graphs of Level 2 are Tree-Automatic.*
+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
+ 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.
diff --git a/notebooks/arithmetic_and_algebra.ipynb b/notebooks/arithmetic_and_algebra.ipynb
index fa22d23..a824141 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,11 @@
"metadata": {},
"outputs": [],
"source": [
- "from autstr.arithmetic import VariableETerm as Var\n",
+ "from autstr.arithmetic import BuechiArithmeticZ\n",
"from time import time\n",
"\n",
- "x = Var('x')"
+ "Z = BuechiArithmeticZ().symbolic() # the structure declares its own vocabulary\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"
]
},
{
@@ -148,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"
@@ -224,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",
@@ -284,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",
@@ -351,7 +472,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."
]
},
{
@@ -361,7 +486,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",
@@ -383,6 +508,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",
@@ -400,9 +567,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/notebooks/composition.ipynb b/notebooks/composition.ipynb
index 122edbf..6fcf36e 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,213 @@
"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",
+ "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."
+ ]
}
],
"metadata": {
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",
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."
]
},
{
diff --git a/notebooks/infinite_structures.ipynb b/notebooks/infinite_structures.ipynb
new file mode 100644
index 0000000..7ab7e9d
--- /dev/null
+++ b/notebooks/infinite_structures.ipynb
@@ -0,0 +1,307 @@
+{
+ "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."
+ ]
+ },
+ {
+ "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": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "name": "python"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/pyproject.toml b/pyproject.toml
index 17b795c..7a4e646 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 = [
@@ -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",
@@ -73,8 +76,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_readme_media.py b/scripts/gen_readme_media.py
index d1964f5..337bd18 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 BuechiArithmeticZ
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 = BuechiArithmeticZ().symbolic()
+ 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():
diff --git a/tests/test_arithmetic.py b/tests/test_arithmetic.py
index 71faef1..a97a803 100644
--- a/tests/test_arithmetic.py
+++ b/tests/test_arithmetic.py
@@ -1,149 +1,255 @@
-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 BuechiArithmetic, BuechiArithmeticZ
+from autstr.powerset import MSO0
- assert not (expr.isfinite())
- assert not (expr.isempty())
+encode, decode = BuechiArithmeticZ.encode, BuechiArithmeticZ.decode
- for a, b in expr:
- if a > 10:
- break
- b0 = -a
- assert b0 == b
+@pytest.fixture(scope='module')
+def Z():
+ return BuechiArithmeticZ().symbolic()
- def test_add(self):
- x = Var('x')
- y = Var('y')
- z = Var('z')
- expr = (x + y).eq(z)
+@pytest.fixture(scope='module')
+def N():
+ return BuechiArithmetic().symbolic()
- assert not (expr.isfinite())
- assert not (expr.isempty())
- for a, b, c in expr:
- if c > 10:
- break
+@pytest.fixture(scope='module')
+def sets():
+ return MSO0().symbolic()
- cp = a + b
- assert (cp == c)
- def test_mul(self):
- x = Var('x')
- y = Var('y')
+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 = (2 * x).eq(y)
- assert not (expr.isfinite())
- assert not (expr.isempty())
+# ----------------------------------------------------------------------
+# encoding
+# ----------------------------------------------------------------------
- for a, b in expr:
- if a > 5:
- 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
- bp = 2 * a
- assert (bp == b)
- def test_sub(self):
- x = Var('x')
- y = Var('y')
- z = Var('z')
+def test_decode_ignores_padding():
+ assert decode(encode(13) + ['*', '*']) == 13
- expr = (x - y).eq(z)
- assert not (expr.isfinite())
- assert not (expr.isempty())
+# ----------------------------------------------------------------------
+# terms
+# ----------------------------------------------------------------------
- for a, b, c in expr:
- if a > 10:
- 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
- c0 = a - b
- assert (c0 == c)
- def test_lt(self):
- x = Var('x')
+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.lt(10)
- 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, in expr:
- condition = a < 10
- assert (condition)
- if abs(a) > 5:
- break
- def test_composite_relations(self):
- x = Var('x')
- y = Var('y')
- z = Var('z')
+def test_integer_multiple(Z):
+ x, y = Z.vars('x y')
+ for a, b in bounded((2 * x).eq(y)):
+ assert b == 2 * a
- constrains = (x + y).eq(z)
- assert not (constrains.isfinite())
- assert not (constrains.isempty())
+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)
- for a, b, c in constrains:
- condition = a + b == c
- assert (condition)
- if max([a, b, c]) > 3:
- 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
- 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)
+# ----------------------------------------------------------------------
+# relations
+# ----------------------------------------------------------------------
- if abs(max([a, b])) > 3:
- break
+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)
- constrains = constrains.drop(['z'])
- for a, b in constrains:
- condition = 0 < a + b < 3
- assert (condition)
+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
- if abs(max([a, b])) > 3:
- break
- constrains = ~constrains
+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)
- for a, b in constrains:
- condition = 0 >= a + b or a + b >= 3
- assert (condition)
- if abs(max([a, b])) > 3:
- break
+# ----------------------------------------------------------------------
+# relational algebra
+# ----------------------------------------------------------------------
- def test_exinf(self):
- x = Var('x')
- y = Var('y')
+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
- expr = x.eq(y).exinf('y')
- assert (expr.isempty())
+ projected = constraints.drop(['z'])
+ assert projected.variables() == ['x', 'y']
+ for a, b in bounded(projected):
+ assert 0 < a + b < 3
- expr = x.lt(y).exinf('y')
- assert not (expr.isempty())
- assert not (expr.isfinite())
+ for a, b in bounded(~projected):
+ assert not 0 < a + b < 3
- def test_contains(self):
- x = Var('x')
- y = Var('y')
- z = Var('z')
- expr = (x + y).eq(z)
- assert ((1, 1, 2) in expr)
- assert not ((10, 2, 13) in expr)
+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(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()
+
+
+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()
+
+
+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)]
+
+
+# ----------------------------------------------------------------------
+# 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)
+
+
+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_automata_tools.py b/tests/test_automata_tools.py
index 5319d5e..6b3b8e8 100644
--- a/tests/test_automata_tools.py
+++ b/tests/test_automata_tools.py
@@ -8,10 +8,12 @@
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, partial_dfa, permute_tapes, projection,
+ shortlex_order, unpad,
)
@@ -155,3 +157,112 @@ 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)
+
+
+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]
+
+
+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'})
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]'
diff --git a/tests/test_collapsible_reach.py b/tests/test_collapsible_reach.py
new file mode 100644
index 0000000..3c82ad2
--- /dev/null
+++ b/tests/test_collapsible_reach.py
@@ -0,0 +1,1063 @@
+"""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.
+"""
+import random
+from collections import deque
+
+import pytest
+
+from autstr.collapsible import (
+ Configuration, Letter, Level2CPS, Operation, PAD, Stack, _Encoding,
+ encode_configuration, initial_stack,
+)
+from autstr.collapsible_reach import (
+ Annotation, LabelAutomaton, Relations, Summaries, _Encoding_of,
+ regular_reach,
+)
+from autstr.sparse_tree_automata import Tree, convolve_trees
+
+
+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,))
+
+ 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):
+ 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 *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: len(c.stack.words[-1]) >= len(word),
+ 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()
+
+
+# ----------------------------------------------------------------------
+# 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))
+
+
+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)
+
+
+# ----------------------------------------------------------------------
+# 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.
+
+ 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.stack == target.stack:
+ return source == target
+ 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],))))
+
+
+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))
+
+
+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.
+
+ 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.stack == target.stack:
+ return source == target
+ 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 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. 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 = {
+ '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
+ 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())
+
+ 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_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)
+ 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_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]
+ for source in configurations:
+ for target in configurations:
+ 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'])
+ for stack in walk_stacks(3, 6):
+ for state in system.states:
+ 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, 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, 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')]
+ 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)
+
+ 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))))
+ # 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))))')
+
+
+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)))')
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_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)
diff --git a/tests/test_infinite_graphs.py b/tests/test_infinite_graphs.py
new file mode 100644
index 0000000..0f9a88c
--- /dev/null
+++ b/tests/test_infinite_graphs.py
@@ -0,0 +1,82 @@
+"""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 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():
+ """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()
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,))
diff --git a/tests/test_interpretations.py b/tests/test_interpretations.py
new file mode 100644
index 0000000..c1a66a9
--- /dev/null
+++ b/tests/test_interpretations.py
@@ -0,0 +1,238 @@
+"""First-order interpretations (Phase 1: one-dimensional, no quotient)."""
+import itertools
+
+import pytest
+
+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)))'
+_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)
+
+
+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 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_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"):
+ 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
+ with pytest.raises(ValueError, match="defines 2 element"):
+ 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)'})
+
+ 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)
diff --git a/tests/test_ordinals.py b/tests/test_ordinals.py
new file mode 100644
index 0000000..24400ce
--- /dev/null
+++ b/tests/test_ordinals.py
@@ -0,0 +1,348 @@
+"""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.arithmetic 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']
diff --git a/tests/test_presentations.py b/tests/test_presentations.py
new file mode 100644
index 0000000..18c28a1
--- /dev/null
+++ b/tests/test_presentations.py
@@ -0,0 +1,170 @@
+"""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.
+"""
+import itertools
+
+import pytest
+
+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
+
+
+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))')
+
+
+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())
+
+
+#: 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: 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.
+ """
+
+ @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()
+ 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_sentences_nest(self):
+ arithmetic = BuechiArithmetic()
+ 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, on either side of the connective."""
+ arithmetic = BuechiArithmetic()
+ 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(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()
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))
diff --git a/tests/test_serialization.py b/tests/test_serialization.py
index bc863cf..584eb0b 100644
--- a/tests/test_serialization.py
+++ b/tests/test_serialization.py
@@ -1,6 +1,11 @@
+import json
+import struct
+import zlib
+
import numpy as np
+import pytest
-from autstr.buildin.presentations import BuechiArithmeticZ
+from autstr.arithmetic import BuechiArithmeticZ
from autstr.sparse_automata import SparseDFA, SparseDFASerializer
@@ -52,6 +57,161 @@ 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)
+
+
+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))))')
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
new file mode 100644
index 0000000..54ff991
--- /dev/null
+++ b/tests/test_symbolic.py
@@ -0,0 +1,551 @@
+"""Tests for the generic symbolic interface."""
+from itertools import islice
+
+import pytest
+
+from autstr.arithmetic import BuechiArithmeticZ
+from autstr.symbolic import (
+ CompileError, FunctionCodec, Signature, SymbolicSymbolError,
+ graph_signature, order_signature, relational_signature,
+)
+
+
+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():
+ # 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
+# ----------------------------------------------------------------------
+
+@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')
+ 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()
+
+
+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))
+
+
+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'])
+
+
+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
+
+
+# ----------------------------------------------------------------------
+# 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
+
+
+# ----------------------------------------------------------------------
+# 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})
diff --git a/tests/test_symbolic_tree.py b/tests/test_symbolic_tree.py
new file mode 100644
index 0000000..82f0300
--- /dev/null
+++ b/tests/test_symbolic_tree.py
@@ -0,0 +1,208 @@
+"""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.
+"""
+from itertools import islice
+
+import pytest
+
+from autstr.tree_arithmetic import SkolemArithmetic
+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")
+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()
+ # '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
+ 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 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 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_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')
+ 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:
+ """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')
+ (x * y).eq(S.const(12)).evaluate()
+ assert dict(skolem.automata) == before
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']
diff --git a/tests/test_tree_automata.py b/tests/test_tree_automata.py
index 7eaa50a..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,
)
@@ -305,3 +306,128 @@ 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()
+
+
+# ====================================================================
+# 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_interpretations.py b/tests/test_tree_interpretations.py
new file mode 100644
index 0000000..f47d4fa
--- /dev/null
+++ b/tests/test_tree_interpretations.py
@@ -0,0 +1,309 @@
+"""First-order interpretations over the tree engine.
+
+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. 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.
+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.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
+from autstr.utils.tree_automata_tools import (
+ attach_padding, equivalent, minimize, partial_tree_automaton,
+ tree_automaton, tree_order,
+)
+
+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
+
+
+#: 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.
+ """
+
+ @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:
+ 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'])})
diff --git a/tests/test_tree_presentations.py b/tests/test_tree_presentations.py
index 25f565c..e3965b3 100644
--- a/tests/test_tree_presentations.py
+++ b/tests/test_tree_presentations.py
@@ -3,11 +3,13 @@
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 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,56 @@ 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_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
+ 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)))
diff --git a/tests/test_tree_tools.py b/tests/test_tree_tools.py
index 7dd5772..2f3b16f 100644
--- a/tests/test_tree_tools.py
+++ b/tests/test_tree_tools.py
@@ -3,10 +3,14 @@
import numpy as np
import pytest
-from autstr.sparse_tree_automata import SparseTreeAutomaton, Tree, tree_to_arrays
+from autstr.utils.tree_automata_tools import k_deeper_automaton
+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, minimize, project,
+ attach_padding, domain_within, equivalent, expand, fold_tapes, minimize,
+ partial_tree_automaton, project, restrict_alphabet, tree_order,
)
from test_tree_automata import RefDTA, random_sta, random_tree
@@ -354,3 +358,289 @@ 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)
+
+
+# ====================================================================
+# 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)
+
+
+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)
+
+
+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)
+
+
+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)
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'))
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))))')