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))))')