diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index bf4b68a7a..a17a8eb7a 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -14,12 +14,16 @@ concurrency: jobs: build: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + env: + CARGO_BUILD_JOBS: '2' + CMAKE_BUILD_PARALLEL_LEVEL: '2' + RUST_TEST_THREADS: '2' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Read Open Question Board source - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: repository: GiggleLiu/autoresearch-gadgets ref: main @@ -32,11 +36,17 @@ jobs: - name: Build Open Question Board run: uv run --project open-questions-source/website --locked python open-questions-source/website/build.py - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + toolchain: '1.99.0' + targets: wasm32-unknown-unknown + + - name: Install browser binding tools + run: cargo install wasm-bindgen-cli --version 0.2.129 --locked - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: '22' + node-version: '24.21.0' cache: npm - name: Install mdBook @@ -54,50 +64,24 @@ jobs: tar -xJf typst.tar.xz mv typst-x86_64-unknown-linux-musl/typst "$HOME/bin/" - - name: Install Node dependencies - run: npm ci - - - name: Generate data + - name: Build and verify deployment artifact run: | - cargo run --features "example-db" --example export_examples - cargo run --example export_petersen_mapping - cargo run --example export_graph - node scripts/generate_reduction_graph_layout.js - cargo run --example export_schemas - cargo build -p problemreductions-cli --bin pred - bash scripts/generate_doc_snippets.sh target/debug/pred - - - name: Build mdBook - run: mdbook build - - - name: Build PDF - run: typst compile --root . docs/paper/reductions.typ book/reductions.pdf - - - name: Build rustdoc - run: RUSTDOCFLAGS="--default-theme=dark" cargo doc --no-deps - - - name: Combine documentation - run: | - mkdir -p book/api - cp -r target/doc/* book/api/ - - - name: Build research website - run: python3 scripts/build_website.py --open-questions open-questions-source/website/dist - - - name: Verify deployment artifact - run: | - python3 -m pip install playwright==1.58.0 - python3 -m playwright install --with-deps chromium + uv run --python 3.12.14 --no-project --with-requirements scripts/website-requirements.txt python -m playwright install --with-deps chromium firefox webkit + uv run --python 3.12.14 --no-project --with-requirements scripts/website-requirements.txt python scripts/check_website.py --output book --open-questions open-questions-source/website/dist npm audit --audit-level=moderate - npm run test:reduction-graph-js - python3 -m unittest discover -s scripts -p 'test_website_build.py' - python3 -m http.server 3001 --bind 127.0.0.1 --directory book & - server_pid=$! - trap 'kill "$server_pid"' EXIT - python3 scripts/test_website.py + git diff --exit-code -- Cargo.lock package-lock.json + + - name: Retain verification diagnostics + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: website-verification + path: | + book/verification.json + book/verification.log - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 with: path: './book' @@ -109,11 +93,11 @@ jobs: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 needs: build steps: - name: Setup Pages - uses: actions/configure-pages@v4 + uses: actions/configure-pages@1f0c5cde4bc74cd7e1254d0cb4de8d49e9068c7d # v4 - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 diff --git a/.gitignore b/.gitignore index fc59be7ea..756c20c27 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ # Rust /target/ -Cargo.lock **/*.rs.bk *.pdb @@ -74,6 +73,8 @@ tarpaulin-report.html # Generated documents *.pdf +docs/website/baselines/ +/docs/website/*preview.png # Reference packages (downloaded for comparison) pkgref/ @@ -90,6 +91,12 @@ claude-output.log *.json !package.json !package-lock.json +!tsconfig.json +!docs/website/graph-scene.schema.json +!docs/website/graph-details.schema.json +!docs/website/path-graph.schema.json +!docs/website/atlas-data.schema.json +!docs/website/native-exports.schema.json .claude/worktrees/ docs/test-reports/ docs/superpowers/ @@ -99,3 +106,5 @@ docs/src/generated/ .claude/projects/ docs/research/ docs/solutions/ + +/docs/website/src/generated/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 000000000..b700b8b5f --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1870 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a649af8a827553c29fb0cb4bd4a6f1a0dd695bd3232b9bc98bd9c8a3ffbb8b" +dependencies = [ + "find-msvc-tools", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_complete" +version = "4.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "criterion" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" +dependencies = [ + "alloca", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools", + "num-traits", + "oorandom", + "page_size", + "plotters", + "rayon", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.7", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "highs" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dfc791427e78fdc1a0a37983f79c20f86a0b7b7dfdb5d6a57a6f72d77cd9074" +dependencies = [ + "highs-sys", + "log", +] + +[[package]] +name = "highs-sys" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4da0c52322648e2360a5a8896d23301fa5ed99ed7607c5faf229bd0bbb30d3f" +dependencies = [ + "bindgen", + "cmake", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inventory" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6928282826c822ad91bf1c9a1cb90a30ba1c26770749929b4656cd6be829cd7c" +dependencies = [ + "rustversion", +] + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys", +] + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "owo-colors" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c45bb4a6ae1280ec0803b1ef9d3455eb50f01efbbe1447ab020f1d54fba9d8" +dependencies = [ + "supports-color 2.1.0", + "supports-color 3.0.2", +] + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", + "serde", + "serde_derive", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "problemreductions" +version = "0.6.0" +dependencies = [ + "criterion", + "highs", + "inventory", + "num-bigint", + "num-rational", + "num-traits", + "petgraph", + "problemreductions-expr", + "problemreductions-macros", + "proptest", + "rand 0.10.3", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "problemreductions-cli" +version = "0.6.0" +dependencies = [ + "anyhow", + "clap", + "clap_complete", + "num-bigint", + "owo-colors", + "problemreductions", + "rmcp", + "schemars", + "serde", + "serde_json", + "tokio", + "tracing-subscriber", +] + +[[package]] +name = "problemreductions-expr" +version = "0.6.0" +dependencies = [ + "num-bigint", + "num-rational", + "num-traits", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "problemreductions-macros" +version = "0.6.0" +dependencies = [ + "num-traits", + "problemreductions-expr", + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "problemreductions-web" +version = "0.6.0" +dependencies = [ + "num-bigint", + "num-rational", + "num-traits", + "problemreductions-expr", + "proptest", + "schemars", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.5", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rmcp" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab5023804cbe0304cbe06197ac16e645a0a0d0ffa2953bf735c15c2fa0213350" +dependencies = [ + "base64", + "chrono", + "futures", + "indexmap", + "pastey", + "pin-project-lite", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1da498700030856ba63114bceefa30ba21f5e278cb975e5b9de3529fe842b40" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn 3.0.7", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.7", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "supports-color" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6398cde53adc3c4557306a96ce67b302968513830a77a95b2b17305d9719a89" +dependencies = [ + "is-terminal", + "is_ci", +] + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d62a2e0561533f2ca2561d0cf27fd9fedb640a1bf2616ff5d5c80d99017faadc" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "bytes", + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.7", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 3117e15e7..d06e87f66 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,12 +4,14 @@ members = [ "problemreductions-expr", "problemreductions-macros", "problemreductions-cli", + "problemreductions-web", ] [package] name = "problemreductions" version = "0.6.0" -edition = "2021" +edition = "2024" +rust-version = "1.88" description = "A library for reducing NP-hard problems" license = "MIT" repository = "https://github.com/CodingThrust/problem-reductions" @@ -22,7 +24,6 @@ benchmarks = ["dep:criterion"] [dependencies] petgraph = { version = "0.8.3", features = ["serde-1"] } -bitvec = "1.1.1" serde = { version = "1.0.229", features = ["derive"] } # Persisted problem and reduction data must preserve every finite f64 on replay. serde_json = { version = "1.0.151", features = ["float_roundtrip"] } @@ -61,3 +62,9 @@ debug = "full" lto = true codegen-units = 1 strip = true + +# Size-optimized build for the browser (scripts/build_website.py). +[profile.wasm-release] +inherits = "release" +opt-level = "z" +panic = "abort" diff --git a/Makefile b/Makefile index 6b0a5db5c..d55937539 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Makefile for problemreductions -.PHONY: help build test bench mcp-test fmt clippy doc mdbook website paper paper-data clean coverage rust-export compare qubo-testdata export-schemas release diagrams jl-testdata cli cli-demo copilot-review papers papers-lookup papers-download papers-scihub papers-status papers-push papers-pull papers-index +.PHONY: help build test bench mcp-test fmt clippy doc mdbook website website-data paper paper-data clean coverage rust-export compare qubo-testdata export-schemas release diagrams jl-testdata cli cli-demo copilot-review papers papers-lookup papers-download papers-scihub papers-status papers-push papers-pull papers-index TEST_FEATURES := example-db @@ -77,13 +77,16 @@ paper-data: cargo run --features "$(TEST_FEATURES)" --example export_examples cargo run --features "$(TEST_FEATURES)" --example export_petersen_mapping -# Build mdBook documentation -doc: node_modules/elkjs/package.json paper-data +# Fresh shared inputs for mdBook, the website, and isolated verification builds. +website-data: node_modules/elkjs/package.json paper-data cargo run --example export_graph node scripts/generate_reduction_graph_layout.js cargo run --example export_schemas cargo build -p problemreductions-cli --bin pred bash scripts/generate_doc_snippets.sh target/debug/pred + +# Build mdBook documentation +doc: website-data mdbook build RUSTDOCFLAGS="--default-theme=dark" cargo doc --no-deps rm -rf book/api @@ -91,18 +94,12 @@ doc: node_modules/elkjs/package.json paper-data python3 scripts/build_website.py # Build the product website with fresh atlas data; API/PDF builds remain in doc/paper. -website: node_modules/elkjs/package.json paper-data - cargo run --example export_graph - node scripts/generate_reduction_graph_layout.js - cargo run --example export_schemas - cargo build -p problemreductions-cli --bin pred - bash scripts/generate_doc_snippets.sh target/debug/pred +website: website-data mdbook build python3 scripts/build_website.py # Generate SVG diagrams from Typst sources (light + dark themes) TYPST_DOC_DIAGRAMS := $(wildcard docs/src/static/*.typ) -TYPST_PAPER_DIAGRAMS := $(wildcard docs/paper/static/*.typ) diagrams: @for src in $(TYPST_DOC_DIAGRAMS); do \ base=$$(basename $$src .typ); \ @@ -229,7 +226,6 @@ compare: rust-export done # Closed-loop CLI demo: exercises all commands end-to-end -PRED := cargo run -p problemreductions-cli --release -- CLI_DEMO_DIR := /tmp/pred-cli-demo cli-demo: cli @echo "=== pred CLI closed-loop demo ===" diff --git a/benches/solver_benchmarks.rs b/benches/solver_benchmarks.rs index 72dffb2e4..1841eca21 100644 --- a/benches/solver_benchmarks.rs +++ b/benches/solver_benchmarks.rs @@ -1,6 +1,6 @@ //! Benchmarks for the BruteForce solver on various problem types. -use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion}; +use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; use problemreductions::models::formula::*; use problemreductions::models::graph::*; use problemreductions::models::misc::*; diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index bd827a828..9797e57af 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -269,6 +269,7 @@ "SpinGlass": [Spin Glass], "QUBO": [QUBO], "ILP": [Integer Linear Programming], + "DecisionILP": [Binary Decision Integer Linear Programming], "IntegerKnapsack": [Integer Knapsack], "Knapsack": [Knapsack], "PartiallyOrderedKnapsack": [Partially Ordered Knapsack], @@ -5100,6 +5101,30 @@ In all graph problems below, $G = (V, E)$ denotes an undirected graph with $|V| ] } +#problem-def("DecisionILP")[ + Given a binary integer linear program with integer coefficients, a minimization + or maximization sense, and an integer bound $b$, find $x in {0,1}^n$ satisfying + all constraints and $c^top x <= b$ for minimization, or $c^top x >= b$ for maximization. +][ + This is the objective-threshold form of binary ILP. Binary adders translate its + pseudo-Boolean constraints to SAT @een2006. Exhaustive enumeration takes + $O^*(2^n)$ time; this is the registered reference bound, not a claim of a tight + best-known SAT solving bound. The registered SAT reduction answers a fixed + objective-threshold query; no reverse ILP-to-DecisionILP edge is registered. + + *Example.* #let x = load-model-example("DecisionILP") + Select exactly one of #x.instance.inner.variables.len() variables and minimize + the objective with coefficients + #x.instance.inner.objective.map(t => str(t.at(1))).join(", "). + At bound #x.instance.bound, the witness + $(#fmt-values(x.optimal_config))$ has objective #x.instance.bound and meets the bound. + #pred-commands( + "pred create --example " + problem-spec(x) + " -o x.json", + "pred solve x.json", + "pred evaluate x.json --config " + cli-config(x.optimal_config), + ) +] + #{ let x = load-model-example("QuadraticAssignment") let C = x.instance.cost_matrix @@ -5369,6 +5394,8 @@ In all graph problems below, $G = (V, E)$ denotes an undirected graph with $|V| ][ Simultaneous Incongruences is an NP-complete problem @garey1979. It asks whether the complement of a system of congruences — a _covering system_ — can be simultaneously avoided. A _covering system_ is a finite collection of congruences $\{a_i space (op("mod") space b_i)\}$ that covers every integer; when the system is a covering system there is no valid $x$ and the instance is a "no" instance. The problem generalises checking whether a given set of congruences is a covering system, which has connections to Erdős's covering conjecture and sieve methods in analytic number theory. + Exhaustive search tests the $L = op("lcm")(b_1, dots, b_n)$ candidates in one full period, checking at most $n$ incongruences per candidate. With `lcm_bits` $= floor(log_2 L) + 1$, the catalog bound is $O((n+1) 2^"lcm_bits")$, counting fixed-width arithmetic operations. The period can be exponential in the binary input length, so this is not a polynomial-time algorithm.#footnote[The catalog records exhaustive search; a better general exact bound has not been independently verified from the literature.] + *Example.* Let $n = #pairs.len()$ with pairs #pairs.map(p => $(#p.at(0), #p.at(1))$).join(", "). The full period is $L = op("lcm")(#fmt-values(moduli)) = #lcm-val$. We test $x = #xval$: #pred-commands( @@ -12033,6 +12060,333 @@ The _penalty method_ @glover2019 @lucas2014 converts a constrained optimization $ f(bold(x)) = "obj"(bold(x)) + P sum_k g_k (bold(x))^2 $ where $P$ is a penalty weight large enough that any constraint violation costs more than the entire objective range. Since $g_k (bold(x))^2 >= 0$ with equality iff $g_k (bold(x)) = 0$, minimizers of $f$ are feasible and optimal for the original problem. Because binary variables satisfy $x_i^2 = x_i$, the resulting $f$ is a quadratic in $bold(x)$, i.e.\ a QUBO. +== Direct Ordering, Scheduling, and Assignment QUBOs + +The following constructions apply the integer penalty method @glover2019 @lucas2014 directly, without an intermediate ILP. Write the expanded source objective as $f(x) = c + sum_(a <= b) q_(a b) x_a x_b$, after combining coefficients. Set $L = c + sum_(a <= b) min(0, q_(a b))$, $U = c + sum_(a <= b) max(0, q_(a b))$, and $P = U - L + 1$. For the nonnegative integer violation sum $g$ defined in each rule, expand $F = f + P g$ and store its nonconstant terms as QUBO energy $E = F - C$. + +A source witness has $g = 0$, so its energy lies in $[L-C,U-C]$ and adding $C$ recovers its objective. Conversely, any invalid assignment has $g >= 1$, hence $F >= L+P > U$. Thus every target minimum in the interval decodes to a source optimum; a minimum outside it maps to `Min(None)`. This also handles infeasible precedence systems. For feasibility rules use $f = 0$, $P = 1$, and DecisionQUBO bound $-C$; completed `Or` values map identically. Every decoder first validates the target witness and its energy certificate. Coefficients, constants, and energy bounds use checked integer arithmetic; unrepresentable encodings return an overflow error. + +#let direct-qubo-commands(source, target, source-variant: none) = { + let data = load-example(source, target, source-variant: source-variant) + let solution = data.solutions.at(0) + pred-commands( + "pred create --example " + rule-spec(data) + " -o source.json", + "pred path " + problem-spec(data.source) + " " + problem-spec(data.target) + " --limit all --quiet --json | jq ' .paths[] | select(.steps == 1)' > route.json", + "pred reduce source.json --via route.json -o bundle.json", + "pred solve bundle.json", + "pred evaluate source.json --config " + cli-config(solution.source_config), + ) +} + +#reduction-rule("SequencingToMinimizeWeightedCompletionTime", "QUBO", example: true, + extra: [#direct-qubo-commands("SequencingToMinimizeWeightedCompletionTime", "QUBO")], +)[ + Weighted completion sequencing has a direct QUBO on $n(n-1)/2$ comparison bits, including signed processing times and weights. +][ + _Construction._ For $ib$, and one when $a=b$. + + _Correctness._ A legal order gives no directed triangles and satisfies every precedence. Each task contributes its own processing time times its weight, and each earlier task contributes its processing time times the later task's weight, yielding $f$. Conversely, a tournament with no directed triangle is transitive: a shortest longer directed cycle has a chord producing a shorter directed cycle. Its unique order satisfies all zero-penalty precedences. The shared penalty bound separates all invalid bitstrings, including cyclic precedence instances. + + _Source arithmetic._ Before expansion, bound every elapsed-time prefix by the sums of negative and positive processing times. For each task, include its own duration in the completion-time bounds, multiply both endpoints by its weight, then bound all partial objective sums by the sums of negative minima and positive maxima. Reject bounds outside `i64`; algebraic cancellation must not hide source evaluation overflow. + + _Extraction and size._ Count incoming arcs to obtain each task's rank and invert these ranks to an ordered task list. There are exactly $n(n-1)/2$ bits and at most $3 binom(n,3)$ couplings: each triple supplies three pairs of comparison bits, while precedences are linear. The canonical example orders the highest-weight unit jobs first; its source and target witnesses are shown below. +] + +#reduction-rule("MaximumLikelihoodRanking", "QUBO", example: true, + extra: [#direct-qubo-commands("MaximumLikelihoodRanking", "QUBO")], +)[ + Maximum likelihood ranking reduces directly to a comparison-bit QUBO with $n(n-1)/2$ variables. +][ + _Construction._ Use the preceding tournament bits and triangle penalties, with no precedence penalties. For comparison matrix $M$, set + $ f = sum_(i=q$, reducing squared bits to linear terms even for self precedences. The objective is $f = sum_(i,p: p+1>d_i) x_(i p)$, where $d_i$ is the task deadline. + _Correctness._ A valid schedule activates a permutation matrix, violates no precedence, and contributes one precisely for each late job. Conversely, zero row and column penalties yield a permutation; zero precedence products force the required strict ordering. Its objective is the number of late jobs. The penalty bound detects cycles and self precedences as infeasible sources. + _Extraction and size._ Read each task's selected position and invert to the ordered task list. Exactly $n^2$ bits are used, with at most $n^2(n^2-1)/2$ off-diagonal terms. The canonical schedule meets all deadlines. + + _Positive integer lengths._ The `i64` constructor requires strictly positive lengths, but accepts signed deadlines. Set $H=sum_i p_i$ and use choices $t=0,dots,H-p_i$ per task. Add one-hot row squares and conflicts between overlapping selected intervals, plus every precedence pair whose starts satisfy $t>=u$ (a self precedence penalizes every selected row bit). The objective charges one exactly when $t+p_i>d_i$. With $U$ the sum of all choice costs, use $P=U+1$. + + _Correctness for integer lengths._ ($arrow.r.double$) Prefix starts of any legal permutation fit the horizon, have zero penalties, and give its exact late-task count. ($arrow.l.double$) A zero-penalty selection consists of nonoverlapping intervals of total duration $H$ inside $[0,H]$, so they fill the horizon without gaps. Sorting starts reconstructs the exact source completion times and respects all precedences. Invalid selections cost more than $U$; an infeasible source therefore recovers `Min(None)`. Source elapsed-time prefixes fit the checked positive total $H$. + + _Integer extraction and size._ Validate the energy interval, decode starts and sort to an order. Exactly $S=sum_i(H-p_i+1)$ bits and at most $S(S-1)/2$ couplings are used. For processing magnitude bits $h$, $S<=n^2 2^h$. The registry reports unavailable size transforms because its expression contract permits only constant integer exponents. This is pseudopolynomial in processing times; it can exceed binary-time alternatives. A simple construction enumerates $O((1+q)S^2)$ conflict candidates for $q$ precedences, plus sparse accumulation costs. + + *Integer example.* #let weighted = load-example("MinimumTardinessSequencing", "QUBO", source-variant: (weight: "i64")) + Lengths #repr(weighted.source.instance.lengths), deadlines #repr(weighted.source.instance.deadlines), and order #repr(weighted.solutions.at(0).source_config) give completions 1, 2, 4 and one late task. The target has #weighted.target.instance.num_vars variables. + +] + +#reduction-rule("QuadraticAssignment", "QUBO", example: true, + extra: [#direct-qubo-commands("QuadraticAssignment", "QUBO")], +)[ + Quadratic assignment with $n<=m$ facilities and locations reduces directly to $n m$ bits. +][ + _Construction._ Let $x_(i p)$ assign facility $i$ to location $p$. Penalize each row by $(1-sum_p x_(i p))^2$ and each column by $sum_(i0$. Empty groups are allowed by the source definition. + _Correctness._ Each partition activates exactly one bit per item and its objective is the sum of squared group totals. Conversely, zero row penalties assign each item once; substitution gives those same group totals and objective. The shared penalty lemma excludes dropped or multiply assigned items. + _Extraction and size._ Return each item's selected group. There are exactly $n k$ bits and at most $n k(k-1)/2+k n(n-1)/2$ couplings: same-item pairs from the constraints and same-group pairs from the objective. These two pair families are disjoint. The example splits the total size equally between its two groups. +] + +#reduction-rule("OptimalLinearArrangement", "QUBO", example: true, + extra: [#direct-qubo-commands("OptimalLinearArrangement", "QUBO")], +)[ + Optimal linear arrangement on `SimpleGraph` reduces to $n(n-1)/2$ tournament comparison bits. +][ + _Construction._ Let $r_(i j)$ mean that vertex $i$ precedes vertex $j$, with $r_(j i)=1-r_(i j)$. For $m$ source edges set + $ F=m+sum_((u,v) in E) sum_(w != u,v) (r_(u w)+r_(v w)-2r_(u w)r_(v w)). $ + For $n>=2$ let $U=m(n-1)$; empty and singleton graphs have $U=0$. Add the tournament triangle penalties with weight $P=U+1$, remove the resulting constant $C$, and recover completed minima by adding $C$ inside $[0,U]$. + _Correctness._ A source ordering gives a transitive tournament. For each edge, the XOR of its endpoints' comparisons with $w$ equals one precisely when $w$ lies strictly between those endpoints. Thus its contribution is one plus the number of intermediate vertices, equal to the rank distance. Conversely, every XOR and triangle penalty is nonnegative on arbitrary bits. A positive triangle penalty costs more than $U$, so a target inside the recovery interval has no directed triangle and hence is a total order. Predecessor counts give its ranks, and the same between-vertex identity proves equality of the objective. Every graph has a feasible ordering, including the empty graph. + _Extraction and size._ Validate the target, reject cycles, and return predecessor counts. Exactly $n(n-1)/2$ bits and at most $3 binom(n,3)=n(n-1)(n-2)/2$ couplings suffice: objective terms and triangle penalties couple only comparisons sharing a vertex, and every such pair belongs to one index triple. The coupling bound is cubic even for dense graphs. Construction uses $O(n^3+m n)$ coefficient additions; ordered maps add a logarithmic factor. For the canonical three-vertex path, $U=4$, $P=5$, $C=2$, and the all-true comparison vector has target value zero, recovering source cost two. +] + +#reduction-rule("SequencingWithinIntervals", "DecisionQUBO", example: true, + extra: [#direct-qubo-commands("SequencingWithinIntervals", "DecisionQUBO")], +)[ + Interval-constrained sequencing has a direct decision QUBO using one bit for each allowed integer start slot. +][ + _Construction._ Task $i$ has release $r_i$, deadline $d_i$, and duration $l_i$. Allocate $s_i=max(0,d_i-r_i-l_i+1)$ bits for starts $r_i+t$, $0<=t0$ and both resources are available in period $h$. Add $(sum_h x_(c t h)-R_(c t))^2$ for every demand and one product for each pair sharing a period and either resource. Remove $C=sum_(c,t)R_(c t)^2$ and use decision bound $-C$. If a demand is negative or exceeds its eligible-period count, use the zero polynomial on the same variable set with bound $-1$ instead. + _Correctness._ A feasible timetable meets every demand and has no resource conflict, so all penalties vanish. Conversely, zero demand squares enforce exact assignment counts and zero conflict products enforce exclusivity. Unavailable or zero-demand cells are false by construction. The special target is unsatisfiable precisely in the immediately impossible-demand branch: a sum of eligible boolean cells cannot equal such a demand. + _Extraction and size._ Fill a false craftsman-task-period tensor and insert the eligible selected bits. The target has exactly $A$ variables and at most $A(A-1)/2$ couplings, where $A$ is the eligible-assignment count, including the impossible-demand branch. +] + + +== Further direct integer QUBO constructions + +The following rules use the checked penalty and aggregate-recovery conventions above. Their constructions adapt the quadratic penalty method @glover2019; each proof states the exact source semantics. + +#reduction-rule("SchedulingWithIndividualDeadlines", "DecisionQUBO", example: true, + extra: [#direct-qubo-commands("SchedulingWithIndividualDeadlines", "DecisionQUBO")], +)[ + Unit-task scheduling with individual deadlines reduces to a decision QUBO with deadline-valid starts and exact processor capacities. +][ + _Construction._ For $n$ tasks and processor capacity $m$, allocate row bits $x_(i t)$ for $0 <= t < h_i = min(d_i,n)$. Add $(sum_t x_(i t)-1)^2$ for each task. At time $t$ let $k_t$ be the number of available task bits. If $m < k_t$, introduce $b= floor(log_2(m))+1$ slack bits with weights $1,2,dots,2^(b-1)$ (no slack bits for $m=0$), and add $(sum_i x_(i t)+s_t-m)^2$. Omit automatically satisfied capacities. For each precedence $(i,j)$ add $x_(i t)x_(j u)$ for every $t>=u$, including repeated indices for self precedences. Use unit penalties and bound $-C$. + +_Correctness._ $arrow.r.double$: compress the occupied slots of a source schedule to consecutive integers starting at zero. There are at most $n$ occupied slots; this preserves strict precedences and capacity and only decreases completion times. Choose these starts and the representable slack $m-sum_i x_(i t)$, obtaining zero penalty. $arrow.l.double$: zero penalty selects exactly one deadline-valid start per task, respects every capacity, and has no forbidden precedence pair. Empty rows and cyclic or self precedences remain infeasible. + +_Solution extraction and bounds._ Decode the unique start in each row. Completed `Or` values map identically. The actual variable count is $sum_i h_i + sum_(t: m v$ add $w_(u v)(1-r_(u v))$ using the tournament convention. Parallel arcs remain separate source indices; their coefficients add. Apply the shared objective interval and triangle penalty $P=U-L+1$. Before cancellation, check the sums of all negative and all positive source weights, bounding every selected-prefix sum. + +_Correctness._ $arrow.r.double$: augment any feasible removal set with every negative arc, without increasing cost. Topologically order its retained graph. All backward arcs were removed; discarding any additionally removed forward nonnegative arcs cannot increase the objective. This produces a canonical order with no greater cost. $arrow.l.double$: removing negative arcs, loops and all backward arcs leaves only forward arcs and hence a DAG, whose removal cost equals the quadratic objective. These inequalities prove equal optima; the shared penalty separates every cyclic target tournament. This argument covers signed, zero, loop and parallel-arc semantics. + +_Solution extraction and bounds._ Remove each indexed arc exactly when its weight is negative, it is a loop, or its endpoints are backward in the decoded order. Add the omitted constant for aggregate recovery inside the certified interval. With $v$ vertices, exactly $v(v-1)/2$ bits and at most $3 binom(v,3)$ couplings suffice; construction takes $O(m+v^3)$ additions. Empty graphs have objective zero. Every source has the all-arcs-removed feasible witness. + + *Example.* #let ex = load-example("MinimumFeedbackArcSet", "QUBO") + The canonical instance and target coefficients are shown below. Its source witness is #repr(ex.solutions.at(0).source_config); the displayed target witness decodes to it and meets the objective or feasibility certificate described above. +] + +#reduction-rule("Partition", "DecisionQUBO", example: true, + extra: [#direct-qubo-commands("Partition", "DecisionQUBO")], +)[ + Partition reduces to one selection bit per element by squaring the difference between subset totals @lucas2014. +][ + _Construction._ For positive sizes $s_i$ with validated total $S$, set $H=(2 sum_i s_i x_i-S)^2$ and remove $C=S^2$. Use bound $-C$. + +_Correctness._ $arrow.r.double$: equal subset totals make the residual zero. $arrow.l.double$: every square is nonnegative, so satisfying the energy threshold forces the selected sum to equal its complement. Odd totals therefore remain NO instances without a special branch. + +_Solution extraction and bounds._ Copy the selection bits; aggregate `Or` recovery is identity. Exactly $n$ variables and at most $n^2$ couplings are registered; expansion takes $O(n^2)$ arithmetic operations. Every source subtotal lies between zero and the checked total. Target coefficients, constant and energy sums are independently checked: a representable source need not yield a representable squared penalty. + + *Example.* #let ex = load-example("Partition", "DecisionQUBO") + The canonical instance and target coefficients are shown below. Its source witness is #repr(ex.solutions.at(0).source_config); the displayed target witness decodes to it and meets the objective or feasibility certificate described above. +] + +#reduction-rule("SubsetSum", "DecisionQUBO", example: true, + extra: [#direct-qubo-commands("SubsetSum", "DecisionQUBO")], +)[ + Arbitrary-precision SubsetSum reduces to a checked integer decision QUBO with one bit per element. +][ + _Construction._ Expand $H=(sum_i s_i x_i-T)^2$ using arbitrary-precision signed integers. Only then convert the coefficients and $C=T^2$ into the target integer representation. Check positive and negative coefficient sums and use bound $-C$. + +_Correctness._ $arrow.r.double$: a subset of sum $T$ gives zero residual. $arrow.l.double$: satisfying the threshold forces the integer square to vanish and hence exactly the source equality. Empty selections and target zero follow the same equations. Arbitrary-precision input arithmetic is preserved; no input-size restriction is silently assumed. + +_Solution extraction and bounds._ Copy the bits and use identity `Or` recovery. Exactly $n$ variables and at most $n^2$ couplings suffice. Expansion takes $O(n^2)$ big-integer operations, whose bit cost depends on input magnitudes. Unrepresentable derived constants or coefficients return a typed numeric error. Expanding before conversion matters: a size $2^32$ and target $2^31$ produce a zero diagonal coefficient despite the intermediate size square exceeding `i64`. + + *Example.* #let ex = load-example("SubsetSum", "DecisionQUBO") + The canonical instance and target coefficients are shown below. Its source witness is #repr(ex.solutions.at(0).source_config); the displayed target witness decodes to it and meets the objective or feasibility certificate described above. +] + +#reduction-rule("ExactCoverBy3Sets", "DecisionQUBO", example: true, + extra: [#direct-qubo-commands("ExactCoverBy3Sets", "DecisionQUBO")], +)[ + Exact cover by 3-sets reduces to a decision QUBO with one bit per indexed subset @lucas2014. +][ + _Construction._ For universe $U$ and subset-selection bits $x_j$, set $H=sum_(u in U)(sum_(j: u in S_j)x_j-1)^2$. The omitted constant is $C=|U|$; use bound $-C$. Duplicate subsets retain distinct bits. + +_Correctness._ $arrow.r.double$: an exact cover selects each element exactly once and makes every equation vanish. $arrow.l.double$: zero penalty means every element is covered once, so selected triples are disjoint and cover the whole universe. Summing incidences gives exactly $|U|/3$ selected triples, matching the source's cardinality check. Uncovered elements contribute unavoidable constants; the empty universe gives the empty cover. + +_Solution extraction and bounds._ Copy the subset bits; aggregate recovery is identity. Exactly $m$ bits and at most $m^2$ couplings suffice. The implementation scans incidences in $O(|U|m)$ time and expands only intersecting rows, with at most three contributions per pair of triples. Diagonals are $-3$ and pair coefficients twice the intersection cardinality. + + *Example.* #let ex = load-example("ExactCoverBy3Sets", "DecisionQUBO") + The canonical instance and target coefficients are shown below. Its source witness is #repr(ex.solutions.at(0).source_config); the displayed target witness decodes to it and meets the objective or feasibility certificate described above. +] + +#reduction-rule("StaffScheduling", "DecisionQUBO", example: true, + extra: [#direct-qubo-commands("StaffScheduling", "DecisionQUBO")], +)[ + Integer worker multiplicities reduce to bounded binary budget and coverage residuals, using the squared-equality construction of @lucas2014. +][ + _Construction._ Let $s$ be the number of indexed schedule patterns, $p$ the number of periods, $W >= 0$ the worker budget, $a_(j t)$ the pattern incidence, and $r_t$ the signed requirement. Encode each count $c_j$ and unused budget $z$ in $[0,W]$ with bounded binary weights: successive powers of two and a final remainder, whose sum is exactly the upper bound. This represents every integer in the interval and no others. For each $r_t>0$, encode surplus $h_t$ in $[0,W-r_t]$. A requirement above $W$ instead emits the constant NO decision with no variables and bound $-1$. Otherwise expand + $ H=(sum_j c_j+z-W)^2 + sum_(t:r_t>0)(sum_j a_(j t)c_j-h_t-r_t)^2. $ + Remove $C=W^2+sum_(t:r_t>0)r_t^2$ and use decision bound $-C$. Nonpositive requirements need no row, including the minimum signed integer; empty and duplicate patterns retain their source meaning. + + _Correctness._ $arrow.r.double$: a feasible staffing vector has unused budget $z=W-sum_j c_j$ and nonnegative coverage surplus at most $W-r_t$, so all squares vanish. $arrow.l.double$: a target witness meeting the bound has $H<=0$. Every square is nonnegative, so the budget and coverage equations hold exactly. All decoded counts are nonnegative, their sum is at most $W$, and every positive requirement is met. Nonpositive requirements hold automatically. Unit penalties suffice because this endpoint is feasibility, with no competing objective. + + _Solution extraction and bounds._ Validate target feasibility, then sum each schedule's weighted bits. Aggregate `Or` recovery is identity, including NO results and the existing ExactCoverBy3Sets incoming route. With $b(B)=ceil(log_2(B+1))$, the exact variable count outside the early NO case is $(s+1)b(W)+sum_(t:r_t>0)b(W-r_t)$. With $beta$ the magnitude bits of $W$ (so $b(W-r_t)<=b(W)<=beta$), this is at most $N=beta(s+p+1)$, with at most $binom(beta(s+1),2)+p(beta^2 s+binom(beta,2))$ registered couplings. The budget row already contains every count-count pair and every count-budget pair. Each period adds only count-surplus pairs (at most $beta^2 s$) and within-surplus pairs (at most $binom(beta,2)$); different periods' surplus blocks never interact. The coupling bound is therefore $O(s^2+s p+p)$, linear in periods for fixed schedule count. Construction performs $O((p+1)(s+1)^2+s+p)$ coefficient updates and row bookkeeping for fixed $beta$, since each coverage square may revisit count-count coefficients; ordered-map updates add a logarithmic factor. Bit counts grow logarithmically with the budget. Before expansion, check $s W$ to protect the source's count and coverage folds. Dimension arithmetic, expanded coefficients, omitted constant, and separate positive and negative coefficient sums are checked independently; an unrepresentable construction returns a typed overflow error. The conservative guard can reject representable special cases. + + *Example.* #let ex = load-example("StaffScheduling", "DecisionQUBO") + The canonical source assigns #repr(ex.solutions.at(0).source_config) workers across its #ex.source.instance.schedules.len() patterns, with budget #ex.source.instance.num_workers. The required coverages are #repr(ex.source.instance.requirements). The count blocks, unused-budget block and coverage-surplus blocks produce the displayed target; its canonical bits make every residual zero. +] + +#reduction-rule("ThreePartition", "DecisionQUBO", example: true, + extra: [#direct-qubo-commands("ThreePartition", "DecisionQUBO")], +)[ + ThreePartition reduces to multiplicities of eligible value triples, without labeled-group assignment bits. +][ + _Construction._ For each distinct size $v$, let $c_v$ count its indexed items. Enumerate unordered value triples $t=(a,b,c)$ with $a+b+c=B$. Write $r_(v t)$ for the number of occurrences of $v$ in $t$, and discard triples needing unavailable copies. A triple multiplicity $z_t$ is bounded by $M_t=min_(v:r_(v t)>0) floor(c_v/r_(v t))$. Encode it with bounded binary weights whose sum is $M_t$, then expand + $ H=sum_v (sum_t r_(v t) z_t-c_v)^2. $ + Remove $C=sum_v c_v^2$ and use decision bound $-C$. All penalties have unit weight. Pair enumeration and lookup of $B-a-b$ find the eligible triples. + + _Correctness._ A source partition supplies representable triple multiplicities satisfying every count equation. Conversely, a target at the threshold makes every square zero. For each selected triple copy, consume the next unused original index of each required value and assign those three indices one group. The count equations guarantee that every item is used exactly once; each group has sum $B$, and their number is $n/3$. The original positive-size, range, and total-sum constraints remain in force. If no triple is eligible, the target has zero variables and a negative decision bound, certifying NO. + + _Solution extraction and bounds._ Validate the target and perform the indexed assignment just described; completed `Or` values map identically. If $b_t$ is the bit count for $z_t$, bounded binary encoding gives $b_t<=M_t$. Charge three distinct indexed item pairs per unit of $M_t$. The multiplicity caps supply enough pairs even for repeated values. Different triple types cannot share a pair-value class because its missing value is uniquely $B-a-b$. Thus $3 sum_t M_t<=binom(n,2)$ and $N=sum_t b_t<=n(n-1)/6$. + + For one count row $v$, its triple types correspond to complementary value pairs summing to $B-v$. Each value has only one complement. A distinct pair contributes at most half its combined item count in bits; an equal pair contributes at most half its item count. Hence the number $D_v$ of bits in that row is at most $n/2$. Each bit occurs in at most three rows, so $sum_v D_v<=3N$ and $E<=sum_v binom(D_v,2)<=3N(n-2)/4<=n(n-1)(n-2)/8$. These bounds reduce both the Boolean exponent and the cubic coupling coefficient of labeled-group assignment. With $d$ distinct values, construction uses $O(d^2 log d)$ triple lookup work and $O(n^3)$ coefficient additions, with sparse-map logarithmic factors. Target coefficients depend on item counts rather than squared size magnitudes; all arithmetic remains checked. + + *Example.* #let ex = load-example("ThreePartition", "DecisionQUBO") + The canonical instance and target coefficients are shown below. Its source witness is #repr(ex.solutions.at(0).source_config); the displayed target witness decodes to it and meets the objective or feasibility certificate described above. +] + +#reduction-rule("ThreeDimensionalMatching", "DecisionQUBO", example: true, + extra: [#direct-qubo-commands("ThreeDimensionalMatching", "DecisionQUBO")], +)[ + Perfect three-dimensional matching reduces to one selection bit per indexed triple. +][ + _Construction._ For each of the three axes and each coordinate $u$ add $(sum_(j: t_(j,"axis")=u)x_j-1)^2$. Remove constant $C=3q$ and use bound $-C$, where each axis has $q$ coordinates. + +_Correctness._ $arrow.r.double$: a perfect matching covers each coordinate once, so all equations vanish. $arrow.l.double$: zero penalty forces every coordinate on each axis to appear exactly once. Summing any axis gives exactly $q$ selected triples, and zero duplicate incidences proves pairwise coordinate disjointness. Indexed duplicate triples and the empty matching are covered by the same reasoning. + +_Solution extraction and bounds._ Copy the triple bits and recover `Or` identically. Exactly $m$ variables and at most $m^2$ couplings suffice. The incidence scans cost $O(q m)$ and expansion has at most three contributions per triple pair. This preserves perfect matching, rather than weakening it to arbitrary disjoint triples. + + *Example.* #let ex = load-example("ThreeDimensionalMatching", "DecisionQUBO") + The canonical instance and target coefficients are shown below. Its source witness is #repr(ex.solutions.at(0).source_config); the displayed target witness decodes to it and meets the objective or feasibility certificate described above. +] + +#reduction-rule("StackerCrane", "QUBO", example: true, + extra: [#direct-qubo-commands("StackerCrane", "QUBO")], +)[ + StackerCrane reduces to a cyclic required-arc assignment QUBO using the source's exact connector evaluation contract. +][ + _Construction._ Let $n$ be the number of required directed arcs and $B$ their total length. Precompute $d_(i j)$ from the head of arc $i$ to the tail of arc $j$ by reusing the source Dijkstra routine on required directed arcs and optional undirected edges. This preserves its adjacency order, equal-distance tie order, maximum-integer sentinel, and overflow-to-unavailable behavior. For $n^2$ arc-position bits use $f=B+sum_(i,j,p) d_(i j)x_(i p)x_(j,(p+1) mod n)$ over available connectors, omitting $i=j$ when $n>1$. Add row and column one-hot squares and forbidden-transition products for unavailable connectors, with the shared penalty $P$. For $n=1$ include its diagonal connector once; for $n=0$ use zero objective. + +_Correctness._ $arrow.r.double$: a feasible source arc order selects a permutation matrix and available connectors, giving exactly its closed-walk cost. $arrow.l.double$: a certified target has a unique arc in each position and every required connector available, hence its decoded order evaluates to the same cost. Required arcs may be traversed again inside connectors, as allowed by the source. Disconnected sources have no unpenalized permutation. Before construction check $B+n max_(i,j: d_(i j) " available") d_(i j)$ for representability, independently of all target bounds, so every nonnegative route prefix is safe. + +_Solution extraction and bounds._ Invert the selected positions to the source-native arc-order witness. The source expands it into the closed walk using those same connector queries; no vertex-route witness is substituted. Recover the objective by adding the constant in the certified interval, and `Min(None)` outside it. Exactly $n^2$ bits suffice. Row and column penalties contribute $n^2(n-1)$ pairs, and cyclic transitions contribute at most another $n^2(n-1)$ pairs. Thus at most $2n^2(n-1)$ couplings suffice. For $n=1$ the connector is diagonal, and for $n=2$ reversed cyclic transitions may coincide; both satisfy the bound. There are $n^2$ shortest-path queries, each bounded by $O((v+e+n) log(v+e+n+1))$ for the lazy heap with parallel edges. Polynomial expansion uses $O(n^3)$ coefficient updates and storage; ordered-map updates add a logarithmic factor to time. + + *Example.* #let ex = load-example("StackerCrane", "QUBO") + The canonical instance and target coefficients are shown below. Its source witness is #repr(ex.solutions.at(0).source_config); the displayed target witness decodes to it and meets the objective or feasibility certificate described above. +] + +#reduction-rule("ShortestCommonSuperstring", "QUBO", example: true, + extra: [#direct-qubo-commands("ShortestCommonSuperstring", "QUBO")], +)[ + ShortestCommonSuperstring reduces to an overlap-path assignment QUBO after exact containment preprocessing. +][ + _Construction._ Remove empty strings, duplicate strings (retain the first), and proper contained strings. For the retained $k$ strings let $o_(i j)$ be maximal suffix-prefix overlap. Use $k^2$ string-position bits and objective $f=sum_i |s_i|-sum_(p=0)^(k-2) sum_(i != j) o_(i j)x_(i p)x_(j,p+1)$, with row and column assignment penalties from the shared objective lemma. The source constructor accepts input symbols outside its declared alphabet; add an unconditional penalty for these infeasible instances, rather than producing an invalid reconstructed witness. + +_Correctness._ $arrow.r.double$: order retained strings by their occurrence starts in a shortest source superstring. With containment removed, their ends have the same strict order. Consecutive start distances are at least the preceding length minus maximal overlap, so the path objective is no greater than the source length. $arrow.l.double$: concatenate any path using its maximal overlaps. The current word ends with the previous string, so every retained string remains contained and the length is exactly $f$. Removed duplicates, substrings and empty strings are also contained. These two inequalities prove equality of optima. All-empty inputs give the empty word; an invalid alphabet symbol gives an infeasible source and no certified target value. + +_Solution extraction and bounds._ Decode a string permutation, concatenate with the recorded overlaps, and pad with `None` to the sum of original input lengths. Aggregate recovery uses the shared constant and interval. Actual variables are $k^2<=n^2$. Row and column penalties contribute $k^2(k-1)$ pairs. Each of the $k(k-1)$ directed string transitions has $k-1$ adjacent-position pairs, contributing at most $k(k-1)^2$ further pairs. Hence the coupling count is at most $k(k-1)(2k-1)<=n(n-1)(2n-1)$, cubic in the number of strings. The bound also covers $k=0$ and $k=1$. Naive containment and overlap preprocessing is bounded by $O(n^2 L^2)$ for original total length $L$, with $O(k^3)$ polynomial coefficient updates. Ordered-map updates add a logarithmic factor. Check source length conversion and target arithmetic independently. + + *Example.* #let ex = load-example("ShortestCommonSuperstring", "QUBO") + The canonical instance and target coefficients are shown below. Its source witness is #repr(ex.solutions.at(0).source_config); the displayed target witness decodes to it and meets the objective or feasibility certificate described above. +] #let kc_qubo = load-example("KColoring", "DecisionQUBO") #let kc_qubo_sol = kc_qubo.solutions.at(0) #reduction-rule("KColoring", "DecisionQUBO", @@ -12111,11 +12465,11 @@ where $P$ is a penalty weight large enough that any constraint violation costs m $ H = a y_3 + 2(y_1 y_2 - 2 y_1 a - 2 y_2 a + 3a). $ Rosenberg's penalty is zero when $a=y_1 y_2$ and positive otherwise @borosgruber2014quadratization. Its multiplier 2 strictly exceeds the possible unit gain from the $a y_3$ term. Thus minimizing over each independent auxiliary gives exactly the clause falsification indicator, including repeated or complementary literals. Expand with $x^2=x$ into an upper-triangular QUBO matrix, and retain the omitted constant $C$. - _Correctness._ For every source assignment, the minimum target energy over auxiliaries is the number of falsified clauses minus $C$. Every clause expression is nonnegative before subtracting $C$. Hence the formula is satisfiable iff the global target minimum is exactly $-C$. A satisfying assignment lifts by setting each cubic auxiliary to $y_1 y_2$; every zero-penalty target configuration projects to a satisfying source assignment. If an empty clause occurs, its constant penalty 1 prevents the threshold from being attained. Empty formulas have $C=0$ and every source assignment satisfies them. + _Correctness._ For every source assignment, the minimum target energy over auxiliaries is the number of falsified clauses minus $C$. Every clause expression is nonnegative before subtracting $C$. Hence the formula is satisfiable iff the global target minimum is exactly $-C$. A satisfying assignment lifts by setting each cubic auxiliary to $y_1 y_2$; every zero-penalty target configuration projects to a satisfying source assignment. Empty formulas have $C=0$ and every source assignment satisfies them. _Extraction._ Formal target validation precedes decoding. The DecisionQUBO target checks the bound $E <= -C$, and the registered aggregate decoder maps its completed `Or` value identically. Direct witness extraction rejects energies above the bound and reads the first $n$ coordinates only for a satisfying target witness. It does not solve SAT or repair auxiliary assignments. A completed target decision gives the source decision. - _Domain and overhead._ The K2 variant has $n$ variables; K3 reserves $n+m$, with unused auxiliaries mathematically free for short clauses. Native `new_allow_less` permits widths up to K; CLI and serde still require exactly K per actual clause. Source and target both use `Or`; the target wraps the integer QUBO with the signed bound $-C$. Matrix and constant accumulation use checked arithmetic; literal indices come from the formal `CNFClause::variables` API. Neither endpoint nor variant changes. + _Domain and overhead._ The K2 variant has $n$ variables; K3 reserves $n+m$, one auxiliary per clause. Every clause has exactly K literals. Source and target both use `Or`; the target wraps the integer QUBO with the signed bound $-C$. Matrix and constant accumulation use checked arithmetic; literal indices come from the formal `CNFClause::variables` API. Neither endpoint nor variant changes. ] #let ksat_qc = load-example("KSatisfiability", "QuadraticCongruences") @@ -12149,9 +12503,9 @@ where $P$ is a penalty weight large enough that any constraint violation costs m *Multiplicity:* The fixture stores one canonical satisfying witness. ], )[ - The bounded quadratic-congruence construction of Manders and Adleman @mandersAdleman1976 converts an odd signed-knapsack target into a square congruence using prime powers and the Chinese Remainder Theorem. We give base-eight positions only to the actual normalized clauses. The same slack equation handles every native clause width from zero through three. + The bounded quadratic-congruence construction of Manders and Adleman @mandersAdleman1976 converts an odd signed-knapsack target into a square congruence using prime powers and the Chinese Remainder Theorem. We give base-eight positions only to the actual normalized clauses. The same slack equation handles clauses of at most three literals after normalization. ][ - _Construction._ Delete tautologies and duplicate literals and clauses, retaining empty clauses. Compact the $l$ occurring variables, recording their original positions. Let $C_1, dots, C_m$ be the remaining clauses, $r_j = |C_j|$, $N = 2m+l+1$, and $D=8^(m+1)$. Let $f_i^+$ and $f_i^-$ be the sums of $8^j$ over clauses containing variable $i$ positively and negatively. Define $N$ integer coefficients and an odd target: + _Construction._ Delete tautologies and duplicate literals and clauses. Compact the $l$ occurring variables, recording their original positions. Let $C_1, dots, C_m$ be the remaining clauses, $r_j = |C_j|$, $N = 2m+l+1$, and $D=8^(m+1)$. Let $f_i^+$ and $f_i^-$ be the sums of $8^j$ over clauses containing variable $i$ positively and negatively. Define $N$ integer coefficients and an odd target: $ c_0=1, quad c_(2j-1)=-8^j/2, quad c_(2j)=-8^j, quad c_(2m+i)=(f_i^+-f_i^-)/2, $ $ tau=1+sum_(j=1)^m (r_j-5)8^j/2. $ Take the first $N$ primes starting at $13$, indexed from zero, and put $q_i=p_i^N$ and $K=product_i q_i$. Let $theta_i$ be the least positive integer satisfying $theta_i equiv c_i mod D$, $theta_i equiv 0 mod (K/q_i)$, and $p_i$ not dividing $theta_i$. CRT gives a residue modulo $D K/q_i$; choosing the first positive representative and, if necessary, adding that modulus once gives $theta_i$. Set $H=sum_i theta_i$ and @@ -12160,7 +12514,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m _Clause encoding._ For signs $alpha_i in {-1,+1}$, put $t_i=(1-alpha_(2m+i))/2$ and $y_j=(3-alpha_(2j-1)-2alpha_(2j))/2 in {0,1,2,3}$. Let $T_j$ count true literals in $C_j$ and $R_j=y_j-T_j+1$. Direct expansion gives $ sum_i c_i alpha_i-tau=alpha_0-1+sum_(j=1)^m R_j 8^j. $ - Here $-2<=R_j<=4$ and $alpha_0-1 in {-2,0}$. The right side has absolute value less than $D$. Taking successive residues modulo eight shows that it vanishes exactly when $alpha_0=1$ and all $R_j=0$. Thus the linear congruence modulo $D$ encodes precisely the satisfying assignments. An empty clause has $T_j=0$ and cannot have $R_j=0$; it requires no exceptional construction. + Here $-2<=R_j<=4$ and $alpha_0-1 in {-2,0}$. The right side has absolute value less than $D$. Taking successive residues modulo eight shows that it vanishes exactly when $alpha_0=1$ and all $R_j=0$. Thus the linear congruence modulo $D$ encodes precisely the satisfying assignments. _Magnitude and square-root lemmas._ Each $theta_i<=2D K/q_i$. For $m>=1$, $N>=2m+1>=3$ and $13^N>4N D$, giving $2H route.json", + "pred reduce x.json --via route.json -o bundle.json", + "pred solve bundle.json --solver ilp", + ) + *Example.* The source selects exactly one of + #decision-ilp-sat.source.instance.inner.variables.len() variables, with objective + coefficients #decision-ilp-sat.source.instance.inner.objective.map(t => str(t.at(1))).join(", ") + and minimization bound #decision-ilp-sat.source.instance.bound. + The CNF has #decision-ilp-sat.target.instance.num_vars variables and + #decision-ilp-sat.target.instance.clauses.len() clauses. Its canonical witness + projects to $(#fmt-values(decision-ilp-sat.solutions.at(0).source_config))$; + both the selected-variable sum and the objective equal the bound. + Lowering the bound to zero makes the instance infeasible: exactly one variable + must be selected, and every objective coefficient is at least one. + ], +)[ + Binary decision ILP reduces to CNF satisfiability by binary addition and + Tseitin gate equivalences, following the adder-based pseudo-Boolean encoding + approach @een2006. The encoding preserves the objective threshold and both + optimization senses; it does not turn an arbitrary feasible assignment into an optimum. +][ + _Construction._ Let $n$ be the number of binary variables and $m$ the number of + constraints. Append the objective comparison as one additional row. Reserve + the first $n$ SAT variables for the source assignment and one false constant $f$. + For a row $sum_i a_i x_i diamond b$, replace each negative term by + $a_i x_i = (-a_i)(1-x_i)+a_i$. The normalized weights are $w_i=abs(a_i)$, + literals are $x_i$ or $not x_i$, and the threshold is + $B=b-sum_(i:a_i<0) a_i$. Set $T=sum_i w_i$. + Rows constant over $[0,T]$ emit either no clauses or an empty clause. + + Otherwise, sum the weighted literals using $W$ bits, where $W$ is the binary + length of $T$. Starting from zero, add each weighted literal with ripple + full-adders: for input bits $a,b$ and carry $d$, compute + $p=a xor b$, output $p xor d$, and carry $(a and b) or (p and d)$. + Every partial sum is at most $T<2^W$, so the last carry can be discarded. + Use equivalence clauses for each AND, OR, and XOR gate; constants may be folded. + Equality asserts each sum bit. For a less-than-or-equal comparison, start + $q="true"$ and scan from least to most significant bit: set + $q=(not s_j or q)$ when $B_j=1$, and $q=(not s_j and q)$ when $B_j=0$. + Assert the final $q$. Greater-than-or-equal complements both $W$-bit operands + before applying the same comparator. + + _Correctness._ ($arrow.r.double$) A feasible source assignment meeting the bound + satisfies every normalized comparison by the signed-term identity. Extend it + with the deterministic gate outputs. Induction over adders gives exact sums; + at each comparator bit, a more significant unequal bit decides the comparison, + while equal bits retain the previous result. Every asserted output is therefore true. + ($arrow.l.double$) A satisfying CNF assignment obeys every gate equivalence and + the false-constant clause, forcing the computed sums and comparisons. The output + assertions imply every source row and its objective bound after reversing normalization. + + _Solution extraction._ Validate the full SAT witness, then convert its first + $n$ bits to integers zero or one. Completed Boolean values map identically. + + _Arithmetic and overhead._ Before encoding, checked sums of positive and negative + coefficients must each fit signed 64-bit arithmetic, including the objective row. + Otherwise the reduction returns a typed overflow error: some binary assignments + would overflow the source evaluator. Normalization uses arbitrary-precision integers. + On the accepted domain $T<=2^64-1$, hence $W<=64$. Each row uses at most $5 n W$ + adder gates, $W$ comparator gates, and $W+1$ assertion clauses; every gate uses + at most four clauses of at most three literals. Including the constant gives + $V<=n+1+64(m+1)(5n+1)$ variables, + $C<=1+(m+1)(1280n+321)$ clauses, and at most $3C$ literal occurrences. +] + #reduction-rule("ILP", "QUBO")[ Binary ILP constraints are encoded by squared integer residuals with binary slack variables, following the penalty construction discussed by Lucas @lucas2014[Section 3]. The rule retains the omitted constant and uses a certified energy interval to recover the original objective direction or report infeasibility. No assumption that the ILP is feasible is needed. ][ @@ -12704,6 +13130,36 @@ where $P$ is a penalty weight large enough that any constraint violation costs m _Overhead._ For $v$ named variables, $t$ expression nodes and $o$ named outputs, the target has at most $v+2t$ variables and $5t+o$ constraints. Non-XOR outputs and empty XOR outputs contribute at most $t$ auxiliaries. Binary XOR steps contribute at most the number of expression-tree edges, hence at most $t$. Base gate rows contribute at most $t$, while input inequalities and binary XOR rows contribute at most four times the number of tree edges. Output equalities add at most $o$ rows. This accounts for arbitrarily many constant inputs: three constants under one XOR use five auxiliaries, exceeding the former one-auxiliary-per-node bound of four. ] +== Conditional Direct QUBO Constructions + +These constructions use the checked sparse penalty and extraction contracts above. Their logical-size advantages are instance-dependent. Every decoder includes target validation, costing $O(N+E)$ in addition to decoding, for $N$ variables and $E$ nonzero terms. Construction costs count arithmetic operations; coefficient bit lengths and sparse-map accumulation also contribute. The scheduling conflict construction follows the time-indexed penalty pattern of #link("https://arxiv.org/abs/1506.08479")[Venturelli et al.]. + +#reduction-rule("SequencingWithReleaseTimesAndDeadlines", "DecisionQUBO", example: true, + extra: [#direct-qubo-commands("SequencingWithReleaseTimesAndDeadlines", "DecisionQUBO")], +)[ + Release-time/deadline sequencing reduces to DecisionQUBO on admissible starts, including zero-duration tasks. +][ + _Construction._ First check that $max_i r_i+sum_i p_i$ fits the source integer type, bounding finish additions for every source order. Task $i$ chooses $t=r_i,dots,d_i-p_i$. An impossible window gives an empty row. Add squared one-hot rows and a unit pair penalty whenever neither selected task finishes before the other starts. Store the nonconstant energy with bound equal to the negative omitted constant. + _Correctness._ ($arrow.r.double$) Earliest execution of a feasible source order gives valid choices without conflicts. ($arrow.l.double$) Zero-penalty choices are sorted by start, duration and index, placing zero-duration tasks first at a shared start. Inductively, earliest execution of that order starts no later than each selected start and finishes by its deadline. Source finish additions on recovered witnesses are bounded by these deadlines. Empty rows enforce NO and touching intervals are allowed. + _Extraction and size._ Decode and sort after target validation; completed feasibility maps identically. For $T=max_i d_i$, $S=sum_i max(0,d_i-p_i-r_i+1)<=n w$, where $w<=T+1$ is the largest single start window, with at most $S(S-1)/2$ couplings and $O(S^2)$ candidate pairs. This grid is pseudopolynomial in $T$, and can be much larger than the existing bounded-start route. + + *Example.* #let ex = load-example("SequencingWithReleaseTimesAndDeadlines", "DecisionQUBO") + The canonical source witness #repr(ex.solutions.at(0).source_config) maps to a target with #ex.target.instance.inner.num_vars variables; the exact source and target configurations are shown below. +] + +#reduction-rule("DynamicStorageAllocation", "DecisionQUBO", example: true, + extra: [#direct-qubo-commands("DynamicStorageAllocation", "DecisionQUBO")], +)[ + Dynamic storage allocation gains a direct address-choice DecisionQUBO formulation. +][ + _Construction._ Item $i$, size $s_i$, chooses one address $a=0,dots,D-s_i$ using a squared one-hot row. For items with strictly overlapping lifetimes, penalize each pair of selected address intervals that strictly overlap. All penalties have unit weight; the decision threshold restores the omitted constant to zero. + _Correctness._ ($arrow.r.double$) Every valid allocation selects one address per item and has no conflict. ($arrow.l.double$) A target at the threshold has exactly one address per row, each fitting within memory, and no overlapping addresses for simultaneously live items. Lifetimes or addresses that merely touch incur no conflict. Selected endpoints never exceed $D$, so source address addition is representable. + _Extraction and size._ Validate once and return selected addresses. Completed `Or` maps identically, including NO. Exactly $S=sum_i(D-s_i+1)<=n D$ bits and at most $S(S-1)/2$ couplings; construction enumerates $O(S^2)$ pairs. This is pseudopolynomial in numeric memory capacity, not polynomial in its bit length. + + *Example.* #let ex = load-example("DynamicStorageAllocation", "DecisionQUBO") + The canonical source witness #repr(ex.solutions.at(0).source_config) maps to a target with #ex.target.instance.inner.num_vars variables; the exact source and target configurations are shown below. +] + == Non-Trivial Reductions #let sat_mis = load-example("Satisfiability", "DecisionMaximumIndependentSet") @@ -16925,17 +17381,6 @@ Problems parameterized by graph type, weight type, target type, or clause width _Solution extraction._ Return the target configuration unchanged. ] -#reduction-rule("KSatisfiability", "KSatisfiability")[ - A $k$-SAT instance with fixed clause width ($k = 2$ or $k = 3$) converts to generic $k$-SAT by constructing the registered $K_N$ variant. The clauses and variables are preserved verbatim; the target uses `new_allow_less` to accept clauses with fewer than $k$ literals. -][ - _Construction._ Given $"KSat"_(K_j)(n, cal(C))$ with $j in {2, 3}$, construct $"KSat"_(K_N)(n, cal(C))$ with the same $n$ variables and clause set $cal(C)$. - - _Correctness._ Every clause in $cal(C)$ has at most $j$ literals. The $K_N$ variant accepts clauses of any width, so all clauses remain valid. The satisfiability condition (every clause has at least one true literal) is unchanged. - - _Solution extraction._ Return the target configuration unchanged. -] - - // Completeness check: warn about reduction rules in JSON but missing from paper #context { let covered = covered-rules.final() @@ -17505,6 +17950,8 @@ The following table shows concrete target-variable counts for example instances, _Correctness._ ($arrow.r.double$) An exact cover $I$ with $|I| = q$ assigns one worker per selected schedule, covering each period exactly once. ($arrow.l.double$) A feasible assignment with $sum_j w_j = q$ and each period covered exactly once gives pairwise-disjoint schedules covering all of $X$. + _Size._ The budget $q$ has at most $q+1$ magnitude bits; transforms carry no logarithm, so the declared bound is linear in $|X|/3$. + _Solution extraction._ Map $w in NN^m$ to $c in {0,1}^m$ by $c[j] = 1$ if $w[j] > 0$. ] @@ -17538,7 +17985,7 @@ The following table shows concrete target-variable counts for example instances, )[ Use the classical 3-SAT to Vertex Cover gadget construction, then ask whether the resulting graph has a vertex cover of size at most $k = n + 2m$ @garey1979. ][ - _Construction._ Given 3-CNF $phi$ with $n$ variables and $m$ clauses, build the graph $G = (V, E)$ used in the classical 3-SAT $arrow.r$ Minimum Vertex Cover reduction: for each variable $x_i$, add literal vertices $u_i$ and $overline(u)_i$ with one truth-setting edge between them; for each clause $c_j$, add triangle vertices $t^j_0, t^j_1, t^j_2$; for each literal position $(j, r)$, add a communication edge from $t^j_r$ to the literal vertex representing that literal. Assign unit weight to every vertex and set the decision threshold to $k = n + 2m$. For accepted short nonempty clauses, repeat their literals to fill three positions. For an empty clause, keep its triangle without communication edges and use threshold $-1$, making the decision instance infeasible. The vertex count stays exact; the edge count is at most $n+6m$. + _Construction._ Given 3-CNF $phi$ with $n$ variables and $m$ clauses, build the graph $G = (V, E)$ used in the classical 3-SAT $arrow.r$ Minimum Vertex Cover reduction: for each variable $x_i$, add literal vertices $u_i$ and $overline(u)_i$ with one truth-setting edge between them; for each clause $c_j$, add triangle vertices $t^j_0, t^j_1, t^j_2$; for each literal position $(j, r)$, add a communication edge from $t^j_r$ to the literal vertex representing that literal. Assign unit weight to every vertex and set the decision threshold to $k = n + 2m$. The graph has exactly $2n+3m$ vertices and $n+6m$ edges. _Correctness._ ($arrow.r.double$) If $phi$ is satisfiable, choose one literal vertex per variable according to a satisfying assignment and choose two triangle vertices per clause, omitting a vertex whose literal is true. This gives a cover of size $n + 2m$, so the decision instance is yes. ($arrow.l.double$) If the decision instance is yes, any cover of size at most $n + 2m$ must use exactly one literal vertex per variable edge and exactly two vertices per clause triangle. The omitted triangle vertex in each clause has its communication edge covered only by a selected literal vertex, so the corresponding literal is true. These selected literal vertices define a satisfying assignment for $phi$. @@ -17594,14 +18041,14 @@ The following table shows concrete target-variable counts for example instances, Conversely, either equal signal colouring extends: make that colour the cycles $u,v,w,a,b,u$ and $u,v,w,c,d,u$ in the two cliques, and give all remaining edges the other colour. Both cycles agree on the private triangle, and each clique has no monochromatic triangle. No triangle crosses between the two signal edges. - _Construction._ Let the source have $n$ variables and $m$ clauses of length at most three; the typed API also admits shorter clauses. Let $q$ count its length-three clauses. + _Construction._ Let the source have $n$ variables and $m$ clauses, each with three literal positions. - 1. Apply the existing typed embedding into Satisfiability and the existing SAT-to-NAE reduction. It adds a sentinel $s$, replacing each nonempty clause $C$ by $"NAE"(C,s)$ and an empty clause by $"NAE"(s,s)$. - 2. Replace $"NAE"(a,b)$ by $"NAE"(a,b,b)$; keep triples unchanged; replace $"NAE"(a,b,c,d)$ by $"NAE"(a,b,z)$ and $"NAE"(overline(z),c,d)$ with a fresh variable $z$. There are $N=n+1+q$ logical variables and $m+q$ resulting triples. - 3. Give every logical variable $i$ two disjoint signal edges $P_i,M_i$. Their $4N$ vertices are disjoint from all subsequent clause vertices. For each variable include the sign-consistency triple $"NAE"(P_i,P_i,M_i)$, followed by the logical triples, using $P_i$ for a positive literal and $M_i$ for a negative one. There are $T=N+m+q$ triples in total. + 1. Apply the existing typed embedding into Satisfiability and the existing SAT-to-NAE reduction. It adds a sentinel $s$, replacing each clause $C$ by the four-literal $"NAE"(C,s)$. + 2. Replace each $"NAE"(a,b,c,d)$ by $"NAE"(a,b,z)$ and $"NAE"(overline(z),c,d)$ with a fresh variable $z$. There are $N=n+1+m$ logical variables and $2m$ resulting triples. + 3. Give every logical variable $i$ two disjoint signal edges $P_i,M_i$. Their $4N$ vertices are disjoint from all subsequent clause vertices. For each variable include the sign-consistency triple $"NAE"(P_i,P_i,M_i)$, followed by the logical triples, using $P_i$ for a positive literal and $M_i$ for a negative one. There are $T=N+2m$ triples in total. 4. Give every triple its own triangle. Connect its three edges to the three literal signals using three equality senders, each with fresh private vertices. The terminal edges already exist, so a sender adds three vertices and fifteen edges. Repeated literals use separate senders; no endpoint identification or case-specific repair is performed. - _NAE identities._ The length-two identity holds because duplicating a value does not change whether all values agree. For the length-four identity, if $a=b$ the first triple forces $z=overline(a)$; if $c=d$ the second forces $z=c$. These requirements conflict exactly when all four values agree. If either pair differs, its corresponding triple imposes no condition on $z$. This proves the identity in both directions for all input patterns. + _NAE identity._ For the length-four identity, if $a=b$ the first triple forces $z=overline(a)$; if $c=d$ the second forces $z=c$. These requirements conflict exactly when all four values agree. If either pair differs, its corresponding triple imposes no condition on $z$. This proves the identity in both directions for all input patterns. _Triangle isolation._ Private vertices from different senders are nonadjacent. Any triangle containing a private sender vertex must lie in that sender: both other vertices belong to it, and the only edges among its four boundary vertices are its two terminal edges. This remains true after assembly because all variable signals and clause triangles have disjoint vertex sets, and senders add no boundary-to-boundary edges. A triangle containing no sender-private vertex is one of the clause triangles. Hence the target contains exactly the intended clause triangles and the nineteen triangles inside each sender, even with repeated or complementary literals and repeated variable occurrences. @@ -17611,7 +18058,7 @@ The following table shows concrete target-variable counts for example instances, _Solution extraction._ Validate the target colouring and read the positive signal colours for the original variables and sentinel. Pass this assignment to the existing SAT-to-NAE extraction API. A global colour swap leaves the normalized source assignment unchanged. - _Overhead._ The exact counts are $4N+12T$ vertices, $2N+48T$ edges and $58T$ triangles. Since $q<=m$, these are bounded by $16n+40m+16$, $50n+146m+50$ and $58n+174m+58$, respectively. All graph-size arithmetic is checked before allocation. With exactly three literals per clause the bounds are equalities. + _Overhead._ The exact counts are $4N+12T$ vertices, $2N+48T$ edges and $58T$ triangles. Substituting $N=n+1+m$ and $T=n+1+3m$ gives exactly $16n+40m+16$, $50n+146m+50$ and $58n+174m+58$, respectively. All graph-size arithmetic is checked before allocation. With exactly three literals per clause the bounds are equalities. _YES example._ For the clause $(x_1 or x_2 or x_3)$, choose values $(1,0,0)$, sentinel $0$ and splitting variable $0$. Both logical triples evaluate to $"NAE"(1,0,0)$. Here $N=5$, $T=7$, giving 104 vertices, 346 edges and 406 triangles. The signal colours extend by the sender construction. @@ -17653,16 +18100,16 @@ The following table shows concrete target-variable counts for example instances, )[ Schaefer's Lemma 3.5 @schaefer1978 expresses a three-input disjunction by five exact-one constraints. The implementation compacts the $a$ appearing source variables, adds two global constant variables and six auxiliaries per clause. A source with $m$ clauses produces $a + 2 + 6m$ target variables and $1 + 5m$ target clauses, bounded by $n + 2 + 6m$ and $1 + 5m$ in the declared source parameters. ][ - _Construction._ Let $J$ be the sorted original indices of the appearing variables. Replace each actual source literal by the corresponding compact variable with its sign preserved. Use the existing SAT variable allocator to allocate $z_0$ and $z_T$, followed by six fresh variables $a_j, b_j, c_j, d_j, e_j, f_j$ for each source clause. The global exact-one constraint $R(z_0,z_0,z_T)$ forces $(z_0,z_T)=(0,1)$. Each native source clause has zero through three positions; fill missing positions with $z_0$. This preserves its disjunction, including the empty clause's false value. For the resulting three literals $ell_1^j,ell_2^j,ell_3^j$, append + _Construction._ Let $J$ be the sorted original indices of the appearing variables. Replace each actual source literal by the corresponding compact variable with its sign preserved. Use the existing SAT variable allocator to allocate $z_0$ and $z_T$, followed by six fresh variables $a_j, b_j, c_j, d_j, e_j, f_j$ for each source clause. The global exact-one constraint $R(z_0,z_0,z_T)$ forces $(z_0,z_T)=(0,1)$. For each clause's three literals $ell_1^j,ell_2^j,ell_3^j$, append $ R(ell_1^j,a_j,d_j), quad R(ell_2^j,b_j,d_j), quad R(a_j,b_j,e_j), quad R(c_j,d_j,f_j), quad R(ell_3^j,c_j,z_0). $ Here $R$ counts literal occurrences, so its repeated $z_0$ in the global constraint is intentional. Negative literals remain permitted by the target model. Every clause uses this same construction; no empty-clause target case is needed. - _Correctness._ ($arrow.r.double$) Given a satisfying source assignment, assign its appearing coordinates to the compact target variables and set the constants as above. Let $(x,y,z)$ be one padded clause's literal truth values. Set $d=(1-x)(1-y)$, $a=1-x-d$, $b=1-y-d$, $c=1-z$, $e=1-a-b$ and $f=z-d$. If $x=y=0$, satisfaction forces $z=1$, giving $(a,b,c,d,e,f)=(0,0,0,1,1,0)$. Otherwise $d=0$, $a=1-x$, $b=1-y$, $e=x+y-1$, and $f=z$; these and $c=1-z$ are Boolean because $x+y>=1$. Substitution gives $x+a+d=y+b+d=a+b+e=c+d+f=z+c=1$, satisfying all five constraints. Auxiliary variables are disjoint across clauses, so the assignments combine. + _Correctness._ ($arrow.r.double$) Given a satisfying source assignment, assign its appearing coordinates to the compact target variables and set the constants as above. Let $(x,y,z)$ be one source clause's literal truth values. Set $d=(1-x)(1-y)$, $a=1-x-d$, $b=1-y-d$, $c=1-z$, $e=1-a-b$ and $f=z-d$. If $x=y=0$, satisfaction forces $z=1$, giving $(a,b,c,d,e,f)=(0,0,0,1,1,0)$. Otherwise $d=0$, $a=1-x$, $b=1-y$, $e=x+y-1$, and $f=z$; these and $c=1-z$ are Boolean because $x+y>=1$. Substitution gives $x+a+d=y+b+d=a+b+e=c+d+f=z+c=1$, satisfying all five constraints. Auxiliary variables are disjoint across clauses, so the assignments combine. - ($arrow.l.double$) Every target solution forces $z_0=0$. If a padded clause had $x=y=z=0$, the final constraint would force $c=1$; the fourth would then force $d=f=0$. The first two would force $a=b=1$, contradicting the third. Thus every padded disjunction is true, which implies every original clause is true. Repetition, opposite signs and missing positions do not change this argument. + ($arrow.l.double$) Every target solution forces $z_0=0$. If a source clause had $x=y=z=0$, the final constraint would force $c=1$; the fourth would then force $d=f=0$. The first two would force $a=b=1$, contradicting the third. Thus every source clause is true. Repeated literals and opposite signs do not change this argument. _Solution extraction._ Validate the target assignment once using the formal evaluator and reject $"Or(false)"$. Initialize a length-$n$ false vector, then restore the compact source coordinates to their original positions in $J$. Unused source variables stay false. The resolved value types are $"Or" arrow.r "Or"$. @@ -17706,9 +18153,9 @@ The following table shows concrete target-variable counts for example instances, #reduction-rule("KSatisfiability", "FeasibleRegisterAssignment", example: false, )[ - Sethi's Reduction 3 @sethi1973 builds a DAG with shared-register variable leaf pairs and $p\/q\/r\/overline(r)$ clause gadgets. Nonempty short clauses are padded by repeating a literal; occurring variables are compacted. For $s$ declared source variables and $c$ clauses, the target has at most $2s+12c$ vertices, $15c$ arcs, $s+9c$ registers, and $s+3c$ pairs of vertices sharing a register. + Sethi's Reduction 3 @sethi1973 builds a DAG with shared-register variable leaf pairs and $p\/q\/r\/overline(r)$ clause gadgets. Occurring variables are compacted. For $s$ declared source variables and $c$ clauses, the target has at most $2s+12c$ vertices, $15c$ arcs, $s+9c$ registers, and $s+3c$ pairs of vertices sharing a register. ][ - _Native normalization._ If any clause is empty, return the three-vertex DAG with dependencies $(2,0),(2,1)$ and register assignment $(0,0,1)$. Both predecessors must stay live until vertex 2, so neither can overwrite the other's register; this target is infeasible. Otherwise collect appearing source indices in increasing order and store their inverse map. Pad each nonempty clause to three literal occurrences by repeating its first literal. Repetition preserves a disjunction, and each occurrence has its own gadget and register pair, so no distinct-variable assumption is needed. An empty conjunction yields the empty DAG and empty realization. + _Native normalization._ Collect appearing source indices in increasing order and store their inverse map. Each of the three literal occurrences of a clause, including repeated literals, has its own gadget and register pair, so no distinct-variable assumption is needed. An empty conjunction yields the empty DAG and empty realization. _Construction._ Let $a$ be the number of appearing variables. For each compact variable $x_k$, create leaves $s_k^+,s_k^-$ sharing register $S_k$. For every clause position $(i,j)$ create $p_(i,j),q_(i,j),r_(i,j),overline(r)_(i,j)$. The $p$ and $q$ registers are unique, while $r$ and $overline(r)$ share a fresh register $R_(i,j)$. Arcs point from a consumer to its dependency: $q_(i,j) -> p_(i,j) -> r_(i,j)$ and $q_(i,j) -> overline(r)_(i,j+1)$, with positions cyclic modulo three. For positive literal $x_k$, add $r_(i,j) -> s_k^+$ and $overline(r)_(i,j) -> s_k^-$; reverse these attachments for a negative literal. This DAG has four levels: leaves, $r/overline(r)$, $p$, and $q$. @@ -17718,7 +18165,7 @@ The following table shows concrete target-variable counts for example instances, _Extraction._ Validate the complete position vector and reject $"Or(false)"$. Set each appearing source variable true iff its compact positive leaf occurs before its negative leaf. Restore original indices through the inverse map and assign false to unused variables. The target model's permitted last-use overwrite does not weaken the order-transfer proof: neither member of a shared pair is the other member's consumer in this construction. - _Bounds and native arithmetic._ For the ordinary construction $a <= min(s,3c)$, so the displayed upper bounds follow from the exact counts $2a+12c$, $15c$, $a+9c$, and $a+3c$. The fixed NO target has counts $(3,2,2,1)$ and also satisfies the bounds whenever an empty clause exists; the empty YES target has all counts zero. Let $I="isize::MAX"$. A native source clause stores a vector object of 24 bytes on 64-bit or 12 bytes on 32-bit, so successful allocation of its outer vector bounds $c$ by $I/24$ or $I/12$. Therefore $2a+12c <= 18c$, $a+9c <= 12c$, and $15c$ fit $"usize"$ before target allocation on both architectures. Once the target register-assignment vector is allocated, every indexed vertex is within that vector. All same-register groups have size two except the fixed target's singleton, so pair counting introduces no large product. Checked construction arithmetic is retained; allocation and solver limits are not infeasibility proofs. + _Bounds and native arithmetic._ For the ordinary construction $a <= min(s,3c)$, so the displayed upper bounds follow from the exact counts $2a+12c$, $15c$, $a+9c$, and $a+3c$. The empty YES target has all counts zero. Let $I="isize::MAX"$. A native source clause stores a vector object of 24 bytes on 64-bit or 12 bytes on 32-bit, so successful allocation of its outer vector bounds $c$ by $I/24$ or $I/12$. Therefore $2a+12c <= 18c$, $a+9c <= 12c$, and $15c$ fit $"usize"$ before target allocation on both architectures. Once the target register-assignment vector is allocated, every indexed vertex is within that vector. All same-register groups have size two, so pair counting introduces no large product. Checked construction arithmetic is retained; allocation and solver limits are not infeasibility proofs. ] #let ksat_rs = load-example("KSatisfiability", "RegisterSufficiency") @@ -17743,9 +18190,9 @@ The following table shows concrete target-variable counts for example instances, *Multiplicity:* The fixture stores one canonical satisfying assignment. Different satisfying assignments can induce different valid computation orders in the target DAG. ], )[ - Sethi's Reduction 1 and Theorem 3.11 @sethi1973 encode literal choices through register pressure. We extend the construction to the native zero-to-three-literal clause domain by truth-preserving normalization and explicit empty-formula boundaries. The original padding $b=max(2n-m,0)$ is retained. + Sethi's Reduction 1 and Theorem 3.11 @sethi1973 encode literal choices through register pressure. The native source has exactly three literals per clause, with repeated literals permitted. We compact appearing variables and handle the empty formula explicitly. The original padding $b=max(2n-m,0)$ is retained. ][ - _Normalization._ A formula containing an empty clause is false; output a single output vertex with register bound zero. An empty conjunction is true; output the empty graph with bound zero. In every other case collect the appearing variables in increasing original-index order, map them to $1,dots,n$, and retain the inverse map. Keep the original clause order and literal occurrences. Repeat the first literal of each nonempty short clause until it has three positions. This preserves disjunction truth, gives $n,m>=1$, and never introduces a fictitious vertex for a missing literal. Let $Y_(i,j)$ denote the literal at position $j$ of clause $i$, allowing repeated literals. + _Normalization._ An empty conjunction is true; output the empty graph with bound zero. Otherwise collect the appearing variables in increasing original-index order, map them to $1,dots,n$, and retain the inverse map. Keep the original clause order and literal occurrences, which gives $n,m>=1$. Let $Y_(i,j)$ denote the literal at position $j$ of clause $i$, allowing repeated literals. _DAG construction._ Put $b=max(2n-m,0)$ and $K=3m+4n+1+b$. Arcs $(v,u)$ mean that $v$ depends on $u$. Create the following disjoint families, with one-based subscripts: $ |A|=2n+1, quad |B|=b, quad |C|=m, quad F={f_(i,j):1<=i<=m,1<=j<=3}, $ @@ -17907,13 +18354,13 @@ The following table shows concrete target-variable counts for example instances, *Multiplicity:* The fixture stores one canonical witness. The formula has multiple satisfying assignments; each induces at least one $k$-clique by choosing one true literal per clause. ], )[ - Karp's literal-compatibility construction @karp1972 is extended with an anchor and, when needed, isolated padding. This keeps the same endpoint and handles both empty formulas and the shorter clauses accepted by the native `new_allow_less` API. + Karp's literal-compatibility construction @karp1972 is extended with an anchor vertex, so the empty formula maps to a one-vertex clique instance. ][ - _Construction._ Let $m$ be the clause count and $t=sum_j |C_j|$ the actual occurrence count. Number literal vertices in clause/position order, with stored variable indices and polarities. Set $N=max(t,m)+1$, $k=m+1$, and anchor $a=t$. Connect two literal vertices iff they come from different clauses and are not opposite polarities of the same variable; then connect $a$ to all literal vertices. Any remaining vertices are isolated. Thus $1<=k<=N$, $N<=3m+1$, and the edge count is at most $9m^2+3m$. + _Construction._ Let $m$ be the clause count. Number the $3m$ literal occurrences in clause/position order, with stored variable indices and polarities. Set $N=3m+1$, $k=m+1$, and anchor $a=3m$. Connect two literal vertices iff they come from different clauses and are not opposite polarities of the same variable; then connect $a$ to all literal vertices. Thus $1<=k<=N$ and the edge count is at most $9m^2+3m$. - _Correctness._ A satisfying assignment selects a true occurrence from each clause; these mutually compatible vertices and the anchor form a $k$-clique. Conversely, the literal subgraph contains at most one vertex per clause in a clique, hence has clique number at most $m$. For $m>0$, any clique of size at least $k>=2$ excludes padding isolates and must contain the anchor plus one occurrence from every clause. Their polarities consistently satisfy all clauses. An empty clause prevents such a clique. For $m=0$, the target is a single anchor with $k=1$, and every source assignment satisfies the empty conjunction. + _Correctness._ A satisfying assignment selects a true occurrence from each clause; these mutually compatible vertices and the anchor form a $k$-clique. Conversely, the literal subgraph contains at most one vertex per clause in a clique, hence has clique number at most $m$. For $m>0$, any clique of size at least $k>=2$ must contain the anchor plus one occurrence from every clause. Their polarities consistently satisfy all clauses. For $m=0$, the target is a single anchor with $k=1$, and every source assignment satisfies the empty conjunction. - _Solution extraction._ Validate the complete target selection and its true clique predicate first. Decode the literal vertices via their stored source indices and signs, leaving unselected variables false; ignore the nonliteral anchor and padding by the defined mapping. Checked occurrence and output counts precede allocation. Formal `CNFClause::variables` supplies validated indices. Normal CLI/serde still requires exactly three literals per actual clause; native short-clause support does not change variants or serialization. + _Solution extraction._ Validate the complete target selection and its true clique predicate first. Decode the literal vertices via their stored source indices and signs, leaving unselected variables false; ignore the anchor by the defined mapping. Checked vertex and output counts precede allocation. Formal `CNFClause::variables` supplies validated indices. ] #let ksat_co = load-example("KSatisfiability", "CyclicOrdering") @@ -17942,7 +18389,7 @@ The following table shows concrete target-variable counts for example instances, )[ This reduction @galilMegiddo1977 first normalizes clauses to three distinct, globally ordered variables. With $s$ declared source variables and $c$ source clauses it produces at most $3s + 26c + 3$ elements and $40c + 2$ cyclic-order constraints. The original ten-constraint gadget is applied to the normalized formula. ][ - _Normalization._ An empty clause maps to the fixed NO target on three elements with constraints $(0,1,2)$ and $(0,2,1)$. Otherwise remove repeated literals within each clause and discard clauses containing both signs of a variable. Compact the remaining appearing variables in increasing original-index order, retaining the inverse map. Replace a two-literal clause $(a or b)$ by $(a or b or u) and (a or b or not u)$ with fresh $u$. Replace a unit clause $a$ by the four clauses $(a or (plus.minus u) or (plus.minus v))$ over both independent signs, with fresh $u,v$. These conjunctions are true for every auxiliary assignment when the original clause is true and false for at least one constituent clause under every auxiliary assignment when the original clause is false. Every resulting clause has three distinct variables; order its literals by increasing variable index. If no clauses remain, use the one-element unconstrained YES target. + _Normalization._ Remove repeated literals within each clause and discard clauses containing both signs of a variable. Compact the remaining appearing variables in increasing original-index order, retaining the inverse map. Replace a two-literal clause $(a or b)$ by $(a or b or u) and (a or b or not u)$ with fresh $u$. Replace a unit clause $a$ by the four clauses $(a or (plus.minus u) or (plus.minus v))$ over both independent signs, with fresh $u,v$. These conjunctions are true for every auxiliary assignment when the original clause is true and false for at least one constituent clause under every auxiliary assignment when the original clause is false. Every resulting clause has three distinct variables; order its literals by increasing variable index. If no clauses remain, use the one-element unconstrained YES target. _Construction._ Let the normalized formula have $N$ variables and $M$ clauses. For each variable $x_i$, create three target elements $alpha_i, beta_i, gamma_i$. A positive literal has associated triple $(alpha_i,beta_i,gamma_i)$; a negative literal has $(alpha_i,gamma_i,beta_i)$. For each normalized clause, let its three literal triples be $(a,b,c)$, $(d,e,f)$, $(g,h,i)$ in increasing variable order. Add five fresh elements $j,k,l,m,n$ and the ten constraints $ @@ -18002,7 +18449,7 @@ The following table shows concrete target-variable counts for example instances, )[ Ullman's reduction first builds a variable-capacity unit-task scheduling instance for 3-SAT, then pads each time slot with chained filler jobs so a fixed number of processors simulates the desired capacity profile. Because every task has length $1$, preemption is irrelevant: the resulting instance is already a valid preemptive scheduling instance whose optimal makespan is at most $T = n + 3$ iff the formula is satisfiable @ullman1975 @garey1979. ][ - Short nonempty clauses are padded by repeating literals. An empty conjunction maps to one unit task with threshold 1; a formula containing an empty clause maps to the same task with threshold 0. The following construction handles the remaining instances. + An empty conjunction maps to one unit task with threshold 1. The following construction handles the remaining instances. _Construction._ Let $phi$ be a 3-CNF formula with variables $x_1, dots, x_n$ and clauses $C_1, dots, C_m$. Create unit jobs $x_(i,j)$ and $overline(x)_(i,j)$ for $1 <= i <= n$ and $0 <= j <= n$, plus forcing jobs $y_i, overline(y)_i$, and clause jobs $D_(r,s)$ for $1 <= r <= m$, $1 <= s <= 7$. Add chain precedences $x_(i,j) prec x_(i,j+1)$ and $overline(x)_(i,j) prec overline(x)_(i,j+1)$, and branching precedences $x_(i,i-1) prec y_i$, $overline(x)_(i,i-1) prec overline(y)_i$. Set $T = n + 3$ and slot capacities $c_0 = n$, $c_1 = 2n + 1$, $c_t = 2n + 2$ for $2 <= t <= n$, $c_(n+1) = m + n + 1$, and $c_(n+2) = 6m$. For each clause $C_r = (ell_1 or ell_2 or ell_3)$ and each nonzero bit pattern $b in {1, dots, 7}$, create clause job $D_(r,b)$. Its predecessors are the three chain endpoints chosen according to the bits of $b$: for literal position $k$, use the endpoint of $ell_k$ when bit $k$ is 1 and of $not ell_k$ when bit $k$ is 0. This makes exactly one clause job per clause ready one slot earlier when the clause is satisfied. @@ -18085,7 +18532,7 @@ The following table shows concrete target-variable counts for example instances, _Solution extraction._ Validate the target partition, select precisely those vertex items sharing the label of $s$, and invoke the formal clique-to-SAT extractor. This mapping is independent of the numerical names of partition blocks. - _Overhead._ There are exactly $h+e+2$ vertices and $2h+4e$ arcs. With $m$ source clauses, $h<=3m+1$ and $e<=9m(m-1)/2+3m$, including native short or empty clauses. Thus target sizes are at most $(9m^2+3m+6)/2$ vertices and $18m^2+2$ arcs. The construction checks representability of counts and signed integer coefficients before allocating its incidence graph. + _Overhead._ There are exactly $h+e+2$ vertices and $2h+4e$ arcs. With $m$ source clauses, $h<=3m+1$ and $e<=9m(m-1)/2+3m$. Thus target sizes are at most $(9m^2+3m+6)/2$ vertices and $18m^2+2$ arcs. The construction checks representability of counts and signed integer coefficients before allocating its incidence graph. ] #let hc_bicon = load-example("HamiltonianCircuit", "BiconnectivityAugmentation") @@ -18612,7 +19059,7 @@ The following table shows concrete target-variable counts for example instances, #reduction-rule("KSatisfiability", "BicliqueCover")[ Polynomial reduction (Chandran, Issac, and Karrenbauer, IPEC 2016 @chandran_et_al:LIPIcs.IPEC.2016.11) from 3-SAT to BicliqueCover with logarithmic rank. Each appearing source variable is split into a positive/negative pair $(t_i, f_i)$, exactly-one clauses tie them to opposite truth values, and the formula is padded so that $n = 2^ell$ normalized variables admit a balanced satisfying assignment with exactly $n / 2$ true variables. The construction then assembles a bipartite gadget whose biclique cover rank equals $k_f + 2 ell + 2$ iff the (normalized) formula is satisfiable. ][ - _Construction._ Let $psi$ have $n_s$ declared variables and $m_s$ clauses. The native Rust API also permits clauses of at most three literals. An empty clause maps to a single-edge, rank-zero NO target. A formula with no clauses maps to an empty, rank-zero YES target. Otherwise compact the $a$ variables that actually appear, recording their original indices, and repeat one literal in each nonempty short clause to obtain exactly three positions. Repetition preserves disjunction, and absent variables may be assigned false in the inverse map. *Normalize* this compact formula: + _Construction._ Let $psi$ have $n_s$ declared variables and $m_s$ three-literal clauses. A formula with no clauses maps to an empty, rank-zero YES target. Otherwise compact the $a$ variables that actually appear, recording their original indices; absent variables may be assigned false in the inverse map. *Normalize* this compact formula: $ "vars:" quad & "introduce" t_i, f_i "for each appearing" x_i "and pad to" n = 2^ell "variables" \ "clauses:" quad & "replace literal" x_i "by" t_i ";" not x_i "by" f_i \ @@ -18787,15 +19234,15 @@ The following table shows concrete target-variable counts for example instances, )[ This reduction uses variable digons and clause three-cycles, following the kernel hardness approach of @chvatal1973. With $a$ appearing variables, $m$ clauses and $L$ literal occurrences, the target has $2a + 3m$ vertices and $2a + 3m + L$ arcs. These are bounded by $2n + 3m$ and $2n + 6m$ on the native source domain. Each occurrence has its own clause vertex; kernels may include clause vertices as well as literals. ][ - _Construction._ Collect the sorted original indices $J$ of appearing variables, with $a = |J|$. For each compact variable $i$, create literal vertices $p_i, n_i$ and both digon arcs. Each native clause has zero through three literals. Create three vertices $c_(j,0), c_(j,1), c_(j,2)$ and the directed cycle in that order, then add $(c_(j,t), v(ell_(j,t)))$ only for its actual literal positions. Repeated literals keep separate occurrence vertices. An empty clause therefore produces a three-cycle with no outgoing literal arcs, which has no kernel; an empty formula produces an empty graph, whose empty selection is a kernel. No padding is needed. + _Construction._ Collect the sorted original indices $J$ of appearing variables, with $a = |J|$. For each compact variable $i$, create literal vertices $p_i, n_i$ and both digon arcs. For each clause create three vertices $c_(j,0), c_(j,1), c_(j,2)$ and the directed cycle in that order, then add $(c_(j,t), v(ell_(j,t)))$ for its three literal positions. Repeated literals keep separate occurrence vertices. An empty formula produces an empty graph, whose empty selection is a kernel. - _Correctness._ ($arrow.r.double$) Given a satisfying source assignment, choose its true literal in each digon. Let $t_r$ indicate whether the literal at clause position $r$ is selected, with missing positions set to false. Select clause vertex $r$ exactly when $not t_r and t_((r+1) mod 3)$. A selected vertex points to no selected literal, and its successor cannot be selected, so independence holds. An unselected vertex with $t_r$ true is absorbed through its literal arc. Otherwise both $t_r$ and $t_((r+1) mod 3)$ are false. Since the clause is satisfied, $t_((r+2) mod 3)$ is true; its cycle successor is therefore selected and absorbs it. Unselected literal vertices are absorbed by their digon partners. + _Correctness._ ($arrow.r.double$) Given a satisfying source assignment, choose its true literal in each digon. Let $t_r$ indicate whether the literal at clause position $r$ is selected. Select clause vertex $r$ exactly when $not t_r and t_((r+1) mod 3)$. A selected vertex points to no selected literal, and its successor cannot be selected, so independence holds. An unselected vertex with $t_r$ true is absorbed through its literal arc. Otherwise both $t_r$ and $t_((r+1) mod 3)$ are false. Since the clause is satisfied, $t_((r+2) mod 3)$ is true; its cycle successor is therefore selected and absorbs it. Unselected literal vertices are absorbed by their digon partners. - ($arrow.l.double$) Each literal vertex has only its digon partner as an outgoing neighbor. Independence and absorption force exactly one selected endpoint per variable. If a clause has no selected literal, absorption must occur entirely within its directed three-cycle. Independence permits at most one selected cycle vertex, which cannot absorb both others. This contradiction proves that every clause is satisfied, including the native short-clause semantics. This proof does not require distinct variables or a particular literal order. + ($arrow.l.double$) Each literal vertex has only its digon partner as an outgoing neighbor. Independence and absorption force exactly one selected endpoint per variable. If a clause has no selected literal, absorption must occur entirely within its directed three-cycle. Independence permits at most one selected cycle vertex, which cannot absorb both others. This contradiction proves that every clause is satisfied. This proof does not require distinct variables or a particular literal order. _Solution extraction._ Validate the target selection once using the formal target evaluator and reject $"Or(false)"$. Initialize a length-$n$ false vector and restore each original position $J_i$ from the selection of $p_i$. Unused variables remain false; selected clause vertices are ignored. The concrete value types are $"Or" arrow.r "Or"$. - _Bounds and native arithmetic._ Since $a <= L <= 3m$, the target has at most $9m$ vertices and $12m$ arcs. The registered relations use upper bounds in the declared source parameters, covering sparse variables and short clauses. Counts are checked before allocation, and every vertex index is bounded by the checked total. A successfully allocated native clause vector also bounds $m$ by its element layout and the address space; original sparse indices are never doubled. Allocation limits do not represent mathematical infeasibility. + _Bounds and native arithmetic._ Since $a <= L <= 3m$, the target has at most $9m$ vertices and $12m$ arcs. The registered relations use upper bounds in the declared source parameters, covering sparse variables. Counts are checked before allocation, and every vertex index is bounded by the checked total. A successfully allocated native clause vector also bounds $m$ by its element layout and the address space; original sparse indices are never doubled. Allocation limits do not represent mathematical infeasibility. ] @@ -19084,6 +19531,8 @@ The following table shows concrete target-variable counts for example instances, _Size bound._ There are $sum_(i=1)^n (p_i-2)+m$ forbidden pairs, where $p_i$ is the $i$th odd prime. For the ordinary $k$th prime $q_k$, the standard estimate $q_k < k(ln k+ln ln k)$ for $k >= 6$ @axler2019, together with the first five primes, implies $q_k <= 2k^2$ for every $k >= 1$. Consequently $p_i=q_(i+1) <= 2(n+1)^2$ and $2n(n+1)^2+m$ bounds the pair count. + Every clause modulus divides $product_(i=1)^n p_i$, and every variable prime occurs as a modulus, so the target period is $L = product_(i=1)^n p_i$. Since $n+1 <= 2^n$, we have $p_i <= 2(n+1)^2 <= 2^(2n+1)$ and hence `lcm_bits` $<= n(2n+1)+1$. For $n=0$, the empty product is 1 and its bit length is 1. Thus the reduction has polynomial parameter growth while exhaustive target solving still has an exponential bound. + _Correctness._ ($arrow.r.double$) A satisfying assignment $tau$ defines residues $r_i in {1,2}$ per variable. By CRT, some integer $x$ has these residues. It avoids all variable-forbidden classes and all clause-forbidden classes (since at least one literal is true, the residue triple differs from the falsifying triple). ($arrow.l.double$) Any feasible $x$ has $x mod p_i in {1,2}$ for all $i$. Define $tau(x_i) = "TRUE"$ if residue 1, FALSE if 2. If a clause were false, $x$ would match its forbidden CRT class -- contradiction. _Solution extraction._ Set $tau(x_i) = "TRUE"$ if $x mod p_i = 1$, FALSE if $x mod p_i = 2$. @@ -19512,6 +19961,8 @@ The following table shows concrete target-variable counts for example instances, _Correctness._ ($arrow.r.double$) A valid 3-partition fills each slot of width $B$ with exactly 3 elements; fillers are placed in their tight windows. ($arrow.l.double$) Fillers pinned to unit windows create $m$ slots of width $B$; total element work $m B$ fills all slots exactly; size constraints force 3 elements per slot. + _Size._ Every release time and deadline lies in $[0, m(B+1)-1]$, so no task has more than $m(B+1)$ start times. + _Solution extraction._ Decode the Lehmer code, simulate the schedule tracking start times; assign elements to groups by $floor("start" / (B+1))$. ] @@ -19588,15 +20039,13 @@ The following table shows concrete target-variable counts for example instances, *Multiplicity:* The fixture stores one canonical witness. The form is invariant under $f arrow.r -f$, so the complementary assignment $(-1, +1, -1, +1)$ (config $(0, 1, 0, 1)$) is equally optimal. ], )[ - @garey1979 (MS13) The construction takes $A$ to be the weighted adjacency matrix of $G$. The identity $sum_(i, j) a_(i j) f(i) f(j) = 2 W - 4 dot "cut"(S)$ — where $S = {i : f(i) = +1\}$ and $W = sum_(e in E) w(e)$ — converts MaxCut maximization into MinimumMatrixCover minimization with $O(n^2)$ overhead. + @garey1979 (MS13) The construction takes $A$ to be the weighted adjacency matrix of $G$. The identity $sum_(i, j) a_(i j) f(i) f(j) = 2 W - 4 dot "cut"(S)$ — where $S = {i : f(i) = +1\}$ and $W = sum_(e in E) w(e)$ — converts MaxCut maximization into MinimumMatrixCover minimization with $O(n^2)$ overhead for nonnegative weights. Each negative edge first becomes a two-edge gadget through one auxiliary vertex, giving $O((n + |E|)^2)$ overhead. ][ - _Construction._ Given a MaxCut instance $(G, w)$ with $G = (V, E)$, $|V| = n$, and nonnegative integer edge weights $w$, build the $n times n$ matrix $A$ with $a_(i j) = w(\{i, j\})$ for $\{i, j\} in E$, $a_(i j) = 0$ otherwise, and $a_(i i) = 0$. $A$ is symmetric, zero-diagonal, and nonnegative, hence a valid MinimumMatrixCover instance. - - _Correctness._ For any $f in \{-1, +1\}^n$ with $S = \{i : f(i) = +1\}$, each edge $\{i, j\} in E$ appears twice in $sum_(i, j) a_(i j) f(i) f(j)$. A non-cut edge contributes $+2 w(\{i, j\})$ (same signs, $f(i) f(j) = +1$); a cut edge contributes $-2 w(\{i, j\})$. Summing yields $sum_(i, j) a_(i j) f(i) f(j) = 2 (W - "cut"(S)) - 2 "cut"(S) = 2 W - 4 "cut"(S)$. Since $2 W$ is a constant, $min_f sum_(i, j) a_(i j) f(i) f(j) = 2 W - 4 max_S "cut"(S)$, so the two problems share the same optimal partitions. ($arrow.r.double$) A maximum cut $S^*$ defines $f^*$ minimizing the quadratic form. ($arrow.l.double$) A minimizing $f^*$ defines $S^* = \{i : f^*(i) = +1\}$ achieving the maximum cut. + _Construction._ Given a MaxCut instance $(G, w)$ with $G = (V, E)$ and $|V| = n$, first remove negative weights. Replace each edge $\{u, v\}$ with $w(\{u, v\}) < 0$ by a fresh vertex $t$ and edges $\{t, v\}$ of weight $|w| + 1$ and $\{t, u\}$ of weight $|w|$. The resulting graph $G'$ has $n' <= n + |E|$ vertices and nonnegative weights $w'$. Build the $n' times n'$ matrix $A$ with $a_(i j) = w'(\{i, j\})$ for edges of $G'$, $a_(i j) = 0$ otherwise, and $a_(i i) = 0$. $A$ is symmetric, zero-diagonal, and nonnegative, hence a valid MinimumMatrixCover instance. - _Precondition._ Nonnegative edge weights. Negative-weight MaxCut instances are out of scope; a separate weight-shifting reduction would be required. + _Correctness._ For any $f in \{-1, +1\}^n$ with $S = \{i : f(i) = +1\}$, each edge $\{i, j\} in E$ appears twice in $sum_(i, j) a_(i j) f(i) f(j)$. A non-cut edge contributes $+2 w(\{i, j\})$ (same signs, $f(i) f(j) = +1$); a cut edge contributes $-2 w(\{i, j\})$. Summing yields $sum_(i, j) a_(i j) f(i) f(j) = 2 (W - "cut"(S)) - 2 "cut"(S) = 2 W - 4 "cut"(S)$. Since $2 W$ is a constant, $min_f sum_(i, j) a_(i j) f(i) f(j) = 2 W - 4 max_S "cut"(S)$ on $G'$. Each gadget preserves maximum cuts: moving $t$ to the side opposite $v$ gains $|w| + 1$ and loses at most $|w|$, so every maximum cut of $G'$ separates $t$ from $v$. Then $\{t, u\}$ is cut exactly when $u$ and $v$ share a side, and $|w| dot [u, v "together"] = w dot [u, v "cut"] + |w|$. Each gadget therefore adds the constant $2|w| + 1$, and maximum cuts of $G'$ restrict to maximum cuts of $G$; every maximum cut of $G$ extends to one of $G'$. Hence the two problems share the same optimal partitions on $V$. ($arrow.r.double$) A maximum cut $S^*$ defines $f^*$ minimizing the quadratic form. ($arrow.l.double$) A minimizing $f^*$ defines $S^* = \{i : f^*(i) = +1\}$ achieving the maximum cut. - _Solution extraction._ Both encodings agree that $"config"[i] = 1$ means vertex $i in S$, so extraction is the identity. The complementary witness $f arrow.r -f$ is equally optimal because the quadratic form is invariant under this sign flip. + _Solution extraction._ Both encodings agree that $"config"[i] = 1$ means vertex $i in S$, so extraction keeps the original vertices' bits and drops the auxiliary ones. The complementary witness $f arrow.r -f$ is equally optimal because the quadratic form is invariant under this sign flip. ] // 14. HamiltonianPath → IsomorphicSpanningTree (#912) diff --git a/docs/paper/references.bib b/docs/paper/references.bib index a65104787..640774531 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -2332,3 +2332,14 @@ @article{axler2019 year = {2019}, url = {https://www.maths.tcd.ie/EMIS/journals/JIS/VOL22/Axler/axler17.pdf} } + +@article{een2006, + author = {Niklas E{\'e}n and Niklas S{\"o}rensson}, + title = {Translating Pseudo-Boolean Constraints into {SAT}}, + journal = {Journal on Satisfiability, Boolean Modeling and Computation}, + year = {2006}, + volume = {2}, + number = {1--4}, + pages = {1--26}, + doi = {10.3233/SAT190014}, +} diff --git a/docs/src/design.md b/docs/src/design.md index a8710a5a6..50f42e66e 100644 --- a/docs/src/design.md +++ b/docs/src/design.md @@ -541,12 +541,16 @@ Output: ProblemParameters { num_vars: 25 } ``` For multi-step paths, `compose_path_parameter_transform` substitutes each step into the next. -When only upper bounds are known for the intermediate fields, a downstream polynomial is -first fully expanded and like monomials are combined; terms with non-positive coefficients -are then removed before substitution. For example, `m <= n^2` followed by `k = 10 - m` -produces the sound bound `k <= 10`, while -`e' = v(v - 1)/2 - e` produces `e' <= v^2/2`. A non-polynomial downstream formula cannot -propagate symbolic upper bounds and makes that field unavailable, preserving the other fields. Projection to `Growth` is a separate descriptive terminal operation used for +The shared `ParameterTransform::compose` engine first expands downstream polynomials +and combines like monomials, so cancelled dependencies disappear. Its partial hull +removes a negative monomial only if it involves an upper-bounded intermediate field; +negative terms involving exclusively exact inputs are retained. For example, +`v = n`, `e <= n^2` followed by `e' = v(v - 1)/2 - e` produces +`e' <= n(n - 1)/2`, keeping the exact `-n/2` term. With `v <= n` instead, +that term is also dropped and the certificate is `e' <= n^2/2`. +Likewise, `m <= n^2` followed by `k = 10 - m` produces `k <= 10`. +A non-polynomial downstream formula cannot propagate symbolic upper bounds and +makes that field unavailable, preserving the other fields. Projection to `Growth` is a separate descriptive terminal operation used for Big-O display; it does not rank or filter paths. diff --git a/docs/src/introduction.md b/docs/src/introduction.md index 49570e0a3..ff990fd09 100644 --- a/docs/src/introduction.md +++ b/docs/src/introduction.md @@ -4,9 +4,12 @@ Problem Reductions is a Rust library and command-line tool for NP-hard problems {{#include generated/catalog-counts.md}} -- [Atlas](index.html#atlas): every problem variant and reduction, with schemas and overheads -- [Paper](reductions.pdf): definitions, constructions, and proofs -- [Rust API](api/problemreductions/index.html): generated from source +- [**Command line** install `pred`, then show, path, reduce and solve](cli.md) +- [**Agent skills** workflows for proposing, implementing and reviewing rules](skills.md) +- [**Rust library** models, reductions and solvers from code](getting-started.md) +- [**Atlas** every variant and reduction, with schemas and overheads](index.html#atlas) +- [**Paper** definitions, constructions, and proofs](reductions.pdf) +- [**Rust API** generated from source](api/problemreductions/index.html) This guide covers the `pred` CLI, the agent skills shipped with the repository, and the Rust library. Every page has a **Markdown** link for reading without a browser; the [Markdown index](markdown/index.md) lists all pages. diff --git a/docs/src/static/docs-theme.css b/docs/src/static/docs-theme.css index 05d4adfa9..6d9ac784d 100644 --- a/docs/src/static/docs-theme.css +++ b/docs/src/static/docs-theme.css @@ -1,15 +1,8 @@ @font-face { font-family: "DM Sans"; - src: url("../../../assets/fonts/dm-sans-regular.ttf") format("truetype"); + src: url("../../../assets/fonts/dm-sans-variable.woff2") format("woff2"); font-style: normal; - font-weight: 400; - font-display: swap; -} -@font-face { - font-family: "DM Sans"; - src: url("../../../assets/fonts/dm-sans-semibold.ttf") format("truetype"); - font-style: normal; - font-weight: 600 800; + font-weight: 400 600; font-display: swap; } html[class] { @@ -87,7 +80,7 @@ body { .chapter li a { display: block; padding: 7px 10px; - border-radius: 5px; + border-radius: 10px; color: var(--sidebar-fg); } .chapter li a:hover { @@ -198,7 +191,7 @@ body { } .content code { font-size: 13px; - border-radius: 3px; + border-radius: 6px; } .content :not(pre) > code { background: #213025; @@ -208,7 +201,7 @@ body { .content pre { margin: 20px 0; border: 1px solid #334437; - border-radius: 8px; + border-radius: 14px; background: #162019; } .content pre > code { @@ -226,7 +219,7 @@ body { .content pre .buttons button { background: #233429; color: #d3dfcf; - border-radius: 4px; + border-radius: 8px; padding: 7px; } .content table { @@ -254,11 +247,11 @@ body { .content blockquote { margin: 20px 0; padding: 8px 18px; - border-radius: 0 6px 6px 0; + border-radius: 0 10px 10px 0; } .content details { border: 1px solid #304034; - border-radius: 6px; + border-radius: 10px; padding: 12px 16px; margin: 18px 0; } @@ -306,13 +299,13 @@ body { width: 100%; height: 490px; border: 1px solid #334437; - border-radius: 8px; + border-radius: 14px; background: #0d0f12; margin: 24px 0 16px; } :is(#searchbar, #mdbook-searchbar) { padding: 13px 16px; - border-radius: 6px; + border-radius: 10px; } :is(#search-wrapper, #mdbook-search-wrapper) { margin: 25px 0 0; @@ -365,3 +358,44 @@ body { display: none; } } + +.docs-tools a[href$="graph.html"], +.docs-tools a[href$="#atlas"], +.docs-tools a[href$="#home"] { + color: var(--fg); +} +ul.start-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + gap: 12px; + margin: 22px 0 8px; + padding: 0; + list-style: none; +} +.start-cards li { + margin: 0; +} +.content .start-cards a { + display: block; + height: 100%; + box-sizing: border-box; + padding: 14px 16px; + border: 1px solid var(--table-border-color); + border-radius: 14px; + background: var(--quote-bg); + color: var(--sidebar-fg); + font-size: 13px; + line-height: 1.5; + text-decoration: none; +} +.content .start-cards a:hover { + border-color: var(--links); +} +.start-cards a strong { + display: block; + color: var(--links); + font-size: 15px; +} +.content main > p:first-of-type { + max-width: 62ch; +} diff --git a/docs/src/static/docs-theme.js b/docs/src/static/docs-theme.js index d984fc959..a6b9762f0 100644 --- a/docs/src/static/docs-theme.js +++ b/docs/src/static/docs-theme.js @@ -37,15 +37,19 @@ resize(); }); } + if (page === "introduction.html" || page === "index.html") + document.querySelector(".content main > ul")?.classList.add("start-cards"); const menu = document.querySelector(".menu-bar .right-buttons"); if (menu && page !== "print.html") { const nav = document.createElement("nav"); nav.className = "docs-tools"; nav.setAttribute("aria-label", "Documentation resources"); const links = [ - ["Home ↗", root + "index.html#home"], + ["Home", root + "index.html#home"], + ["Graph", root + "graph.html"], + ["Atlas", root + "index.html#atlas"], + ["Paper ↗", root + "reductions.pdf"], ["GitHub ↗", "https://github.com/CodingThrust/problem-reductions"], - ["Atlas ↗", root + "index.html#atlas"], ]; for (const [label, href] of links) { const link = document.createElement("a"); diff --git a/docs/website/README.md b/docs/website/README.md index 164cdb213..cac875fc2 100644 --- a/docs/website/README.md +++ b/docs/website/README.md @@ -1,186 +1,38 @@ -# Research website +# Website -The public entry point presents Problem Reductions as infrastructure for autonomous -research. It includes a searchable problem atlas, pages for every registered -problem variant and reduction, and an interactive vertex-cover / independent-set -example. Current implementation capabilities and future research ambitions are -explicitly distinguished. +Home/Atlas and the reduction graph use the Rust registry, WASM engine and Typst +articles. Page code lives in `src/pages/`, the engine worker in `src/workers/`, +and styles in `src/styles/`. Static data is validated during the build; generated +TypeScript types describe the data consumed by the browser. -The standalone `graph.html` is the primary visual explorer: the graph owns the -workspace between a problem/rule browser and an always-visible detail panel. -The browser lists registered families, expandable variants, and exact directed -rules; its search and mode switch leave the graph viewport unchanged. Selecting -an item highlights its graph counterpart. -Drag the divider to resize the -panel, or focus it and use the arrow keys (Home/End select the width limits). -On narrow screens the panel sits below the graph. -The fCoSE overview is computed at build time and published with the graph data, -so reloads retain the same positions. Expanding variants relaxes the visible local -neighborhood; collapsing restores the overview. Labels -are culled by available screen space, with full variant labels reserved in layout. - -Details compile all problem definitions and reduction rules directly from -`docs/paper/reductions.typ`, including their examples, footnotes and references. -Graph's inspector and Atlas problem/reduction pages use the same renderer and -article files. The inspector's “Open in Atlas” link retains exact variant -endpoints; family-level selections open the corresponding default record. -Selection stays at exact variant granularity: variant-specific content wins; -otherwise the parent problem or directed problem-pair content is used by default. -If neither exists, the panel explicitly reports missing documentation. -No web prose is maintained separately. `problem-def` accepts `variant: (...)`; -`reduction-rule` accepts `source-variant: (...)` and `target-variant: (...)` for -specialized content. Omit these to publish shared family content. - -`scripts/build_graph_details.py` compiles once with Typst 0.15.1 and extracts -each article into the release's `assets/details/` directory. The URL index and -articles load on selection; the 24 most recently used articles are cached. References load -when expanded. Repeated SVG glyph definitions are shared within each article. -Generated content is not copied into the website source directory or committed. -Text reflows in the panel; equations and figures use -Typst's SVG rendering to preserve mathematical notation. The existing PDF build -uses the same source and its original print presentation. HTML export remains -experimental, so the build fails on export errors. - -## Build and preview - -Use mdBook 0.5.2 and Typst 0.15.1, matching the deployment workflow. +Build and serve locally: ```sh make website -python3 -m http.server 3001 --bind 127.0.0.1 --directory book +python3 -m http.server 3001 --directory book ``` -Open . `make doc` also builds Rust API documentation. -`make website`, `make doc`, and `make mdbook` share the `paper-data` prerequisite, -which exports the `docs/paper/data/examples.json` that the Typst source reads. -The deployment workflow builds the PDF and API and combines everything in `book/`. -The fast `make website` preview includes the guide; API/PDF links require those -additional builds, as in deployment. - -Website assets are published under `book/releases//assets/`. -The builder rewrites their URLs together, so old open pages cannot mix new -documentation with old registry data. If an old release is no longer available, -reload the page. Do not overwrite files inside an existing release directory. -The homepage loads a small registry summary; full Atlas data and the graph's -local-layout library load only when needed. - -Finalization runs after PDF/API assembly. It externalizes generated inline -scripts and applies a same-origin CSP (inline styles remain allowed for graph -and Typst layout). Typst articles containing active elements, event handlers, -or unsafe URLs fail the build. Deployment runs browser and build-security checks -before upload; only the deploy job has Pages/OIDC write permissions. - -For CSS/JavaScript iteration after the registry exports have been generated: +Frontend checks: ```sh -python3 scripts/build_website.py +npm ci +npm run typecheck +npm run lint:frontend +npm run test:frontend-contracts ``` -The builder overlays the website's `index.html` and `assets/` onto mdBook's output. -Existing documentation URLs, `introduction.html`, the paper, and Rust API paths -stay available; the retired `reduction-graph.html` and `open-problems.html` redirect -to their website counterparts through `[output.html.redirect]` in `book.toml`. -Running `mdbook build` by itself restores mdBook's homepage; run -the website builder afterward. The GitHub Pages workflow does this automatically. - -## Design and data - -- Deep forest surfaces, warm ivory text, and pale lime form the dark visual palette. -- The header, footer, and favicon use the original `docs/logo.svg` artwork, recolored - at build time to match the dark palette. The canonical source logo stays unchanged. -- Self-hosted DM Sans and Instrument Serif include their OFL licenses in `assets/fonts/`. -- The graph is a generated SVG based on actual registry edges. Its homepage view - groups variants by problem family and does not imply that arbitrary paths compose. -- Detail pages use exact variants. URLs encode problem names and variant keys, - rather than transient registry indices. -- Model and rule “Open implementation” links follow GitHub `main`, not a pinned - commit. Source paths come from registry metadata and are checked against files - during the build; renamed files require a website rebuild and deployment. -- Counts, schemas, capabilities, and overhead expressions come from the same - generated JSON used by the documentation and paper. Nothing is manually counted. -- The five-vertex example is illustrative; research activity, novelty, and live test - results are never fabricated. A reduction's registration does not imply formal proof. -- Navigation, filters, search, clipboard actions, and the example are keyboard - accessible. Layouts adapt to mobile; motion respects reduced-motion preferences. -- No frontend framework or external font request is required. The graph uses - self-hosted Cytoscape and fCoSE; dependency licenses are copied beside the scripts. - -The static website does not yet provide agent execution, an experiment database, -or live research telemetry. The research section describes the intended loop and -links to the existing agent workflows. - -## Browser checks - -Build the documentation with `make doc` so API link checks have their targets, -then build the PDF and run these commands with the preview server running: - -```sh -typst compile --root . docs/paper/reductions.typ book/reductions.pdf -uv run --no-project --with playwright==1.58.0 python -m playwright install chromium -uv run --no-project --with playwright==1.58.0 python scripts/test_website.py -``` - -Set `WEBSITE_BROWSER_CHANNEL=chrome` to use an installed Chrome instead, or -`WEBSITE_BASE_URL=http://localhost:3001/` to change the preview address. The suite -exercises search, filters, exact variants, deep links, keyboard navigation, -clipboard actions, the mathematical example, legacy docs, and mobile overflow. - -## Documentation - -The homepage and graph's **Open questions** link opens `open-questions/`. -The board is generated from the registry and shared templates in -[`GiggleLiu/autoresearch-gadgets`](https://github.com/GiggleLiu/autoresearch-gadgets), -then included in the same Pages artifact. Legacy `#open-questions` and -`open-problems.html` links redirect to the board. - -The deployment workflow reads that private repository's `main` branch with the -existing `PROJECT_READ_TOKEN` secret. That token needs **Contents: read** access -to `GiggleLiu/autoresearch-gadgets`; the default `GITHUB_TOKEN` cannot read another -private repository. Only the generated `website/dist/` is published. Registry -changes appear on the next documentation deployment; use **Run workflow** on -`main` to refresh the board without changing this repository. Feature-branch -runs build and verify the artifact without deploying it. - -To include the board in a local preview, clone its source beside this repository, -build it with its committed lockfile, and run the website overlay after mdBook: - -```sh -uv run --project ../autoresearch-gadgets/website --locked python ../autoresearch-gadgets/website/build.py -python3 scripts/build_website.py --open-questions ../autoresearch-gadgets/website/dist -``` - -Include the board when running the browser suite. Without `--open-questions`, -the builder previews the main website and guide only. - -The guide has seven pages listed in `docs/src/SUMMARY.md`: an overview, the CLI -(quick start, command reference), agent skills, the Rust library -(getting started, API, design). Keep one example per -concept and link to `pred --help`, rustdoc, or `.claude/CLAUDE.md` instead of -restating them. - -`docs/src/static/docs-theme.css` supplies the restrained dark reading theme; -`docs-theme.js` adds the GitHub and atlas links. Native mdBook search, code copying, and -sidebar navigation remain available. Both mdBook 0.4.37 and 0.5.2 (deployment) -are supported. - -The website builder emits `book/markdown/` from the same source pages, expanding -Rust and generated-output includes. The Markdown index is the entry point for -agents. - -## CLI recording - -`docs/src/static/cli-demo.cast` contains real PTY output from six successful -commands against the current CLI. `cli-demo.html` embeds a copy of that cast -and asciinema-player 3.8.0 for offline playback, with autoplay disabled; the -recording script refreshes both files. The bundled -player is Apache-2.0 licensed; its license is alongside the recording. +Types and Rust schemas are regenerated automatically by the checks and website +build into the ignored `src/generated/` directory. The five static-data schemas +are maintained here; build-only validation runs in `scripts/website_contracts.mjs`. -To refresh the recording after a CLI change, build the CLI and run: +For a fresh production build tested in Chromium, Firefox and WebKit: ```sh -python3 scripts/record_cli_demo.py --pred target/debug/pred +uv run --python 3.12.14 --no-project --with-requirements scripts/website-requirements.txt python -m playwright install chromium firefox webkit +uv run --python 3.12.14 --no-project --with-requirements scripts/website-requirements.txt python scripts/check_website.py ``` -The script runs the commands listed in `docs/src/cli.md` in a temporary -directory, simulates typing, and fails if any command exits non-zero. Never -substitute a simulated successful run. +The verifier prints its temporary output directory. Use `--output /tmp/new-site` +to choose one, or `--site /path/to/site` to test an existing build. The optional +Open Question Board is supplied with `--open-questions /path/to/board`. +Deploy the complete generated site so HTML, data, scripts and WASM stay together. diff --git a/docs/website/assets/details.js b/docs/website/assets/details.js deleted file mode 100644 index e6539b06a..000000000 --- a/docs/website/assets/details.js +++ /dev/null @@ -1,161 +0,0 @@ -(() => { - "use strict"; - const detailCache = new Map(); - let manifest; - let renderRequest = 0; - function loadDetail(key) { - const url = window.GRAPH_DETAILS.entries[key]; - if (!url) return Promise.reject(new Error(`Missing detail file: ${key}`)); - if (!detailCache.has(url)) { - detailCache.set(url, fetch(url).then((response) => { - if (!response.ok) throw new Error(`Cannot load documentation (${response.status})`); - return response.text(); - }).catch((error) => { - detailCache.delete(url); - throw error; - })); - if (detailCache.size > 24) detailCache.delete(detailCache.keys().next().value); - } else { - const cached = detailCache.get(url); - detailCache.delete(url); - detailCache.set(url, cached); - } - return detailCache.get(url); - } - - window.renderDetails = async function(detail, title, variant, key, parentKey, onProblem) { - const request = ++renderRequest; - detail.replaceChildren(); - detail.onclick = (event) => { - const link = event.target.closest('a[href^="#"]'); - if (!link) return; - const anchor = link.hash.slice(1); - const key = window.GRAPH_DETAILS.anchors[anchor]; - if (key?.startsWith("problem:") && key !== detail.querySelector("article")?.dataset.contentKey) { - event.preventDefault(); - onProblem(key.slice("problem:".length)); - return; - } - if (link.getAttribute("role") === "doc-biblioref") { - event.preventDefault(); - const references = detail.querySelector(".detail-references"); - references.open = true; - references.scrollIntoView({ block: "nearest" }); - return; - } - const target = detail.querySelector(`[id="${CSS.escape(link.hash.slice(1))}"]`); - if (!target) { - if (key === detail.querySelector("article")?.dataset.contentKey) { - event.preventDefault(); - detail.scrollIntoView({ block: "start" }); - } - return; - } - event.preventDefault(); - const references = target.closest("details"); - if (references) references.open = true; - target.scrollIntoView({ block: "nearest" }); - }; - - if (title) { - const heading = document.createElement("h2"); - heading.textContent = title; - detail.append(heading); - } - if (variant) { - const parameters = document.createElement("p"); - parameters.className = "detail-variant"; - parameters.textContent = variant; - detail.append(parameters); - } - const article = document.createElement("article"); - article.className = "typst-detail"; - article.textContent = "Loading documentation…"; - detail.append(article); - if (!window.GRAPH_DETAILS) { - try { - manifest ||= fetch("./assets/graph-details.json").then(response => { - if (!response.ok) throw new Error("Cannot load documentation index. Reload the page to get the current release."); - return response.json(); - }); - window.GRAPH_DETAILS = await manifest; - } catch (error) { - manifest = null; - if (request === renderRequest) { - article.textContent = error.message; - article.setAttribute("role", "alert"); - } - return; - } - } - if (request !== renderRequest || !article.isConnected) return; - const contentKey = window.GRAPH_DETAILS.entries[key] ? key : parentKey; - const available = Boolean(window.GRAPH_DETAILS.entries[contentKey]); - if (!available) { - const note = document.createElement("p"); - note.className = "detail-source"; - note.textContent = "No documentation is available for this entry or its parent family."; - detail.append(note); - } - article.dataset.contentKey = contentKey; - if (available) { - article.textContent = "Loading documentation…"; - article.setAttribute("aria-busy", "true"); - } - if (!available) { article.remove(); return; } - try { - const content = await loadDetail(contentKey); - if (!article.isConnected) return; - // HTML is generated at build time from the repository's Typst source. - article.innerHTML = content; - const citations = new Set([...article.querySelectorAll('a[role="doc-biblioref"]')] - .map((link) => link.hash.slice(1))); - if (citations.size) { - const references = document.createElement("details"); - references.className = "detail-references"; - references.innerHTML = "References
"; - article.after(references); - const body = references.lastElementChild; - let loaded = false; - references.addEventListener("toggle", async () => { - if (!references.open || loaded) return; - body.textContent = "Loading references…"; - try { - body.innerHTML = await loadDetail("references"); - body.querySelectorAll("li").forEach((item) => { - if (!citations.has(item.id)) item.remove(); - }); - loaded = true; - } catch (error) { - body.textContent = error.message; - body.setAttribute("role", "alert"); - } - }); - } - if (article.querySelector('[role="doc-noteref"] a')) { - const notes = document.createElement("aside"); - notes.className = "typst-detail detail-footnotes"; - try { - notes.innerHTML = await loadDetail("footnotes"); - const used = new Set([...article.querySelectorAll('[role="doc-noteref"] a')].map((link) => link.hash.slice(1))); - notes.querySelectorAll('li').forEach((item) => { - if (!used.has(item.id)) item.remove(); - }); - } catch (error) { - // The article is already rendered; only its footnotes degrade. - notes.textContent = error.message; - notes.setAttribute("role", "alert"); - } - if (!article.isConnected) return; - article.after(notes); - } - } catch (error) { - if (!article.isConnected) return; - article.textContent = error.message; - article.setAttribute("role", "alert"); - } finally { - article.removeAttribute("aria-busy"); - } - } - -})(); diff --git a/docs/website/assets/fonts/DM-Sans-OFL.txt b/docs/website/assets/fonts/DM-Sans-OFL.txt deleted file mode 100644 index a0eedfbf0..000000000 --- a/docs/website/assets/fonts/DM-Sans-OFL.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts) - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -https://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/docs/website/assets/fonts/Instrument-Serif-OFL.txt b/docs/website/assets/fonts/OFL.txt similarity index 91% rename from docs/website/assets/fonts/Instrument-Serif-OFL.txt rename to docs/website/assets/fonts/OFL.txt index e1801b959..8cf401f79 100644 --- a/docs/website/assets/fonts/Instrument-Serif-OFL.txt +++ b/docs/website/assets/fonts/OFL.txt @@ -1,9 +1,10 @@ +Copyright 2020 The DM Mono Project Authors (https://www.github.com/googlefonts/dm-mono) +Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts) Copyright 2022 The Instrument Serif Project Authors (https://github.com/Instrument/instrument-serif) -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -https://scripts.sil.org/OFL - +These fonts (DM Mono, DM Sans and Instrument Serif) are each licensed under the +SIL Open Font License, Version 1.1. The license is copied below, and is also +available with a FAQ at: https://openfontlicense.org ----------------------------------------------------------- SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 diff --git a/docs/website/assets/fonts/dm-mono-medium.woff2 b/docs/website/assets/fonts/dm-mono-medium.woff2 new file mode 100644 index 000000000..48879e4e9 Binary files /dev/null and b/docs/website/assets/fonts/dm-mono-medium.woff2 differ diff --git a/docs/website/assets/fonts/dm-mono-regular.woff2 b/docs/website/assets/fonts/dm-mono-regular.woff2 new file mode 100644 index 000000000..787b808fd Binary files /dev/null and b/docs/website/assets/fonts/dm-mono-regular.woff2 differ diff --git a/docs/website/assets/fonts/dm-sans-regular.ttf b/docs/website/assets/fonts/dm-sans-regular.ttf deleted file mode 100644 index 6c789eadb..000000000 Binary files a/docs/website/assets/fonts/dm-sans-regular.ttf and /dev/null differ diff --git a/docs/website/assets/fonts/dm-sans-semibold.ttf b/docs/website/assets/fonts/dm-sans-semibold.ttf deleted file mode 100644 index ec5dcf3e2..000000000 Binary files a/docs/website/assets/fonts/dm-sans-semibold.ttf and /dev/null differ diff --git a/docs/website/assets/fonts/dm-sans-variable.woff2 b/docs/website/assets/fonts/dm-sans-variable.woff2 new file mode 100644 index 000000000..8f8cb5508 Binary files /dev/null and b/docs/website/assets/fonts/dm-sans-variable.woff2 differ diff --git a/docs/website/assets/fonts/instrument-serif-italic.ttf b/docs/website/assets/fonts/instrument-serif-italic.ttf deleted file mode 100644 index 25d2a8219..000000000 Binary files a/docs/website/assets/fonts/instrument-serif-italic.ttf and /dev/null differ diff --git a/docs/website/assets/fonts/instrument-serif-italic.woff2 b/docs/website/assets/fonts/instrument-serif-italic.woff2 new file mode 100644 index 000000000..a49ad2bd9 Binary files /dev/null and b/docs/website/assets/fonts/instrument-serif-italic.woff2 differ diff --git a/docs/website/assets/fonts/instrument-serif.ttf b/docs/website/assets/fonts/instrument-serif.ttf deleted file mode 100644 index 8794c6950..000000000 Binary files a/docs/website/assets/fonts/instrument-serif.ttf and /dev/null differ diff --git a/docs/website/assets/fonts/instrument-serif.woff2 b/docs/website/assets/fonts/instrument-serif.woff2 new file mode 100644 index 000000000..d5315b531 Binary files /dev/null and b/docs/website/assets/fonts/instrument-serif.woff2 differ diff --git a/docs/website/assets/graph-layout.js b/docs/website/assets/graph-layout.js deleted file mode 100644 index c0bde6341..000000000 --- a/docs/website/assets/graph-layout.js +++ /dev/null @@ -1,13 +0,0 @@ -const reductionLayoutOptions = { - name: "fcose", quality: "proof", randomize: false, - animate: false, fit: false, packComponents: false, tile: true, - nodeDimensionsIncludeLabels: true, - nodeRepulsion: () => 6500, - idealEdgeLength: (edge) => edge.source().isChild() || edge.target().isChild() ? 200 : 95, - edgeElasticity: (edge) => 0.45 * Math.sqrt(Math.max( - edge.source().degree(), edge.target().degree(), - )), - gravity: 0.12, numIter: 1200, -}; - -if (typeof module !== "undefined") module.exports = reductionLayoutOptions; diff --git a/docs/website/assets/graph.css b/docs/website/assets/graph.css deleted file mode 100644 index 952bb59d9..000000000 --- a/docs/website/assets/graph.css +++ /dev/null @@ -1,352 +0,0 @@ -.graph-page { - height: 100vh; - overflow: hidden; -} - -.graph-page .site-header { - position: relative; -} - -.graph-header { - width: calc(100% - 40px); - max-width: none; - height: 70px; -} - -.graph-header .project-logo { - width: 215px; -} - -.graph-header nav { - flex-shrink: 0; -} - -.graph-search { - width: min(560px, 42vw); - margin-left: auto; -} - -#reduction-graph { - height: calc(100vh - 70px); - min-height: 620px; - display: grid; - grid-template-rows: 48px minmax(0, 1fr); - background: #0d1410; -} - -#reduction-graph:focus { - outline: none; -} - -.graph-toolbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 24px; - padding: 0 20px; - border-bottom: 1px solid var(--line); - background: #111a15; - font-size: 14px; -} - -.graph-toolbar > div:first-child { - display: flex; - align-items: baseline; - gap: 14px; -} - -.graph-toolbar strong { - color: var(--ink); - font-weight: 600; -} - -.graph-toolbar span { - color: #718075; -} - -.graph-view-controls { - display: flex; - align-self: stretch; -} - -.graph-view-controls button { - padding: 0 14px; - color: var(--muted); - background: transparent; - border: 0; - border-left: 1px solid var(--line); - font-size: 14px; -} - -.graph-view-controls button[aria-pressed="true"] { - color: var(--green); - background: #17231b; -} - -.graph-view-controls button:hover { - color: var(--ink); -} - -.graph-workspace { - min-height: 0; - display: grid; - grid-template-columns: 260px minmax(0, 1fr) 8px clamp(240px, var(--inspector-width, 320px), calc((100% - 260px) * 0.6)); -} - -.graph-browser { - min-height: 0; - display: flex; - flex-direction: column; - background: #111a15; - border-right: 1px solid var(--line); -} -.browser-modes { display: flex; padding: 14px 14px 10px; gap: 6px; } -.browser-modes button { - flex: 1; padding: 9px; border: 1px solid var(--line); border-radius: 5px; - background: transparent; color: var(--muted); font-size: 14px; -} -.browser-modes button[aria-pressed="true"] { background: #273622; color: var(--green); } -#browser-search { - min-width: 0; margin: 0 14px; padding: 10px; border: 1px solid var(--line); - border-radius: 4px; background: #0d1410; color: var(--ink); font-size: 15px; -} -#browser-count { margin: 12px 16px; color: var(--muted); font-size: 13px; } -#browser-list { min-height: 0; flex: 1; overflow-y: auto; } -.browser-item { - display: block; width: 100%; padding: 12px 16px; border: 0; - border-bottom: 1px solid var(--line); background: transparent; - color: var(--ink); text-align: left; font-size: 15px; overflow-wrap: anywhere; -} -.browser-item small { display: block; margin-top: 5px; color: var(--muted); font-size: 13px; line-height: 1.6; } -.browser-item:hover { background: #1b281f; } -.browser-item[aria-current="true"] { background: #35492c; color: #f0f6e9; box-shadow: inset 4px 0 #c4dda6; } -.browser-item[aria-current="true"] small { color: #c5d3bc; } -.browser-item.variant-item { padding-left: 28px; color: #c4dda6; } -.browser-empty { padding: 16px; color: var(--muted); font-size: 13px; } - -.graph-inspector { - min-width: 0; - overflow-y: auto; - background: #111a15; -} - -.graph-divider { - cursor: col-resize; - touch-action: none; - background: #111a15; - border-inline: 1px solid var(--line); -} - -.graph-divider:hover, -.graph-divider:focus-visible, -.graph-divider.dragging { - background: #748d67; - outline: none; -} - -.inspector-heading { - height: 56px; - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 18px; - border-bottom: 1px solid var(--line); -} - -.inspector-heading span { - color: #87958a; - font-size: 14px; -} - -.graph-detail { padding: 22px 20px 28px; font-size: 16px; line-height: 1.7; } -.graph-detail:empty { padding: 0; } -.graph-detail h2 { margin: 0 0 10px; font-size: 21px; line-height: 1.35; font-weight: 500; overflow-wrap: anywhere; } - -.graph-canvas { - min-width: 0; - min-height: 0; - position: relative; - overflow: hidden; - background-color: #0d1410; - background-image: radial-gradient(#243329 0.7px, transparent 0.7px); - background-size: 19px 19px; -} - -#cy { - width: 100%; - height: 100%; -} -#cy[aria-busy="true"] { visibility: hidden; } - -.graph-legend { - position: absolute; - left: 18px; - max-width: calc(100% - 36px); - bottom: 17px; - display: flex; - flex-wrap: wrap; - pointer-events: none; - gap: 13px; - padding: 9px 12px; - color: #91a095; - background: rgba(13, 20, 16, 0.9); - border: 1px solid var(--line); - border-radius: 5px; - backdrop-filter: blur(10px); - font-size: 13px; -} - -.graph-legend span { - display: flex; - align-items: center; - gap: 5px; -} - -.graph-legend i { - width: 7px; - height: 7px; - border-radius: 50%; - background: var(--category-color); -} - -.category-graph { - --category-color: #b9d99b; -} -.category-formula { - --category-color: #9bbfc0; -} -.category-set { - --category-color: #d1a78d; -} -.category-algebraic { - --category-color: #d8c87e; -} -.category-misc { - --category-color: #bd9dbc; -} - -.graph-canvas-note { - position: absolute; - top: 18px; - right: 18px; - max-width: calc(100% - 36px); - margin: 0; - text-align: right; - color: #68766c; - font-size: 13px; - pointer-events: none; -} - -.graph-detail { - padding: 24px 22px 34px; -} - -.graph-tooltip { - max-width: 260px; - display: none; - position: fixed; - z-index: 50; - padding: 8px 10px; - color: var(--ink); - background: rgba(21, 32, 25, 0.96); - border: 1px solid #465b49; - border-radius: 4px; - box-shadow: 0 12px 35px rgba(0, 0, 0, 0.3); - font-size: 14px; - pointer-events: none; -} - -@media (max-width: 1100px) { - .graph-header nav .docs-nav { - display: none; - } - .graph-canvas-note { - display: none; - } -} - -@media (max-width: 800px) { - .graph-page { - height: auto; - overflow: auto; - } - .graph-header { - min-height: 118px; - } - .graph-header .graph-search { - width: calc(100% - 230px); - order: 2; - } - .graph-header nav { - order: 3; - } - #reduction-graph { - height: auto; - min-height: 0; - } - .graph-toolbar { - min-height: 62px; - padding-block: 8px; - } - .graph-toolbar > div:first-child span { - display: none; - } - .graph-workspace { - grid-template-columns: 1fr; - grid-template-rows: 280px 62vh auto; - } - .graph-browser { grid-row: 1; border-right: 0; border-bottom: 1px solid var(--line); } - .graph-divider { - display: none; - } - .graph-canvas { - grid-row: 2; - } - .graph-inspector { - grid-row: 3; - min-height: 180px; - border-left: 0; - border-top: 1px solid var(--line); - } -} - -@media (max-width: 520px) { - .graph-header .project-logo { - width: 185px; - } - .graph-header .graph-search { - width: 100%; - order: 3; - } - .graph-header nav { - order: 2; - width: auto; - gap: 16px; - } - .graph-header nav .docs-nav { - display: none; - } - .graph-toolbar { - align-items: flex-start; - flex-direction: column; - gap: 7px; - } - #reduction-graph { - grid-template-rows: auto minmax(0, 1fr); - } - .graph-view-controls { - width: 100%; - min-height: 38px; - } - .graph-view-controls button { - flex: 1; - padding: 0 6px; - border: 1px solid var(--line); - } - .graph-legend { - right: 12px; - left: 12px; - max-width: none; - justify-content: center; - } -} diff --git a/docs/website/assets/graph.js b/docs/website/assets/graph.js deleted file mode 100644 index ba6b5ec2d..000000000 --- a/docs/website/assets/graph.js +++ /dev/null @@ -1,853 +0,0 @@ -(async () => { - "use strict"; - - try { - const data = window.REDUCTIONS; - if (!data) throw new Error("Cannot load graph data. Reload the page to get the current release."); - const schemas = new Map(data.schemas.map((schema) => [schema.name, schema])); - const aliases = new Map([ - ["MIS", "MaximumIndependentSet"], - ["MVC", "MinimumVertexCover"], - ["SAT", "Satisfiability"], - ["3SAT", "KSatisfiability"], - ["3-SAT", "KSatisfiability"], - ]); - const colors = { - graph: "#b9d99b", - formula: "#9bbfc0", - set: "#d1a78d", - algebraic: "#d8c87e", - misc: "#bd9dbc", - }; - const families = new Map(); - - data.nodes.forEach((node) => { - const family = families.get(node.name) || { - name: node.name, - category: node.category, - variants: [], - incoming: new Set(), - outgoing: new Set(), - rules: 0, - }; - family.variants.push(node); - families.set(node.name, family); - }); - - const connections = new Map(); - data.edges.forEach((edge) => { - const source = data.nodes[edge.source].name; - const target = data.nodes[edge.target].name; - if (source === target) return; - const key = `${source}\u0000${target}`; - const connection = connections.get(key) || { source, target, count: 0 }; - connection.count += 1; - connections.set(key, connection); - families.get(source).outgoing.add(target); - families.get(target).incoming.add(source); - families.get(source).rules += 1; - families.get(target).rules += 1; - }); - - const displayName = (name) => - schemas.get(name)?.display_name || - name - .replace(/([a-z0-9])([A-Z])/g, "$1 $2") - .replace(/([A-Z])([A-Z][a-z])/g, "$1 $2"); - - const variantKey = (variant) => - Object.entries(variant) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, value]) => `${key}=${value}`) - .join(","); - - const search = document.querySelector("#graph-search"); - const inspector = document.querySelector(".graph-inspector"); - const selectionType = document.querySelector("#selection-type"); - const detail = document.querySelector("#graph-detail"); - const tooltip = document.querySelector("#graph-tooltip"); - let scope = "core"; - - const elements = []; - families.forEach((family) => { - const neighbors = new Set([...family.incoming, ...family.outgoing]); - const position = data.layout[family.name]; - if (!position) throw new Error(`Missing graph layout position: ${family.name}`); - elements.push({ - data: { - id: family.name, - label: displayName(family.name), - category: family.category, - rules: family.rules, - peripheral: neighbors.size < 2, - }, - position: { x: position.x * 1.15, y: position.y * 1.15 }, - }); - }); - connections.forEach((connection) => - elements.push({ - data: { - id: `edge:${connection.source}:${connection.target}`, - source: connection.source, - target: connection.target, - count: connection.count, - }, - }), - ); - - const cy = cytoscape({ - container: document.querySelector("#cy"), - elements: [], - layout: { name: "preset" }, - minZoom: 0.08, - maxZoom: 3, - wheelSensitivity: 0.22, - boxSelectionEnabled: false, - style: [ - { - selector: "node", - style: { - width: (node) => Math.min(22, 10 + Math.log2(1 + node.data("rules")) * 1.5), - height: (node) => Math.min(22, 10 + Math.log2(1 + node.data("rules")) * 1.5), - shape: "ellipse", - "background-color": (node) => - colors[node.data("category")] || colors.misc, - "border-color": "#0d1410", - "border-width": 2, - label: "", - color: "#b9c4bb", - "font-family": "DM Sans, sans-serif", - "font-size": 12, - "font-weight": 400, - "min-zoomed-font-size": 7, - "text-valign": "bottom", - "text-margin-y": 8, - "text-background-color": "#0d1410", - "text-background-opacity": 0.72, - "text-background-padding": 2, - cursor: "pointer", - }, - }, - { - selector: "node.named", - style: { label: "data(label)" }, - }, - { - selector: "edge", - style: { - width: 0.9, - "line-color": "#8fa992", - "target-arrow-shape": "none", - "mid-target-arrow-shape": "none", - "mid-target-arrow-color": "#d2e4bd", - "arrow-scale": 0.7, - "curve-style": "bezier", - opacity: 0.45, - cursor: "pointer", - }, - }, - { selector: ".core-hidden", style: { display: "none" } }, - { - selector: ".faded", - style: { opacity: 0.045, "text-opacity": 0 }, - }, - { - selector: "node.selected", - style: { - label: "data(label)", - "background-color": "#e1f1c9", - "border-color": "#5c7957", - "border-width": 2, - color: "#eef4ea", - "font-size": 12, - "text-opacity": 1, - "z-index": 20, - }, - }, - { - selector: "edge.selected", - style: { - "line-color": "#c4dda6", - "mid-target-arrow-shape": "triangle", - width: 1.2, - opacity: 0.95, - "z-index": 18, - }, - }, - { - selector: "node.neighbor", - style: { label: "", opacity: 0.8, width: 13, height: 13 }, - }, - { - selector: "node.expanded", - style: { - shape: "round-rectangle", - label: "data(label)", - "background-opacity": 0.8, - "background-color": "#0d1410", - "border-color": "#344739", - "border-width": 1, - "text-valign": "top", - "text-margin-y": -10, - padding: 24, - }, - }, - { - selector: "node[?isVariant]", - style: { - label: "data(label)", - shape: "ellipse", - width: 20, - height: 20, - "text-valign": "bottom", - "text-margin-y": 10, - "text-wrap": "wrap", - "text-max-width": 145, - "font-size": 14, - color: "#d6e2d1", - "border-width": 2, - "text-background-opacity": 1, - }, - }, - { selector: ".focus-hidden", style: { display: "none" } }, - { - selector: "node.hovered, node.named", - style: { label: "data(label)", "text-opacity": 1, "z-index": 30 }, - }, - { - selector: "edge.hovered, edge.direction", - style: { - "mid-target-arrow-shape": "triangle", - "line-color": "#b6c9a7", - opacity: 0.9, - width: 1.3, - }, - }, - { - selector: "node.search-match", - style: { - label: "data(label)", - "background-color": "#e1f1c9", - "border-color": "#8eaa7f", - "border-width": 4, - opacity: 1, - "text-opacity": 1, - "z-index": 20, - }, - }, - ], - }); - - search.disabled = true; - for (let index = 0; index < elements.length; index += 40) { - cy.batch(() => cy.add(elements.slice(index, index + 40))); - await new Promise(resolve => setTimeout(resolve, 0)); - } - - function relax(elements) { - const variants = elements.nodes("[?isVariant]"); - // Reserve the full two-line label footprint while solving, then draw compact dots. - variants.style({ width: 180, height: 120, label: "" }); - elements.layout({ - ...reductionLayoutOptions, - idealEdgeLength: 95, - edgeElasticity: 0.45, - }).run(); - variants.removeStyle("width height label"); - } - const expanded = new Set(); - const browserSearch = document.querySelector("#browser-search"); - const browserList = document.querySelector("#browser-list"); - let browseMode = "problems"; - let selectedProblem = ""; - let selectedRules = new Set(); - const overviewPositions = new Map(cy.nodes().map((node) => [node.id(), { ...node.position() }])); - const variantId = (node) => `${node.name}/${variantKey(node.variant)}`; - const describeVariant = (node) => Object.entries(node.variant) - .map(([key, value]) => `${key}: ${value}`).join(", ") || "Default variant"; - - function showDetails(title, variant, key, parentKey) { - inspector.scrollTop = 0; - window.renderDetails(detail, title, variant, key, parentKey, (problem) => { - transition(() => selectNode(cy.getElementById(problem.split("/")[0]))); - }); - const atlas = document.createElement("a"); - atlas.className = "detail-atlas"; - atlas.textContent = "Open in Atlas ↗"; - const endpoint = (value) => { - const [name, variant = ""] = value.split("/"); - return [encodeURIComponent(name), encodeURIComponent(variant)]; - }; - if (key.startsWith("problem:")) { - const [name, variant] = endpoint(key.slice(8)); - atlas.href = `./index.html#problem/${name}${variant ? `?variant=${variant}` : ""}`; - } else { - const [source, target] = key.slice(5).split("->").map(endpoint); - atlas.href = `./index.html#reduction/${source[0]}/${target[0]}${key.includes("/") ? `?from=${source[1]}&to=${target[1]}` : ""}`; - } - detail.querySelector("h2").after(atlas); - const pdf = document.createElement("a"); - pdf.href = "./reductions.pdf"; - pdf.target = "_blank"; - pdf.rel = "noopener"; - pdf.className = "detail-pdf"; - pdf.textContent = "Open PDF reference ↗"; - detail.append(pdf); - } - - const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); - let finishMotion; - let layoutLoaded; - let transitionRequest = 0; - - async function transition(change) { - const request = ++transitionRequest; - try { - layoutLoaded ||= new Promise((resolve, reject) => { - const script = document.createElement("script"); - script.src = "./assets/layout-bundle.js"; - script.onload = resolve; - script.onerror = () => reject(new Error("Cannot load graph layout. Reload the page to get the current release.")); - document.head.append(script); - }); - await layoutLoaded; - } catch (error) { - layoutLoaded = null; - detail.textContent = error.message; - detail.setAttribute("role", "alert"); - return; - } - if (request !== transitionRequest) return; - if (finishMotion) finishMotion(); - if (reducedMotion.matches) { - change(); - return; - } - const before = new Map(cy.nodes().map((node) => [node.id(), { - position: { ...node.position() }, data: { ...node.data() }, - }])); - const fromView = { zoom: cy.zoom(), pan: { ...cy.pan() } }; - change(); - const toView = { zoom: cy.zoom(), pan: { ...cy.pan() } }; - const moving = cy.nodes().not(":parent").map((node) => ({ - node, - from: before.get(node.id())?.position || before.get(node.data("parent")).position, - to: { ...node.position() }, - })); - const exiting = []; - before.forEach(({ data, position }, id) => { - if (!data.isVariant || cy.getElementById(id).nonempty()) return; - const target = cy.getElementById(data.parent).position(); - const ghostData = { ...data }; - delete ghostData.parent; - const ghost = cy.add({ data: ghostData, position, classes: "exiting" }); - ghost.style("events", "no"); - exiting.push(ghost); - moving.push({ node: ghost, from: position, to: { ...target } }); - }); - const mix = (a, b, t) => a + (b - a) * t; - let frame; - function draw(t) { - cy.batch(() => { - moving.forEach(({ node, from, to }) => node.position({ - x: mix(from.x, to.x, t), y: mix(from.y, to.y, t), - })); - exiting.forEach((node) => node.style("opacity", 1 - t)); - }); - cy.viewport({ zoom: mix(fromView.zoom, toView.zoom, t), pan: { - x: mix(fromView.pan.x, toView.pan.x, t), - y: mix(fromView.pan.y, toView.pan.y, t), - } }); - } - finishMotion = () => { - cancelAnimationFrame(frame); - draw(1); - exiting.forEach((node) => node.remove()); - finishMotion = null; - }; - const start = performance.now(); - draw(0); - function tick(now) { - const progress = Math.min(1, (now - start) / 350); - draw(progress * progress * (3 - 2 * progress)); - if (progress < 1) frame = requestAnimationFrame(tick); - else finishMotion(); - } - frame = requestAnimationFrame(tick); - } - - function restoreOverview() { - cy.elements().removeClass("focus-hidden named"); - cy.nodes().not(":parent").not("[?isVariant]").forEach((node) => { - node.position(overviewPositions.get(node.id())); - }); - } - - function arrangeNeighborhood(nodes, edges) { - const neighbors = edges.connectedNodes().difference(nodes).not(":parent"); - const focus = nodes.union(neighbors).union(edges).union(nodes.ancestors()); - if (nodes.first().isParent()) { - relax(focus); - } - cy.elements().difference(focus).addClass("focus-hidden"); - return focus; - } - - function rebuildEdges() { - const edges = new Map(); - data.edges.forEach((rule) => { - const from = data.nodes[rule.source]; - const to = data.nodes[rule.target]; - const source = expanded.has(from.name) ? variantId(from) : from.name; - const target = expanded.has(to.name) ? variantId(to) : to.name; - if (source === target) return; - const id = `edge:${source}:${target}`; - const edge = edges.get(id) || { id, source, target, count: 0 }; - edge.count += 1; - edges.set(id, edge); - }); - cy.edges().remove(); - cy.add([...edges.values()].map((edge) => ({ data: edge }))); - } - - function toggleVariants(node) { - const family = families.get(node.id()); - if (family.variants.length < 2) return; - const center = { ...node.position() }; - if (expanded.has(node.id())) { - node.children().remove(); - node.removeClass("expanded").removeStyle("font-size").position(center); - expanded.delete(node.id()); - } else { - expanded.add(node.id()); - node.addClass("expanded"); - const columns = Math.ceil(Math.sqrt(family.variants.length)); - const rows = Math.ceil(family.variants.length / columns); - cy.add(family.variants.map((variant, index) => ({ - data: { - id: variantId(variant), - parent: node.id(), - isVariant: true, - label: Object.entries(variant.variant) - .map(([key, value]) => `${key}: ${value}`).join("\n"), - category: family.category, - rules: 0, - }, - position: { - x: center.x + (index % columns - (columns - 1) / 2) * 175, - y: center.y + (Math.floor(index / columns) - (rows - 1) / 2) * 105, - }, - }))); - } - rebuildEdges(); - } - - const visibleElements = () => - cy.elements().filter((element) => element.style("display") !== "none"); - - function sizeFocusLabels() { - const zoom = cy.zoom(); - cy.startBatch(); - cy.nodes().not(":parent").not("[?isVariant]") - .style("font-size", (node) => (node.hasClass("selected") ? 14 : 13) / zoom); - cy.nodes(".expanded").style("font-size", 18); - cy.endBatch(); - } - let labelFrame; - function updateLabels() { - sizeFocusLabels(); - cy.nodes().removeClass("named"); - const reserved = cy.nodes("[?isVariant], :parent, .hovered, .selected") - .filter((node) => node.visible()) - .map((node) => node.renderedBoundingBox({ includeNodes: false, includeLabels: true })); - const candidates = cy.nodes().not(":parent").not("[?isVariant]").not(".selected, .hovered") - .filter((node) => node.visible() && !node.hasClass("faded")) - .sort((a, b) => b.data("rules") - a.data("rules") || a.id().localeCompare(b.id())); - candidates.addClass("named"); - const hidden = []; - candidates.forEach((node) => { - const box = node.renderedBoundingBox({ includeNodes: false, includeLabels: true }); - const overlaps = reserved.some((other) => box.x1 < other.x2 + 8 && - box.x2 > other.x1 - 8 && box.y1 < other.y2 + 5 && box.y2 > other.y1 - 5); - if (overlaps || box.x1 < 12 || box.x2 > cy.width() - 12 || - box.y1 < 12 || box.y2 > cy.height() - 55) hidden.push(node); - else reserved.push(box); - }); - cy.collection(hidden).removeClass("named"); - } - cy.on("zoom pan", () => { - cancelAnimationFrame(labelFrame); - labelFrame = requestAnimationFrame(updateLabels); - }); - - function fit(focus = visibleElements(), maxZoom = 1) { - cy.resize(); - cy.fit(focus, Math.max(32, Math.min(cy.width(), cy.height()) * 0.1)); - cy.zoom(Math.min(cy.zoom(), maxZoom)); - cy.center(focus); - cancelAnimationFrame(labelFrame); - updateLabels(); - } - - function applyScope() { - cy.nodes().removeClass("core-hidden"); - if (scope === "core") cy.nodes("[?peripheral]").addClass("core-hidden"); - } - - function clearSelection() { - [...expanded].forEach((name) => toggleVariants(cy.getElementById(name))); - restoreOverview(); - cy.elements().removeClass("selected neighbor faded search-match"); - selectionType.textContent = "Details"; - detail.replaceChildren(); - applyScope(); - fit(); - selectedProblem = ""; - selectedRules.clear(); - renderBrowser(); - } - - function openInspector(type, focus) { - selectionType.textContent = type; - fit(focus, focus.nodes(":parent").empty() && focus.nodes().length <= 12 ? 1.8 : 1); - } - - function selectNode(node) { - cy.elements().removeClass("focus-hidden"); - if (!node.data("isVariant")) { - const wasExpanded = expanded.has(node.id()); - [...expanded].forEach((name) => toggleVariants(cy.getElementById(name))); - restoreOverview(); - if (wasExpanded) { - clearSelection(); - return; - } - toggleVariants(node); - } - cy.elements().removeClass("selected neighbor faded search-match"); - cy.elements().addClass("faded"); - const nodes = node.union(node.children()); - nodes.removeClass("faded core-hidden").addClass("selected"); - node.ancestors().removeClass("faded core-hidden"); - const edges = nodes.connectedEdges(); - edges.removeClass("faded"); - if (node.data("isVariant")) edges.addClass("selected"); - edges.connectedNodes().difference(nodes).removeClass("faded core-hidden").addClass("neighbor"); - edges.connectedNodes().ancestors().removeClass("faded core-hidden"); - const focus = arrangeNeighborhood(nodes, edges); - openInspector("Problem", focus); - selectedProblem = node.id(); - const familyName = node.data("isVariant") ? node.data("parent") : node.id(); - const variant = node.data("isVariant") - ? families.get(familyName).variants.find((item) => variantId(item) === node.id()) : null; - showDetails(displayName(familyName), variant ? describeVariant(variant) : "", - `problem:${node.id()}`, `problem:${familyName}`); - setBrowseMode("problems"); - browserList.querySelector('[aria-current="true"]')?.scrollIntoView({ block: "nearest" }); - } - - function selectEdge(edge) { - cy.elements().removeClass("selected neighbor faded search-match"); - cy.elements().addClass("faded"); - edge.removeClass("faded").addClass("selected"); - edge.connectedNodes().removeClass("faded core-hidden").addClass("selected"); - edge.connectedNodes().ancestors().removeClass("faded core-hidden"); - openInspector("Reduction", edge.connectedNodes()); - selectedRules = new Set(data.edges.flatMap((rule, index) => { - const matches = (endpoint, graphNode) => graphNode.data("isVariant") - ? variantId(data.nodes[endpoint]) === graphNode.id() - : data.nodes[endpoint].name === graphNode.id(); - return matches(rule.source, edge.source()) && matches(rule.target, edge.target()) ? [index] : []; - })); - const rules = [...selectedRules].map((index) => data.edges[index]); - const source = data.nodes[rules[0].source], target = data.nodes[rules[0].target]; - const parentKey = `rule:${source.name}->${target.name}`; - showDetails(`${displayName(source.name)} → ${displayName(target.name)}`, - rules.length === 1 ? `${describeVariant(source)} → ${describeVariant(target)}` - : `${rules.length} concrete rules. Choose a variant in the list.`, - rules.length === 1 ? `rule:${variantId(source)}->${variantId(target)}` : parentKey, parentKey); - setBrowseMode("rules"); - browserList.querySelector('[aria-current="true"]')?.scrollIntoView({ block: "nearest" }); - } - - function setBrowseMode(mode) { - if (browseMode !== mode) browserSearch.value = ""; - browseMode = mode; - document.querySelectorAll("[data-browse]").forEach((button) => - button.setAttribute("aria-pressed", button.dataset.browse === mode)); - browserSearch.placeholder = `Search ${mode}…`; - renderBrowser(); - } - - function renderBrowser() { - const query = browserSearch.value.trim().toLowerCase(); - const fragment = document.createDocumentFragment(); - let count = 0; - function item(title, subtitle, selected) { - const button = document.createElement("button"); - button.className = "browser-item"; - button.setAttribute("aria-current", String(selected)); - button.textContent = title; - if (subtitle) { - const detail = document.createElement("small"); - detail.textContent = subtitle; - button.append(detail); - } - fragment.append(button); - return button; - } - const variantText = (node) => Object.entries(node.variant) - .map(([key, value]) => `${key}: ${value}`).join(", "); - if (browseMode === "problems") { - [...families.values()].sort((a, b) => displayName(a.name).localeCompare(displayName(b.name))) - .forEach((family) => { - const schema = schemas.get(family.name); - const haystack = [family.name, displayName(family.name), schema?.description, - ...(schema?.aliases || []), ...family.variants.map(variantText)].join(" ").toLowerCase(); - if (!haystack.includes(query) && aliases.get(query.toUpperCase()) !== family.name) return; - count++; - const button = item(displayName(family.name), family.variants.length > 1 ? `${family.variants.length} variants` : "", selectedProblem === family.name); - button.dataset.family = family.name; - if (family.variants.length > 1) button.setAttribute("aria-expanded", String(expanded.has(family.name))); - if (expanded.has(family.name)) family.variants.forEach((variant) => { - const child = item(variantText(variant), "", selectedProblem === variantId(variant)); - child.classList.add("variant-item"); - child.dataset.variant = variantId(variant); - }); - }); - } else { - data.edges.forEach((rule, index) => { - const source = data.nodes[rule.source], target = data.nodes[rule.target]; - const title = `${displayName(source.name)} → ${displayName(target.name)}`; - const sourceVariant = variantText(source), targetVariant = variantText(target); - const subtitle = sourceVariant && targetVariant ? `${sourceVariant} → ${targetVariant}` - : sourceVariant ? `Source: ${sourceVariant}` : targetVariant ? `Target: ${targetVariant}` : ""; - const haystack = `${title} ${subtitle} ${source.name} ${target.name}`.toLowerCase(); - const alias = aliases.get(query.toUpperCase()); - if (!haystack.includes(query) && !(alias && [source.name, target.name].includes(alias))) return; - count++; - item(title, subtitle, selectedRules.has(index)).dataset.rule = index; - }); - } - document.querySelector("#browser-count").textContent = `${count} ${browseMode}`; - if (!count) { - const empty = document.createElement("p"); - empty.className = "browser-empty"; - empty.textContent = "No matches. Try another search."; - fragment.append(empty); - } - // Re-rendering replaces the focused button; keyboard focus follows its counterpart. - const focused = browserList.contains(document.activeElement) && document.activeElement.dataset; - browserList.replaceChildren(fragment); - if (focused) [...browserList.querySelectorAll("button")].find((button) => - ["family", "variant", "rule"].every((key) => button.dataset[key] === focused[key]))?.focus(); - } - - browserSearch.addEventListener("input", renderBrowser); - document.querySelectorAll("[data-browse]").forEach((button) => - button.addEventListener("click", () => setBrowseMode(button.dataset.browse))); - browserList.addEventListener("click", (event) => { - const button = event.target.closest("button"); - if (!button) return; - transition(() => { - if (button.dataset.family) selectNode(cy.getElementById(button.dataset.family)); - else if (button.dataset.variant) selectNode(cy.getElementById(button.dataset.variant)); - else { - const rule = data.edges[Number(button.dataset.rule)]; - [...expanded].forEach((name) => toggleVariants(cy.getElementById(name))); - restoreOverview(); - const source = data.nodes[rule.source], target = data.nodes[rule.target]; - new Set([source.name, target.name]).forEach((name) => toggleVariants(cy.getElementById(name))); - const from = expanded.has(source.name) ? variantId(source) : source.name; - const to = expanded.has(target.name) ? variantId(target) : target.name; - const edge = cy.getElementById(`edge:${from}:${to}`); - const endpoints = edge.connectedNodes(); - const groups = endpoints.ancestors().union(endpoints).union(endpoints.ancestors().children()); - groups.removeClass("core-hidden"); - arrangeNeighborhood(groups, edge); - selectEdge(edge); - } - }); - }); - - function applySearch() { - [...expanded].forEach((name) => toggleVariants(cy.getElementById(name))); - restoreOverview(); - selectedProblem = ""; - selectedRules.clear(); - renderBrowser(); - const query = search.value.trim().toLowerCase(); - cy.elements().removeClass("selected neighbor faded search-match"); - selectionType.textContent = "Details"; - detail.replaceChildren(); - applyScope(); - if (!query) { - fit(); - return; - } - const matches = cy.nodes().filter((node) => { - const schema = schemas.get(node.id()); - return ( - aliases.get(query.toUpperCase()) === node.id() || - [ - node.id(), - displayName(node.id()), - schema?.description, - ...(schema?.aliases || []), - ] - .filter(Boolean) - .join(" ") - .toLowerCase() - .includes(query) - ); - }); - cy.nodes().removeClass("core-hidden").addClass("faded"); - cy.edges().addClass("faded"); - matches.removeClass("faded").addClass("search-match"); - matches.connectedEdges().removeClass("faded"); - matches.connectedEdges().connectedNodes().removeClass("faded"); - if (matches.length) fit(matches.union(matches.connectedEdges())); - } - - cy.on("tap", "node", (event) => transition(() => selectNode(event.target))); - cy.on("tap", "edge", (event) => transition(() => selectEdge(event.target))); - cy.on("tap", (event) => { - if (event.target === cy) transition(clearSelection); - }); - cy.on("mouseover", "node", (event) => { - event.target.addClass("hovered"); - event.target.connectedEdges().addClass("direction"); - if (event.target.data("isVariant")) { - tooltip.textContent = `${displayName(event.target.parent().id())} · ${event.target.data("label")}`; - tooltip.style.display = "block"; - return; - } - const family = families.get(event.target.id()); - tooltip.textContent = `${displayName(family.name)} · ${family.variants.length} variant${family.variants.length === 1 ? "" : "s"} · ${family.rules} reductions`; - tooltip.style.display = "block"; - }); - cy.on("mouseover", "edge", (event) => { - event.target.addClass("hovered"); - const edge = event.target; - tooltip.textContent = `${edge.source().data("label")} → ${edge.target().data("label")} · ${edge.data("count")} concrete rule${edge.data("count") === 1 ? "" : "s"}`; - tooltip.style.display = "block"; - }); - cy.on("mousemove", "node, edge", (event) => { - tooltip.style.left = `${event.originalEvent.clientX + 14}px`; - tooltip.style.top = `${event.originalEvent.clientY + 14}px`; - }); - cy.on("mouseout", "node, edge", (event) => { - event.target.removeClass("hovered"); - if (event.target.isNode()) event.target.connectedEdges().removeClass("direction"); - tooltip.style.display = "none"; - }); - - document.querySelectorAll("[data-scope]").forEach((button) => - button.addEventListener("click", () => { - scope = button.dataset.scope; - document.querySelectorAll("[data-scope]").forEach((item) => - item.setAttribute("aria-pressed", item === button), - ); - search.value = ""; - transition(clearSelection); - }), - ); - document.querySelector("#reset-graph").addEventListener("click", () => { - search.value = ""; - transition(clearSelection); - }); - const workspace = document.querySelector(".graph-workspace"); - const divider = document.querySelector(".graph-divider"); - const maxPanelWidth = () => (workspace.clientWidth - document.querySelector(".graph-browser").offsetWidth) * 0.6; - function resizePanel(width) { - const size = Math.max(240, Math.min(maxPanelWidth(), width)); - workspace.style.setProperty("--inspector-width", `${size}px`); - } - function describeDivider() { - divider.setAttribute("aria-valuenow", Math.round(inspector.getBoundingClientRect().width)); - divider.setAttribute("aria-valuemax", Math.round(maxPanelWidth())); - } - describeDivider(); - divider.addEventListener("pointerdown", (event) => { - if (event.button !== 0) return; - if (finishMotion) finishMotion(); - event.preventDefault(); - divider.focus(); - divider.setPointerCapture(event.pointerId); - divider.classList.add("dragging"); - }); - divider.addEventListener("pointermove", (event) => { - if (divider.hasPointerCapture(event.pointerId)) { - resizePanel(workspace.getBoundingClientRect().right - event.clientX - 4); - } - }); - divider.addEventListener("pointerup", (event) => { - if (divider.hasPointerCapture(event.pointerId)) divider.releasePointerCapture(event.pointerId); - }); - divider.addEventListener("lostpointercapture", () => divider.classList.remove("dragging")); - divider.addEventListener("keydown", (event) => { - const widths = { ArrowLeft: inspector.clientWidth + 20, - ArrowRight: inspector.clientWidth - 20, Home: 240, End: maxPanelWidth() }; - if (!(event.key in widths)) return; - event.preventDefault(); - if (finishMotion) finishMotion(); - resizePanel(widths[event.key]); - }); - const canvas = document.querySelector(".graph-canvas"); - let canvasWidth = canvas.clientWidth; - let canvasHeight = canvas.clientHeight; - new ResizeObserver(([entry]) => { - const { width, height } = entry.contentRect; - if (width === canvasWidth && height === canvasHeight) return; - cy.resize(); - cy.panBy({ x: (width - canvasWidth) / 2, y: (height - canvasHeight) / 2 }); - canvasWidth = width; - canvasHeight = height; - describeDivider(); - updateLabels(); - }).observe(canvas); - search.addEventListener("input", () => transition(applySearch)); - search.addEventListener("keydown", (event) => { - if (event.key !== "Enter") return; - transition(() => { - applySearch(); - const match = cy.nodes(".search-match").first(); - if (match.nonempty()) selectNode(match); - }); - }); - document.addEventListener("keydown", (event) => { - const typing = - /INPUT|TEXTAREA|SELECT/.test(event.target.tagName) || - event.target.isContentEditable; - if (event.key === "/" && !typing) { - event.preventDefault(); - search.focus(); - } - if (event.key !== "Escape") return; - if (!typing) { - search.value = ""; - transition(clearSelection); - return; - } - // Escape in a field resets only that field, through its own input handler. - event.preventDefault(); - if (event.target.value) { - event.target.value = ""; - event.target.dispatchEvent(new Event("input", { bubbles: true })); - } - event.target.blur(); - }); - - applyScope(); - renderBrowser(); - requestAnimationFrame(() => { - fit(); - document.querySelector("#cy").removeAttribute("aria-busy"); - workspace.inert = false; - search.disabled = false; - }); - } catch (error) { - document.querySelector(".graph-canvas-note").textContent = `Unable to load graph: ${error.message}`; - throw error; - } -})(); diff --git a/docs/website/atlas-data.schema.json b/docs/website/atlas-data.schema.json new file mode 100644 index 000000000..ea1e1eeb2 --- /dev/null +++ b/docs/website/atlas-data.schema.json @@ -0,0 +1,450 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://codingthrust.github.io/problem-reductions/contracts/atlas-data", + "title": "AtlasData", + "oneOf": [ + { + "$ref": "#/$defs/AtlasSummary" + }, + { + "$ref": "#/$defs/AtlasRecord" + } + ], + "$defs": { + "Name": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, + "Category": { + "type": "string", + "enum": [ + "graph", + "formula", + "set", + "algebraic", + "misc" + ] + }, + "Variant": { + "type": "object", + "propertyNames": { + "minLength": 1 + }, + "additionalProperties": { + "type": "string", + "minLength": 1 + } + }, + "Index": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "DocPath": { + "type": "string", + "pattern": "^(?:[A-Za-z0-9_-]+/)*[A-Za-z0-9_.-]+\\.html$" + }, + "SourcePath": { + "type": "string", + "pattern": "^src/(?:[A-Za-z0-9_-]+/)*[A-Za-z0-9_-]+\\.rs$" + }, + "MathML": { + "type": "string", + "pattern": "^]*\">(?:<(?:mrow|mi|mn|mo|mfrac|msup|msqrt)>|||[^<>])*$" + }, + "SummaryNode": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "category", + "variant" + ], + "properties": { + "name": { + "$ref": "#/$defs/Name" + }, + "category": { + "$ref": "#/$defs/Category" + }, + "variant": { + "$ref": "#/$defs/Variant" + } + } + }, + "SummaryEdge": { + "type": "object", + "additionalProperties": false, + "required": [ + "source", + "target" + ], + "properties": { + "source": { + "$ref": "#/$defs/Index" + }, + "target": { + "$ref": "#/$defs/Index" + } + } + }, + "SummarySchema": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "description" + ], + "properties": { + "name": { + "$ref": "#/$defs/Name" + }, + "description": { + "type": "string" + }, + "display_name": { + "type": "string", + "minLength": 1 + }, + "aliases": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + } + } + }, + "RecordNode": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "category", + "variant", + "default", + "parameter_names", + "complexity", + "complexity_mathml", + "doc_path", + "api_path" + ], + "properties": { + "name": { + "$ref": "#/$defs/Name" + }, + "category": { + "$ref": "#/$defs/Category" + }, + "variant": { + "$ref": "#/$defs/Variant" + }, + "default": { + "type": "boolean" + }, + "parameter_names": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "complexity": { + "type": "string", + "minLength": 1 + }, + "complexity_mathml": { + "$ref": "#/$defs/MathML" + }, + "doc_path": { + "$ref": "#/$defs/DocPath" + }, + "api_path": { + "$ref": "#/$defs/DocPath" + } + } + }, + "Parameter": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "field", + "contract", + "formula", + "reason", + "mathml" + ], + "properties": { + "field": { + "type": "string", + "minLength": 1 + }, + "contract": { + "type": "string", + "enum": [ + "exact", + "upper_bound" + ] + }, + "formula": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "null" + }, + "mathml": { + "$ref": "#/$defs/MathML" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "field", + "contract", + "formula", + "reason", + "mathml" + ], + "properties": { + "field": { + "type": "string", + "minLength": 1 + }, + "contract": { + "type": "string", + "const": "unavailable" + }, + "formula": { + "type": "null" + }, + "reason": { + "type": "string", + "minLength": 1 + }, + "mathml": { + "type": "null" + } + } + } + ] + }, + "RecordEdge": { + "type": "object", + "additionalProperties": false, + "required": [ + "source", + "target", + "witness", + "aggregate", + "turing", + "parameter_contract_error", + "parameters", + "doc_path", + "source_path", + "api_path", + "test_path" + ], + "properties": { + "source": { + "$ref": "#/$defs/Index" + }, + "target": { + "$ref": "#/$defs/Index" + }, + "witness": { + "type": "boolean" + }, + "aggregate": { + "type": "boolean" + }, + "turing": { + "type": "boolean" + }, + "parameter_contract_error": { + "type": [ + "string", + "null" + ], + "minLength": 1 + }, + "parameters": { + "type": "array", + "items": { + "$ref": "#/$defs/Parameter" + } + }, + "doc_path": { + "$ref": "#/$defs/DocPath" + }, + "source_path": { + "$ref": "#/$defs/SourcePath" + }, + "api_path": { + "$ref": "#/$defs/DocPath" + }, + "test_path": { + "anyOf": [ + { + "$ref": "#/$defs/SourcePath" + }, + { + "type": "null" + } + ] + } + } + }, + "Field": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "type_name", + "description" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "type_name": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + } + } + }, + "RecordSchema": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "description", + "category", + "fields", + "module_path", + "source_path" + ], + "properties": { + "name": { + "$ref": "#/$defs/Name" + }, + "description": { + "type": "string" + }, + "display_name": { + "type": "string", + "minLength": 1 + }, + "aliases": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "category": { + "$ref": "#/$defs/Category" + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/$defs/Field" + } + }, + "module_path": { + "type": "string", + "minLength": 1 + }, + "source_path": { + "$ref": "#/$defs/SourcePath" + } + } + }, + "AtlasSummary": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "nodes", + "edges", + "schemas" + ], + "properties": { + "kind": { + "type": "string", + "const": "summary" + }, + "nodes": { + "type": "array", + "items": { + "$ref": "#/$defs/SummaryNode" + }, + "minItems": 1 + }, + "edges": { + "type": "array", + "items": { + "$ref": "#/$defs/SummaryEdge" + } + }, + "schemas": { + "type": "array", + "items": { + "$ref": "#/$defs/SummarySchema" + }, + "minItems": 1 + } + }, + "title": "AtlasSummary" + }, + "AtlasRecord": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "nodes", + "edges", + "schemas" + ], + "properties": { + "kind": { + "type": "string", + "const": "record" + }, + "nodes": { + "type": "array", + "items": { + "$ref": "#/$defs/RecordNode" + }, + "minItems": 1 + }, + "edges": { + "type": "array", + "items": { + "$ref": "#/$defs/RecordEdge" + } + }, + "schemas": { + "type": "array", + "items": { + "$ref": "#/$defs/RecordSchema" + }, + "minItems": 1, + "maxItems": 1 + } + }, + "title": "AtlasRecord" + } + } +} diff --git a/docs/website/cli-preview.png b/docs/website/cli-preview.png deleted file mode 100644 index e15c6aee3..000000000 Binary files a/docs/website/cli-preview.png and /dev/null differ diff --git a/docs/website/docs-preview.png b/docs/website/docs-preview.png deleted file mode 100644 index 3183768bb..000000000 Binary files a/docs/website/docs-preview.png and /dev/null differ diff --git a/docs/website/graph-details.schema.json b/docs/website/graph-details.schema.json new file mode 100644 index 000000000..8e0b8bcf4 --- /dev/null +++ b/docs/website/graph-details.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://codingthrust.github.io/problem-reductions/contracts/graph-details", + "title": "GraphDetailsIndex", + "type": "object", + "additionalProperties": false, + "required": ["entries", "anchors"], + "properties": { + "entries": { + "type": "object", "minProperties": 1, + "propertyNames": {"pattern": "^(problem:.+|rule:.+|references|footnotes)$"}, + "additionalProperties": {"type": "string", "minLength": 1} + }, + "anchors": {"type": "object", "additionalProperties": {"type": "string", "minLength": 1}} + } +} diff --git a/docs/website/graph-scene.schema.json b/docs/website/graph-scene.schema.json new file mode 100644 index 000000000..90cb68e8e --- /dev/null +++ b/docs/website/graph-scene.schema.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://codingthrust.github.io/problem-reductions/contracts/graph-scene", + "title": "GraphSceneData", + "type": "object", + "additionalProperties": false, + "required": ["nodes", "edges", "aliases", "layout"], + "properties": { + "nodes": { + "type": "array", "minItems": 1, + "items": { + "type": "object", "additionalProperties": false, + "required": ["name", "category", "variant", "default"], + "properties": { + "name": {"type": "string", "minLength": 1}, + "category": {"enum": ["graph", "formula", "set", "algebraic", "misc"], "type": "string"}, + "variant": {"type": "object", "additionalProperties": {"type": "string", "minLength": 1}}, + "default": {"type": "boolean"} + } + } + }, + "edges": { + "type": "array", + "items": { + "type": "object", "additionalProperties": false, + "required": ["source", "target", "witness", "aggregate", "turing", "fields"], + "properties": { + "source": {"type": "integer", "minimum": 0, "maximum": 9007199254740991}, + "target": {"type": "integer", "minimum": 0, "maximum": 9007199254740991}, + "witness": {"type": "boolean"}, + "aggregate": {"type": "boolean"}, + "turing": {"type": "boolean"}, + "fields": { + "type": "object", + "additionalProperties": { + "type": "object", "additionalProperties": false, + "required": ["certificate", "accuracy"], + "properties": { + "certificate": {"type": ["string", "null"], "minLength": 1}, + "accuracy": { + "type": "object", "required": ["kind"], + "properties": {"kind": {"enum": ["exact", "upper_bound", "unavailable"]}} + } + } + } + } + } + } + }, + "aliases": { + "type": "object", + "additionalProperties": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true} + }, + "layout": { + "type": "object", + "additionalProperties": { + "type": "object", "additionalProperties": false, + "required": ["x", "y"], + "properties": {"x": {"type": "number"}, "y": {"type": "number"}} + } + } + } +} diff --git a/docs/website/graph.html b/docs/website/graph.html index d01afbbde..1ddb292ad 100644 --- a/docs/website/graph.html +++ b/docs/website/graph.html @@ -1,127 +1,95 @@ - - - - - - - Reduction Graph — Problem Reductions - - - - - - - - - - - - - + + + + + + Reduction Paths — Problem Reductions + + + + + + + + + + + + +
-
-
-
- Reduction graph - __PROBLEM_COUNT__ families · __VARIANT_COUNT__ variants · - __RULE_COUNT__ directed reductions -
-
- - - -
+
+ +
+

Reduction graph

+
+ + +
+
+
+
+ +

Routes alone do not establish complexity or solver availability. Colours summarize variants.

+
-
- -
-
-
- Graph - Formula - Set - Algebraic - Misc -
-

- Hover a node or connection to see reduction direction. -

-
+
+
+
Growth
+ +
- - -
-
- - - +
+ + + + + + diff --git a/docs/website/index.html b/docs/website/index.html index 6669490a1..bab78e270 100644 --- a/docs/website/index.html +++ b/docs/website/index.html @@ -19,16 +19,15 @@ - - - - - + + + + @@ -48,10 +47,10 @@ Atlas Get started Docs Docs Open questions Open questions @@ -66,9 +65,9 @@

Problem
Reductions

@@ -92,9 +91,45 @@

Problem
Reductions

Theory, constructions
& proofs -
+
+
+

Get started

+

+ Use the atlas from the terminal, from Rust, or through an + agent.
All three share the same models and constructions. +

+ +
+
+
+ +
+
# Install the CLI
+$ cargo install problemreductions-cli
+
+# Explore a problem
+$ pred show MIS
+
+# Search for a route to a solver
+$ pred path MIS ILP
+
+
@@ -109,10 +144,36 @@

Problems and reductions

Browse all problems Browse all problems
+
@@ -165,72 +226,11 @@

Grow the atlas

question selection and persistent experiment records.

Explore agent workflows Explore agent workflows
- -
-
-

Get started

-

- Use the atlas from the terminal, from Rust, or through an - agent.
All three share the same models and constructions. -

- -
-
-
- -
-
# Install the CLI
-$ cargo install problemreductions-cli
-
-# Explore a problem
-$ pred show MIS
-
-# Search for a route to a solver
-$ pred path MIS ILP
-
-
diff --git a/docs/website/native-exports.schema.json b/docs/website/native-exports.schema.json new file mode 100644 index 000000000..32c42e8b6 --- /dev/null +++ b/docs/website/native-exports.schema.json @@ -0,0 +1,293 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://codingthrust.github.io/problem-reductions/contracts/native-exports", + "title": "NativeExports", + "type": "object", + "additionalProperties": false, + "required": [ + "graph", + "schemas" + ], + "properties": { + "graph": { + "type": "object", + "additionalProperties": false, + "required": [ + "nodes", + "edges", + "aliases" + ], + "properties": { + "nodes": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "variant", + "default", + "parameter_names", + "category", + "doc_path", + "complexity" + ], + "properties": { + "name": { + "$ref": "#/$defs/Name" + }, + "variant": { + "type": "object", + "propertyNames": { + "minLength": 1 + }, + "additionalProperties": { + "type": "string", + "minLength": 1 + } + }, + "default": { + "type": "boolean" + }, + "parameter_names": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "category": { + "$ref": "#/$defs/Category" + }, + "doc_path": { + "$ref": "#/$defs/DocPath" + }, + "complexity": { + "type": "string", + "minLength": 1 + } + } + } + }, + "edges": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "source", + "target", + "witness", + "turing", + "parameter_contract_error", + "parameters", + "aggregate", + "doc_path" + ], + "properties": { + "source": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "target": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "witness": { + "type": "boolean" + }, + "turing": { + "type": "boolean" + }, + "parameter_contract_error": { + "type": [ + "string", + "null" + ], + "minLength": 1 + }, + "parameters": { + "type": "array", + "items": { + "$ref": "#/$defs/Parameter" + } + }, + "aggregate": { + "type": "boolean" + }, + "doc_path": { + "$ref": "#/$defs/DocPath" + } + } + } + }, + "aliases": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + } + } + } + }, + "schemas": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "description", + "category", + "fields", + "module_path" + ], + "properties": { + "name": { + "$ref": "#/$defs/Name" + }, + "description": { + "type": "string" + }, + "display_name": { + "type": "string", + "minLength": 1 + }, + "aliases": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "category": { + "$ref": "#/$defs/Category" + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/$defs/Field" + } + }, + "module_path": { + "type": "string", + "minLength": 1 + } + } + } + } + }, + "$defs": { + "Name": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, + "Category": { + "type": "string", + "enum": [ + "graph", + "formula", + "set", + "algebraic", + "misc" + ] + }, + "Field": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "type_name", + "description" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "type_name": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + } + } + }, + "DocPath": { + "type": "string", + "pattern": "^(?:[A-Za-z0-9_-]+/)*[A-Za-z0-9_.-]+\\.html$" + }, + "Parameter": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "field", + "contract", + "formula", + "reason" + ], + "properties": { + "field": { + "type": "string", + "minLength": 1 + }, + "contract": { + "type": "string", + "enum": [ + "exact", + "upper_bound" + ] + }, + "formula": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "null" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "field", + "contract", + "formula", + "reason" + ], + "properties": { + "field": { + "type": "string", + "minLength": 1 + }, + "contract": { + "type": "string", + "const": "unavailable" + }, + "formula": { + "type": "null" + }, + "reason": { + "type": "string", + "minLength": 1 + } + } + } + ] + } + } +} diff --git a/docs/website/path-graph.schema.json b/docs/website/path-graph.schema.json new file mode 100644 index 000000000..2b170708d --- /dev/null +++ b/docs/website/path-graph.schema.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://codingthrust.github.io/problem-reductions/contracts/path-graph", + "title": "PathGraphData", + "type": "object", + "additionalProperties": false, + "required": ["nodes", "edges"], + "properties": { + "nodes": { + "type": "array", "minItems": 1, + "items": { + "type": "object", "additionalProperties": false, + "required": ["name", "variant", "default", "parameter_names"], + "properties": { + "name": {"type": "string", "minLength": 1}, + "variant": { + "type": "object", "propertyNames": {"minLength": 1}, + "additionalProperties": {"type": "string", "minLength": 1} + }, + "default": {"type": "boolean"}, + "parameter_names": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}} + } + } + }, + "edges": { + "type": "array", + "items": { + "type": "object", "additionalProperties": false, + "required": ["source", "target", "witness", "turing", "parameters", "parameter_contract_error"], + "properties": { + "source": {"type": "integer", "minimum": 0, "maximum": 9007199254740991}, + "target": {"type": "integer", "minimum": 0, "maximum": 9007199254740991}, + "witness": {"type": "boolean"}, + "turing": {"type": "boolean"}, + "parameter_contract_error": {"type": ["string", "null"], "minLength": 1}, + "parameters": {"type": "array", "items": {"$ref": "#/$defs/Parameter"}} + } + } + } + }, + "$defs": { + "Parameter": { + "oneOf": [ + { + "type": "object", "additionalProperties": false, + "required": ["field", "contract", "formula", "reason"], + "properties": { + "field": {"type": "string", "minLength": 1}, + "contract": {"type": "string", "enum": ["exact", "upper_bound"]}, + "formula": {"type": "string", "minLength": 1}, + "reason": {"type": "null"} + } + }, + { + "type": "object", "additionalProperties": false, + "required": ["field", "contract", "formula", "reason"], + "properties": { + "field": {"type": "string", "minLength": 1}, + "contract": {"type": "string", "const": "unavailable"}, + "formula": {"type": "null"}, + "reason": {"type": "string", "minLength": 1} + } + } + ] + } + } +} diff --git a/docs/website/preview.png b/docs/website/preview.png deleted file mode 100644 index 362c90a3a..000000000 Binary files a/docs/website/preview.png and /dev/null differ diff --git a/docs/website/src/lib/force-layout.mts b/docs/website/src/lib/force-layout.mts new file mode 100644 index 000000000..19d780a52 --- /dev/null +++ b/docs/website/src/lib/force-layout.mts @@ -0,0 +1,97 @@ +/* Deterministic force model for the problem graph, shared by the build (published map) and the page (live + dragging and re-layout). It follows d3-force: link springs biased by degree, pairwise charge, collision, and a + weak pull to the origin. Rules into a solver hub pull weakly and the hubs repel strongly, so the hundred-odd ILP + encodings fan out instead of collapsing the map into a star. */ + +export interface Point { x: number; y: number } +export interface LayoutInput { + ids: readonly string[]; + links: readonly (readonly [string, string])[]; + hubs: ReadonlySet; + radius: (id: string) => number; + // Where to begin; missing ids start on d3's phyllotaxis spiral, so the result never depends on randomness. + start?: ReadonlyMap; + // Total ticks for a full cool-down from alpha 1. + ticks?: number; +} +export interface Simulation { + alpha: number; + // Live interaction holds alpha near this target instead of letting it cool to zero. + alphaTarget: number; + tick(): void; + // A pinned node stays where it is put and the rest of the graph responds to it. + pin(id: string, p: Point | null): void; + positions(): Map; +} +const ALPHA_MIN = 0.001; + +export function createSimulation({ ids, links, hubs, radius, start, ticks = 300 }: LayoutInput): Simulation { + const index = new Map(ids.map((id, i) => [id, i])); + const nodes = ids.map((id, i) => { + const p = start?.get(id), r = 10 * Math.sqrt(0.5 + i), a = i * Math.PI * (3 - Math.sqrt(5)); + return { id, x: p?.x ?? r * Math.cos(a), y: p?.y ?? r * Math.sin(a), vx: 0, vy: 0, r: radius(id), hub: hubs.has(id), + degree: 0, fixed: null as Point | null }; + }); + const edges = links.flatMap(([a, b]) => { + const s = index.get(a), t = index.get(b); + return s === undefined || t === undefined || s === t ? [] : [{ s: nodes[s]!, t: nodes[t]!, hub: hubs.has(a) || hubs.has(b) }]; + }); + for (const { s, t } of edges) { s.degree++; t.degree++; } + const decay = 1 - Math.pow(ALPHA_MIN, 1 / ticks), velocityDecay = 0.4; + + const sim: Simulation = { + alpha: 1, alphaTarget: 0, + tick() { + const alpha = sim.alpha += (sim.alphaTarget - sim.alpha) * decay; + for (const { s, t, hub } of edges) { + let dx = t.x + t.vx - s.x - s.vx || 1e-6, dy = t.y + t.vy - s.y - s.vy || 1e-6; + const len = Math.hypot(dx, dy), k = (len - (hub ? 120 : 32)) / len * alpha * (hub ? 0.04 : 0.5); + dx *= k; dy *= k; + const bias = s.degree / (s.degree + t.degree); + t.vx -= dx * bias; t.vy -= dy * bias; s.vx += dx * (1 - bias); s.vy += dy * (1 - bias); + } + for (let i = 0; i < nodes.length; i++) { + const a = nodes[i]!; + for (let j = i + 1; j < nodes.length; j++) { + const b = nodes[j]!, dx = b.x - a.x || 1e-6, dy = b.y - a.y || 1e-6, d2 = dx * dx + dy * dy; + // Each node pushes the other with its own charge. + const pa = (b.hub ? -600 : -34) * alpha / d2, pb = (a.hub ? -600 : -34) * alpha / d2; + a.vx += dx * pa; a.vy += dy * pa; b.vx -= dx * pb; b.vy -= dy * pb; + const min = a.r + b.r + 2; + if (d2 < min * min) { + const d = Math.sqrt(d2), push = (min - d) / d * 0.5; + a.vx -= dx * push; a.vy -= dy * push; b.vx += dx * push; b.vy += dy * push; + } + } + } + for (const n of nodes) { + if (n.fixed) { n.x = n.fixed.x; n.y = n.fixed.y; n.vx = n.vy = 0; continue; } + n.vx -= n.x * (n.degree ? 0.03 : 0.08) * alpha; + n.vy -= n.y * (n.degree ? 0.05 : 0.09) * alpha; + n.x += n.vx *= 1 - velocityDecay; n.y += n.vy *= 1 - velocityDecay; + } + }, + pin(id, p) { const n = nodes[index.get(id) ?? -1]; if (n) n.fixed = p; }, + positions: () => new Map(nodes.map(n => [n.id, { x: n.x, y: n.y }])), + }; + return sim; +} + +/* Runs the model to rest: the build's published map, or a view laid out without waiting for frames. */ +export function forceLayout(input: LayoutInput): Map { + const sim = createSimulation(input); + while (sim.alpha > ALPHA_MIN) sim.tick(); + return sim.positions(); +} +export const settled = (sim: Simulation) => sim.alpha <= ALPHA_MIN && sim.alphaTarget === 0; + +// Let input and painting run between short batches; canceled views must not apply stale positions. +export async function settleSimulation(sim: Simulation, signal: AbortSignal): Promise { + while (!settled(sim)) { + await new Promise(resolve => setTimeout(resolve, 0)); + if (signal.aborted) return false; + const until = performance.now() + 4; + do { sim.tick(); } while (!settled(sim) && performance.now() < until); + } + return !signal.aborted; +} diff --git a/docs/website/src/lib/graph-details-data.mts b/docs/website/src/lib/graph-details-data.mts new file mode 100644 index 000000000..3f71f95cf --- /dev/null +++ b/docs/website/src/lib/graph-details-data.mts @@ -0,0 +1,12 @@ +export function validateDetailURL(entry: string, page: URL, articles: URL): void { + const error = () => new Error('Invalid documentation index. Reload the current release.'); + let url: URL; + let path: string; + try { + url = new URL(entry, page); + path = decodeURIComponent(url.pathname); + } catch { throw error(); } + if (url.protocol !== page.protocol || url.origin !== page.origin || url.username || url.password || url.search || url.hash + || !path.startsWith(decodeURIComponent(articles.pathname)) || !path.endsWith('.html') + || path.includes('\\') || path.split('/').some(part => part === '.' || part === '..')) throw error(); +} diff --git a/docs/website/src/lib/graph-growth.mts b/docs/website/src/lib/graph-growth.mts new file mode 100644 index 000000000..2ed1fa8e1 --- /dev/null +++ b/docs/website/src/lib/graph-growth.mts @@ -0,0 +1,56 @@ +interface GrowthField { + certificate: string | null; + accuracy: { kind: 'exact' | 'upper_bound' | 'unavailable' }; +} +interface Growth { degree: bigint | null; lead: string } +type Degree = bigint | null | undefined; + +const SUP = '⁰¹²³⁴⁵⁶⁷⁸⁹'; +const sup = (degree: bigint) => degree.toString().replace(/\d/g, digit => SUP.charAt(digit.charCodeAt(0) - 48)); +const memo = new WeakMap(); + +export function growth(field: GrowthField): Growth { + const cached = memo.get(field); + if (cached) return cached; + let out: Growth = { degree: null, lead: 'unavailable' }; + if (field.certificate === '0' && field.accuracy.kind === 'exact') { + out = { degree: 0n, lead: '0' }; + } else if (field.certificate != null) { + const terms = field.certificate.split(' + ').map(term => { + const vars = [...term.matchAll(/([A-Za-z_]\w*)(?:\^([\d.]+))?/g)].map(match => ({ + // Every successful match includes the mandatory variable capture. + v: match[1]!.replace(/^num_/, ''), e: match[2] ? BigInt(match[2]) : 1n, + })); + return { d: vars.reduce((degree, variable) => degree + variable.e, 0n), + txt: vars.map(variable => variable.v + (variable.e === 1n ? '' : sup(variable.e))).join('·') || '1' }; + }); + const degree = terms.reduce((max, term) => term.d > max ? term.d : max, 0n); + // Removing signs and coefficients gives an upper bound, even for an exact formula. + // Keep every monomial: independent parameters need not grow at the same rate. + const lead = [...new Set(terms.map(term => term.txt))]; + out = { degree, lead: 'O(' + lead.slice(0, 3).join(' + ') + + (lead.length > 3 ? ' + …' : '') + ')' }; + } + memo.set(field, out); + return out; +} + +export function pathDegree(profile: { fields: Record }): bigint | null { + let max = 0n; + for (const field of Object.values(profile.fields)) { + const degree = growth(field).degree; + if (degree === null) return null; + if (degree > max) max = degree; + } + return max; +} + +export function sizeBadge(degree: Degree): string { + return degree == null ? '?' : degree === 0n ? '1' : degree === 1n ? 'n' : 'n' + sup(degree); +} + +export function compareDegrees(left: Degree, right: Degree): number { + if (left == null) return right == null ? 0 : 1; + if (right == null) return -1; + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/docs/website/src/lib/graph-structure.mts b/docs/website/src/lib/graph-structure.mts new file mode 100644 index 000000000..db761a632 --- /dev/null +++ b/docs/website/src/lib/graph-structure.mts @@ -0,0 +1,122 @@ +import { growth, compareDegrees } from './graph-growth.mts'; + +export interface GrowthField { certificate: string | null; accuracy: { kind: 'exact' | 'upper_bound' | 'unavailable' } } +export interface RuleEdge { + source: number; target: number; witness: boolean; aggregate: boolean; turing: boolean; + fields: Record; +} +export type Scope = 'witness' | 'aggregate' | 'turing'; +export type ClassId = 'constant' | 'linear' | 'quadratic' | 'cubic' | 'higher' | 'unknown'; +export interface Link { source: number; target: number } + +/* Polynomial total degree when every source parameter scales together: a visualization convention, not a runtime claim. */ +export const CLASSES: readonly { id: ClassId; label: string; name: string }[] = [ + { id: 'constant', label: '1', name: 'Constant' }, + { id: 'linear', label: 'n', name: 'Linear' }, + { id: 'quadratic', label: 'n²', name: 'Quadratic' }, + { id: 'cubic', label: 'n³', name: 'Cubic' }, + { id: 'higher', label: 'n⁴⁺', name: 'Higher degree' }, + { id: 'unknown', label: '?', name: 'Undetermined' }, +]; +const order = new Map(CLASSES.map((c, i) => [c.id, i])); + +export function growthClass(degree: bigint | null): ClassId { + if (degree === null) return 'unknown'; + return degree >= 4n ? 'higher' : (['constant', 'linear', 'quadratic', 'cubic'] as const)[Number(degree)]!; +} + +export interface EdgeGrowth { + degree: bigint | null; cls: ClassId; accuracy: 'exact' | 'upper_bound'; + fields: { name: string; degree: bigint | null; lead: string; kind: GrowthField['accuracy']['kind'] }[]; +} +const memo = new WeakMap(); +/* Overview policy: the highest field degree. One unavailable field leaves the whole edge undetermined. */ +export function edgeGrowth(edge: RuleEdge): EdgeGrowth { + const cached = memo.get(edge); + if (cached) return cached; + const fields = Object.entries(edge.fields).map(([name, f]) => ({ name, kind: f.accuracy.kind, ...growth(f) })); + let degree: bigint | null = 0n; + for (const f of fields) { + if (f.degree === null) { degree = null; break; } + if (compareDegrees(f.degree, degree) > 0) degree = f.degree; + } + const out = { degree, cls: growthClass(degree), fields, + accuracy: fields.every(f => f.kind === 'exact') ? 'exact' as const : 'upper_bound' as const }; + memo.set(edge, out); + return out; +} + +/* Indices of the rules in any listed scope whose class is at most `limit`. `skip` lists rules excluded by other filters. */ +export function activeRules(rules: readonly RuleEdge[], { scope, limit, skip }: { scope: Scope | readonly Scope[]; limit: ClassId; skip?: Set }) { + const max = order.get(limit)!, scopes = [scope].flat(); + return rules.flatMap((rule, i) => scopes.some(s => rule[s]) && !skip?.has(i) && order.get(edgeGrowth(rule).cls)! <= max ? [i] : []); +} + +/* Strongly connected components (iterative Tarjan); `comp[v]` indexes `groups`. */ +export function components(n: number, links: readonly Link[]) { + const out: number[][] = Array.from({ length: n }, () => []); + for (const l of links) out[l.source]!.push(l.target); + const index = new Array(n).fill(-1), low = new Array(n).fill(0), on = new Array(n).fill(false); + const comp = new Array(n).fill(-1), groups: number[][] = [], stack: number[] = []; + let counter = 0; + for (let root = 0; root < n; root++) { + if (index[root] !== -1) continue; + const work: [number, number][] = [[root, 0]]; + while (work.length) { + const frame = work[work.length - 1]!, v = frame[0]; + if (frame[1] === 0) { index[v] = low[v] = counter++; stack.push(v); on[v] = true; } + if (frame[1] < out[v]!.length) { + const w = out[v]![frame[1]++]!; + if (index[w] === -1) work.push([w, 0]); + else if (on[w]) low[v] = Math.min(low[v]!, index[w]!); + continue; + } + work.pop(); + if (work.length) { const p = work[work.length - 1]![0]; low[p] = Math.min(low[p]!, low[v]!); } + if (low[v] === index[v]) { + const group: number[] = []; + for (let w = -1; w !== v;) { w = stack.pop()!; on[w] = false; comp[w] = groups.length; group.push(w); } + groups.push(group); + } + } + } + return { comp, groups }; +} + +/* Concrete variants reachable from `start` along (down) or towards (up) the active links, excluding `start` itself. */ +export function reach(n: number, links: readonly Link[], start: number, direction: 'down' | 'up') { + const next: number[][] = Array.from({ length: n }, () => []); + for (const l of links) (direction === 'down' ? next[l.source]! : next[l.target]!).push(direction === 'down' ? l.target : l.source); + const seen = new Set([start]), queue = [start]; + for (let v = queue.pop(); v !== undefined; v = queue.pop()) for (const w of next[v]!) if (!seen.has(w)) { seen.add(w); queue.push(w); } + seen.delete(start); + return seen; +} + +export interface FamilyRule { source: string; target: string; rules: number[]; cls: ClassId; worst: ClassId } +/* Family-level navigation edges. Each keeps the exact variant rules behind it; `cls` is its cheapest rule, `worst` its dearest. */ +export function familyRules(nodes: readonly { name: string }[], rules: readonly RuleEdge[], active: readonly number[]) { + const out = new Map(); + for (const i of active) { + const rule = rules[i]!, s = nodes[rule.source]!.name, t = nodes[rule.target]!.name; + if (s === t) continue; + const cls = edgeGrowth(rule).cls, key = s + '→' + t, entry = out.get(key); + if (!entry) out.set(key, { source: s, target: t, rules: [i], cls, worst: cls }); + else { + entry.rules.push(i); + if (order.get(cls)! < order.get(entry.cls)!) entry.cls = cls; + if (order.get(cls)! > order.get(entry.worst)!) entry.worst = cls; + } + } + return out; +} + +/* Fewest reductions from any root; keep concrete variant IDs to avoid joining unrelated variants. */ +export function depthsFrom(names: readonly T[], links: readonly { source: T; target: T }[], roots: readonly T[]) { + const next = new Map(); + for (const l of links) (next.get(l.source) ?? next.set(l.source, []).get(l.source)!).push(l.target); + const depth = new Map(roots.filter(r => names.includes(r)).map(r => [r, 0])); + const queue = [...depth.keys()]; + for (let i = 0; i < queue.length; i++) for (const w of next.get(queue[i]!) ?? []) if (!depth.has(w)) { depth.set(w, depth.get(queue[i]!)! + 1); queue.push(w); } + return new Map(names.map(n => [n, depth.get(n) ?? null] as const)); +} diff --git a/docs/website/src/lib/path-worker-data.mts b/docs/website/src/lib/path-worker-data.mts new file mode 100644 index 000000000..f30f98640 --- /dev/null +++ b/docs/website/src/lib/path-worker-data.mts @@ -0,0 +1,26 @@ +import type { WasmPathResult } from '../generated/wasm-paths.mjs'; +import type { WasmComparisonResult } from '../generated/wasm-comparison.mjs'; + +export type PathRequest = { source: number; target: number; limit: number } & ( + { kind: 'search' } | { kind: 'compare'; cached: WasmPathResult } +); +export type PathReply = + | { phase: 'paths'; result: WasmPathResult } + | { phase: 'comparison'; result: WasmComparisonResult } + | { phase: 'paths' | 'comparison'; error: string }; + +const error = () => new Error('Invalid path worker contract. Reload the current release.'); +export function validatePathRequest(value: PathRequest): PathRequest { + if (![value.source, value.target].every(index => Number.isSafeInteger(index) && index >= 0) + || !Number.isInteger(value.limit) || value.limit < 1 || value.limit > 999) throw error(); + return value; +} + +export function validatePathReply(value: PathReply, request: PathRequest, result?: WasmPathResult): PathReply { + if ('error' in value) { + if (value.phase === 'comparison' && !result?.paths.length) throw error(); + } else if (value.phase === 'paths') { + if (request.kind !== 'search' || result !== undefined) throw error(); + } else if (!result?.paths.length) throw error(); + return value; +} diff --git a/docs/website/src/lib/variant.mts b/docs/website/src/lib/variant.mts new file mode 100644 index 000000000..48e6595ec --- /dev/null +++ b/docs/website/src/lib/variant.mts @@ -0,0 +1,2 @@ +export const nodeKey = (node: { name: string; variant: Record }): string => + JSON.stringify([node.name, Object.entries(node.variant).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)]); diff --git a/docs/website/src/pages/details.ts b/docs/website/src/pages/details.ts new file mode 100644 index 000000000..d6e9713ce --- /dev/null +++ b/docs/website/src/pages/details.ts @@ -0,0 +1,205 @@ +import { validateDetailURL } from '../lib/graph-details-data.mts'; +import type { GraphDetailsIndex } from '../generated/graph-details.mjs'; + +let detailsIndex: GraphDetailsIndex | undefined; +const detailCache = new Map>(); +let manifest: Promise | null = null; +let renderRequest = 0; +const indexURL = new URL('./assets/graph-details.json', window.location.href); +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} +function normalizeReferenceRoles(container: Element) { + for (const wrapper of container.querySelectorAll('sup[role="doc-noteref"], sup[role="doc-backlink"]')) { + const role = wrapper.getAttribute('role'); + if (role) for (const link of wrapper.querySelectorAll('a[href]')) link.setAttribute('role', role); + wrapper.removeAttribute('role'); + } +} +export function loadDetailsIndex() { + if (detailsIndex) return Promise.resolve(detailsIndex); + manifest ||= fetch(indexURL).then(response => { + if (!response.ok) throw new Error('Cannot load documentation index. Reload the page to get the current release.'); + return response.json(); + }).then((value: GraphDetailsIndex) => { + performance.mark('details:index-json-parsed'); + for (const url of Object.values(value.entries)) { + validateDetailURL(url, new URL(window.location.href), new URL('./details/', indexURL)); + } + return detailsIndex = value; + }).catch(error => { + manifest = null; + throw error; + }); + return manifest; +} +function loadDetail(key: string): Promise { + const url = detailsIndex?.entries[key]; + if (!url) return Promise.reject(new Error(`Missing detail file: ${key}`)); + let content = detailCache.get(url); + if (!content) { + content = fetch(url).then((response) => { + if (!response.ok) throw new Error(`Cannot load documentation (${response.status})`); + return response.text(); + }).catch((error) => { + detailCache.delete(url); + throw error; + }); + detailCache.set(url, content); + const oldest = detailCache.keys().next(); + if (detailCache.size > 24 && !oldest.done) detailCache.delete(oldest.value); + } else { + detailCache.delete(url); + detailCache.set(url, content); + } + return content; +} + +export async function renderDetails(detail: HTMLElement, title: string, variant: string, key: string, parentKey: string, onProblem: (name: string) => void) { + const request = ++renderRequest; + detail.replaceChildren(); + detail.onclick = (event) => { + if (!(event.target instanceof Element)) return; + const link = event.target.closest('a[href^="#"]'); + if (!link) return; + const anchor = link.getAttribute('href')?.slice(1); + if (!anchor) return; + const anchors = detailsIndex?.anchors; + const key = anchors && Object.hasOwn(anchors, anchor) ? anchors[anchor] : undefined; + if (key?.startsWith("problem:") && key !== detail.querySelector("article")?.dataset.contentKey) { + event.preventDefault(); + onProblem(key.slice("problem:".length)); + return; + } + if (link.getAttribute("role") === "doc-biblioref") { + event.preventDefault(); + const references = detail.querySelector(".detail-references"); + if (!references) return; + references.open = true; + references.scrollIntoView({ block: "nearest" }); + return; + } + const target = detail.querySelector(`[id="${CSS.escape(anchor)}"]`); + if (!target) { + if (key === detail.querySelector("article")?.dataset.contentKey) { + event.preventDefault(); + detail.scrollIntoView({ block: "start" }); + } + return; + } + event.preventDefault(); + const references = target.closest("details"); + if (references) references.open = true; + const focus = target.matches('sup') ? target.querySelector('a[href]') : target; + if (focus instanceof HTMLElement || focus instanceof SVGElement) { + if (!focus.matches('a[href], button, input, select, textarea, summary, [tabindex]')) focus.tabIndex = -1; + focus.focus({ preventScroll: true }); + } + target.scrollIntoView({ block: "nearest" }); + }; + + if (title) { + const heading = document.createElement("h2"); + heading.textContent = title; + detail.append(heading); + } + if (variant) { + const parameters = document.createElement("p"); + parameters.className = "detail-variant"; + parameters.textContent = variant; + detail.append(parameters); + } + const article = document.createElement("article"); + article.className = "typst-detail"; + article.textContent = "Loading documentation…"; + detail.append(article); + let index: GraphDetailsIndex; + try { + index = await loadDetailsIndex(); + } catch (error) { + if (request === renderRequest) { + article.textContent = errorMessage(error); + article.setAttribute("role", "alert"); + } + return; + } + if (request !== renderRequest || !article.isConnected) return; + const contentKey = index.entries[key] ? key : parentKey; + const available = Boolean(index.entries[contentKey]); + if (!available) { + const note = document.createElement("p"); + note.className = "detail-source"; + note.textContent = "No documentation is available for this entry or its parent family."; + detail.append(note); + } + article.dataset.contentKey = contentKey; + if (available) { + article.textContent = "Loading documentation…"; + article.setAttribute("aria-busy", "true"); + } + if (!available) { article.remove(); return; } + try { + const content = await loadDetail(contentKey); + if (!article.isConnected) return; + // HTML is generated at build time from the repository's Typst source. + article.innerHTML = content; + // Atlas embeds these sections directly below its page h1; Graph has a drawer h2. + if (detail.classList.contains('reference-detail')) for (const heading of article.querySelectorAll('h3')) { + const replacement = document.createElement('h2'); + for (const attribute of heading.attributes) replacement.setAttribute(attribute.name, attribute.value); + replacement.append(...heading.childNodes); heading.replaceWith(replacement); + } + article.querySelectorAll('pre').forEach(code => { code.tabIndex = 0; }); + normalizeReferenceRoles(article); + const citations = new Set([...article.querySelectorAll('a[role="doc-biblioref"]')] + .map((link) => link.getAttribute('href')?.slice(1))); + if (citations.size) { + const references = document.createElement("details"); + references.className = "detail-references"; + const summary = document.createElement('summary'); + summary.textContent = 'References'; + const body = document.createElement('div'); + references.append(summary, body); + article.after(references); + let loaded = false; + references.addEventListener("toggle", async () => { + if (!references.open || loaded) return; + body.textContent = "Loading references…"; + try { + body.innerHTML = await loadDetail("references"); + body.querySelectorAll("li").forEach((item) => { + if (!citations.has(item.id)) item.remove(); + }); + loaded = true; + } catch (error) { + body.textContent = errorMessage(error); + body.setAttribute("role", "alert"); + } + }); + } + if (article.querySelector('a[role="doc-noteref"]')) { + const notes = document.createElement("aside"); + notes.className = "typst-detail detail-footnotes"; + try { + notes.innerHTML = await loadDetail("footnotes"); + normalizeReferenceRoles(notes); + const used = new Set([...article.querySelectorAll('a[role="doc-noteref"]')].map((link) => link.getAttribute('href')?.slice(1))); + notes.querySelectorAll('li').forEach((item) => { + if (!used.has(item.id)) item.remove(); + }); + } catch (error) { + // The article is already rendered; only its footnotes degrade. + notes.textContent = errorMessage(error); + notes.setAttribute("role", "alert"); + } + if (!article.isConnected) return; + article.after(notes); + } + } catch (error) { + if (!article.isConnected) return; + article.textContent = errorMessage(error); + article.setAttribute("role", "alert"); + } finally { + article.removeAttribute("aria-busy"); + } +} diff --git a/docs/website/src/pages/graph-scene.ts b/docs/website/src/pages/graph-scene.ts new file mode 100644 index 000000000..de707c420 --- /dev/null +++ b/docs/website/src/pages/graph-scene.ts @@ -0,0 +1,370 @@ +// The scene is small and already laid out at build time; SVG owns hit testing and painting. +import { make } from './svg.ts'; + +export interface Point { x: number; y: number } +export interface SceneNodeInput extends Point { + id: string; label: string; size: number; color: string; deg: number; +} +export interface SceneNode extends Omit { + shape: SVGGElement; label: SVGTextElement; labelText: string; routeLabel: string; + labelKey: string | null; width: number; height: number; +} +export interface SceneEdge { + id: string; source: SceneNode; target: SceneNode; shape: SVGLineElement; hit: SVGLineElement; + label?: never; +} +export type SceneItem = SceneNode | SceneEdge; +interface Rectangle extends Point { w: number; h: number } +interface Bounds { x1: number; x2: number; y1: number; y2: number } +export type LabelSide = 'below' | 'above' | 'right' | 'left'; +interface Gesture { item: SceneItem | undefined; moved: boolean; start: Point } + +export class GraphScene { + zoom: number; + pan: Point; + animation: number; + intro: number; + svg: SVGSVGElement; + world: SVGGElement; + nodes: SceneNode[]; + edges: SceneEdge[]; + byId: Map; + elements: SceneItem[]; + measure: CanvasRenderingContext2D; + tabStop: SceneNode; + visibleRect?: () => Rectangle; + onNodeTap?: (node: SceneNode, options?: { keyboard: boolean }) => void; + onEdgeTap?: (edge: SceneEdge) => void; + onBackgroundTap?: () => void; + onChange?: () => void; + onDrag?: (node: SceneNode) => void; + onDragEnd?: (node: SceneNode) => void; + onHover?: (node: SceneNode, over: boolean) => void; + + constructor(container: HTMLElement, nodes: SceneNodeInput[], edges: { s: string; t: string }[]) { + this.zoom = 1; this.pan = {x: 0, y: 0}; this.animation = 0; this.intro = 0; + this.svg = make('svg', {class: 'graph-scene', role: 'group', + 'aria-label': 'Problem families and directed reductions. Arrows move between spatial neighbours. Page Up and Page Down visit every problem. Home and End jump to the first and last problem. Enter or Space opens controls.'}); + this.world = make('g'); + const defs = make('defs'), marker = make('marker', {id: 'route-arrow', viewBox: '0 -5 10 10', refX: 9, refY: 0, + markerWidth: 11, markerHeight: 11, orient: 'auto', markerUnits: 'userSpaceOnUse'}); + marker.append(make('path', {d: 'M0,-4.5 L10,0 L0,4.5 Z', fill: 'var(--accent)'})); + defs.append(marker); + // One arrowhead per growth class so the colour follows the edge without relying on context-stroke. + for (const cls of ['constant', 'linear', 'quadratic', 'cubic', 'higher', 'unknown']) { + const head = make('marker', {id: 'arrow-' + cls, viewBox: '0 -5 10 10', refX: 9, refY: 0, markerWidth: 7, markerHeight: 7, + orient: 'auto', markerUnits: 'userSpaceOnUse'}); + head.append(make('path', {d: 'M0,-4 L10,0 L0,4 Z', fill: `var(--g-${cls})`})); + defs.append(head); + } + this.svg.append(defs, this.world); container.append(this.svg); + this.nodes = nodes.map(data => { + const shape = make('g', {class: 'scene-node', 'data-node': data.id, role: 'button', tabindex: -1, + 'aria-haspopup': 'dialog', 'aria-label': data.label}); + const halo = make('circle', {class: 'node-halo', r: data.size / 2 + 6}); + const circle = make('circle', {class: 'node-disc', r: data.size / 2, fill: data.color}); + const label = make('text', {class: 'node-label', 'text-anchor': 'middle'}); + shape.append(halo, circle, label); + return {...data, shape, label, labelText: data.label, routeLabel: data.label, labelKey: null, width: 0, height: 0}; + }); + this.byId = new Map(this.nodes.map(n => [n.id, n])); + this.edges = edges.map(({s, t}) => { + const source = this.byId.get(s), target = this.byId.get(t); + if (!source?.label || !target?.label) throw new Error('A scene edge references a missing node'); + const id = s + '→' + t; + const edge = {id, source, target, + shape: make('line', {class: 'scene-edge', 'data-edge': id, 'aria-hidden': 'true'}), + hit: make('line', {class: 'edge-hit', 'data-hit': id, 'aria-hidden': 'true'})}; + this.byId.set(edge.id, edge); this.world.append(edge.hit, edge.shape); return edge; + }); + for (const node of this.nodes) this.world.append(node.shape); + this.elements = [...this.nodes, ...this.edges]; + const measure = document.createElement('canvas').getContext('2d'); + if (!measure) throw new Error('Canvas text measurement is unavailable'); + this.measure = measure; + this.updatePositions(); + + const point = (e: MouseEvent): Point => { const r = this.svg.getBoundingClientRect(); return {x: e.clientX - r.x, y: e.clientY - r.y}; }; + const pointers = new Map(); + let gesture: Gesture | undefined; + this.svg.addEventListener('pointerdown', e => { + if (e.button !== 0) return; + this.stop(); + const p = point(e); pointers.set(e.pointerId, p); + const target = e.target instanceof Element ? e.target.closest('[data-node], [data-edge], [data-hit]') : null; + const id = target?.getAttribute('data-node') || target?.getAttribute('data-edge') || target?.getAttribute('data-hit'); + gesture = {item: id ? this.byId.get(id) : undefined, + moved: pointers.size > 1, start: p}; + this.svg.setPointerCapture(e.pointerId); + }); + this.svg.addEventListener('pointermove', e => { + const old = pointers.get(e.pointerId); + if (!old || !gesture) return; + const before = [...pointers.values()], p = point(e); + pointers.set(e.pointerId, p); + const dx = p.x - old.x, dy = p.y - old.y; + if (pointers.size > 1) { + const after = [...pointers.values()]; + const [first, second] = before, [nextFirst, nextSecond] = after; + if (!first || !second || !nextFirst || !nextSecond) return; + const center = (a: Point, b: Point): Point => ({x: (a.x + b.x) / 2, y: (a.y + b.y) / 2}); + const distance = (a: Point, b: Point) => Math.hypot(a.x - b.x, a.y - b.y); + const a = center(first, second), b = center(nextFirst, nextSecond), previous = distance(first, second); + if (previous) this.zoomAt(this.zoom * distance(nextFirst, nextSecond) / previous, a); + this.setViewport(this.zoom, {x: this.pan.x + b.x - a.x, y: this.pan.y + b.y - a.y}); + gesture.item = undefined; gesture.moved = true; + } else if (Math.hypot(p.x - gesture.start.x, p.y - gesture.start.y) > 3 || gesture.moved) { + gesture.moved = true; + if (gesture.item?.label) { + this.moveNode(gesture.item, dx, dy); + } else this.setViewport(this.zoom, {x: this.pan.x + dx, y: this.pan.y + dy}); + } + }); + const release = (e: PointerEvent) => { + if (!pointers.has(e.pointerId) || !gesture) return; + pointers.delete(e.pointerId); + if (gesture.moved && gesture.item?.label) this.onDragEnd?.(gesture.item); + if (e.type === 'pointerup' && !gesture.moved) { + if (gesture.item?.label) this.onNodeTap?.(gesture.item); + else if (gesture.item) this.onEdgeTap?.(gesture.item); + else this.onBackgroundTap?.(); + } + if (pointers.size) gesture.moved = true; + }; + this.svg.addEventListener('pointerup', release); + this.svg.addEventListener('pointercancel', release); + // Roving tabindex: one Tab stop for the whole graph, arrows move between spatial neighbours. + const first = this.nodes[0]; + if (!first) throw new Error('A scene requires at least one node'); + this.tabStop = first; + this.tabStop.shape.setAttribute('tabindex', '0'); + this.svg.addEventListener('focusin', e => { + const node = this.nodeOf(e.target); + if (!node) return; + this.stop(); + this.setTabStop(node); + node.shape.classList.add('focused'); + node.shape.setAttribute('aria-label', this.describe(node)); + this.reveal(node); + this.onChange?.(); + }); + this.svg.addEventListener('focusout', e => { + this.nodeOf(e.target)?.shape.classList.remove('focused'); + this.onChange?.(); + }); + this.svg.addEventListener('click', e => { + const node = this.nodeOf(e.target); + if (node && e.detail === 0) this.onNodeTap?.(node, {keyboard: true}); + }); + this.svg.addEventListener('keydown', e => { + const node = this.nodeOf(e.target); + if (!node || e.altKey || e.ctrlKey || e.metaKey) return; + if (e.key === ' ') { e.preventDefault(); return; } + if (e.key === 'Enter') { e.preventDefault(); this.onNodeTap?.(node, {keyboard: true}); return; } + const index = this.nodes.indexOf(node); + const stops: Record = {Home: 0, End: this.nodes.length - 1, + PageDown: (index + 1) % this.nodes.length, + PageUp: (index + this.nodes.length - 1) % this.nodes.length}; + const next = stops[e.key]; + if (next != null) { e.preventDefault(); this.nodes[next]?.shape.focus(); return; } + const directions: Record = {ArrowRight: [1, 0], ArrowLeft: [-1, 0], ArrowDown: [0, 1], ArrowUp: [0, -1]}; + const direction = directions[e.key]; + if (!direction) return; + e.preventDefault(); + this.neighbour(node, direction)?.shape.focus(); + }); + this.svg.addEventListener('keyup', e => { + const node = this.nodeOf(e.target); + if (node && e.key === ' ' && !e.altKey && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); this.onNodeTap?.(node, {keyboard: true}); + } + }); + this.svg.addEventListener('wheel', e => { + e.preventDefault(); this.stop(); this.zoomAt(this.zoom * Math.exp(-e.deltaY * .002), point(e)); + }, {passive: false}); + for (const [event, over] of [['pointerover', true], ['pointerout', false]] as const) this.svg.addEventListener(event, e => { + const node = this.nodeOf(e.target); + if (node && (!(e.relatedTarget instanceof Node) || !node.shape.contains(e.relatedTarget))) this.onHover?.(node, over); + }); + } + + nodeOf(target: EventTarget | null): SceneNode | undefined { + const id = target instanceof Element ? target.closest('[data-node]')?.getAttribute('data-node') : null; + const item = id ? this.byId.get(id) : undefined; + return item?.label ? item : undefined; + } + setTabStop(node: SceneNode) { + if (this.tabStop === node) return; + this.tabStop.shape.setAttribute('tabindex', '-1'); + node.shape.setAttribute('tabindex', '0'); + this.tabStop = node; + } + // The spoken name includes whatever the route painting says about the node. + describe(node: SceneNode) { + const roles = ([['start', 'start of the route'], ['end', 'end of the route'], ['route', 'on the selected route']] as const) + .filter(([name]) => this.has(node, name)).map(([, text]) => text); + return [node.routeLabel.replace('\n', ', '), ...roles].join(', '); + } + // Nearest node inside a 90° cone around the arrow direction, measured on screen-independent world coordinates. + neighbour(node: SceneNode, [dx, dy]: [number, number]) { + let best: SceneNode | undefined, bestScore = Infinity; + for (const other of this.nodes) { + if (other === node) continue; + const along = (other.x - node.x) * dx + (other.y - node.y) * dy; + const across = Math.abs((other.x - node.x) * dy - (other.y - node.y) * dx); + if (along <= 0 || across > along) continue; + const score = along + 2 * across; + if (score < bestScore) { best = other; bestScore = score; } + } + return best; + } + // Keyboard focus must never land off-screen: pan the minimum distance to bring the node into view. + reveal(node: SceneNode) { + const box = this.svg.getBoundingClientRect(), p = this.position(node); + const r = this.visibleRect?.() || {x: 0, y: 0, w: box.width, h: box.height}; + this.labelBox(node); + const radius = (node.size / 2 + 6) * this.zoom; + const side = Math.min(Math.max(48, node.width / 2 + 12, radius + 12), r.w / 2); + const top = Math.min(Math.max(48, radius + 12), r.h / 2); + const bottom = Math.min(Math.max(48, node.size / 2 * this.zoom + node.height + 17), r.h / 2); + const shift = (value: number, low: number, high: number) => value < low ? low - value : value > high ? high - value : 0; + const x = shift(p.x, r.x + side, r.x + r.w - side), y = shift(p.y, r.y + top, r.y + r.h - bottom); + if (x || y) this.setViewport(this.zoom, {x: this.pan.x + x, y: this.pan.y + y}); + } + has(item: SceneItem, name: string) { return item.shape.classList.contains(name); } + classes(items: SceneItem[], remove: string, add = '') { + const removed = remove.split(' ').filter(Boolean), added = add.split(' ').filter(Boolean); + for (const item of items) { + for (const name of removed) if (this.has(item, name)) item.shape.classList.remove(name); + for (const name of added) if (!this.has(item, name)) item.shape.classList.add(name); + } + } + /* Draws nodes at `at(node)`, the laid-out position by default. The intro draws transient positions + and skips the invisible hit lines; the layout itself never moves. */ + updatePositions(at: (node: SceneNode) => Point = node => node, hits = true) { + for (const node of this.nodes) { const p = at(node); node.shape.setAttribute('transform', `translate(${p.x} ${p.y})`); } + for (const edge of this.edges) { + const a = at(edge.source), b = at(edge.target), d = Math.hypot(b.x-a.x, b.y-a.y) || 1, r = edge.target.size/2 + 4; + for (const line of hits ? [edge.shape, edge.hit] : [edge.shape]) { + line.setAttribute('x1', String(a.x)); line.setAttribute('y1', String(a.y)); + line.setAttribute('x2', String(b.x - (b.x-a.x)*r/d)); line.setAttribute('y2', String(b.y - (b.y-a.y)*r/d)); + } + } + } + moveNode(node: SceneNode, dx: number, dy: number) { + this.stop(); this.settle(); + node.x += dx / this.zoom; node.y += dy / this.zoom; + this.updatePositions(); this.onDrag?.(node); this.onChange?.(); + } + position(item: SceneItem): Point { + const x = item.label ? item.x : (item.source.x + item.target.x) / 2; + const y = item.label ? item.y : (item.source.y + item.target.y) / 2; + return {x: x * this.zoom + this.pan.x, y: y * this.zoom + this.pan.y}; + } + bounds(items: SceneItem[], rendered?: false): Bounds & { w: number; h: number }; + bounds(items: SceneItem[], rendered: true): Bounds; + bounds(items: SceneItem[], rendered = false) { + const nodes = items.flatMap(n => n.label ? [n] : [n.source, n.target]); + const x1 = Math.min(...nodes.map(n => n.x - n.size/2)), x2 = Math.max(...nodes.map(n => n.x + n.size/2)); + const y1 = Math.min(...nodes.map(n => n.y - n.size/2)), y2 = Math.max(...nodes.map(n => n.y + n.size/2)); + return rendered ? {x1: x1*this.zoom+this.pan.x, x2: x2*this.zoom+this.pan.x, + y1: y1*this.zoom+this.pan.y, y2: y2*this.zoom+this.pan.y} : {x1, x2, y1, y2, w: x2-x1, h: y2-y1}; + } + setViewport(zoom: number, pan: Point) { + this.zoom = zoom; this.pan = pan; + this.world.setAttribute('transform', `translate(${pan.x} ${pan.y}) scale(${zoom})`); + this.onChange?.(); + } + zoomAt(zoom: number, p: Point) { + zoom = Math.max(.1, Math.min(4, zoom)); + const scale = zoom / this.zoom; + this.setViewport(zoom, {x: p.x-(p.x-this.pan.x)*scale, y: p.y-(p.y-this.pan.y)*scale}); + } + stop() { cancelAnimationFrame(this.animation); this.animation = 0; } + /* Animates every node from `from(node)` to its laid-out position along an underdamped spring, each released + after `delay(node)` seconds. The closed-form spring keeps each frame O(n), and the layout itself never moves. */ + morph(from: (node: SceneNode) => Point, delay: (node: SceneNode) => number = () => 0) { + this.settle(); + const nodes = this.nodes, starts = nodes.map(from), delays = nodes.map(delay); + const zeta = .55, omega = 9, damped = omega * Math.sqrt(1 - zeta * zeta), settled = 1 + Math.max(0, ...delays); + const begin = performance.now(); + this.svg.classList.add('unfolding'); + const step = (now: number) => { + const elapsed = (now - begin) / 1000; + if (elapsed >= settled) { this.settle(); return; } + const shown = new Map(nodes.map((b, i) => { + const t = Math.max(0, elapsed - delays[i]!), a = starts[i]!; + const k = Math.exp(-zeta * omega * t) * (Math.cos(damped * t) + zeta * omega / damped * Math.sin(damped * t)); + return [b, {x: b.x + (a.x - b.x) * k, y: b.y + (a.y - b.y) * k}] as const; + })); + this.updatePositions(node => shown.get(node)!, false); + this.intro = requestAnimationFrame(step); + }; + step(begin); + } + /* Intro: every node starts in a cloud at the centre and is released in a ripple from the centre outwards. */ + unfold() { + const nodes = this.nodes; + const cx = nodes.reduce((s, p) => s + p.x, 0) / nodes.length, cy = nodes.reduce((s, p) => s + p.y, 0) / nodes.length; + const far = Math.max(1, ...nodes.map(p => Math.hypot(p.x - cx, p.y - cy))); + this.morph(p => ({x: cx + (p.x - cx) * .06 + (Math.random() - .5) * 24, y: cy + (p.y - cy) * .06 + (Math.random() - .5) * 24}), + p => .35 * Math.hypot(p.x - cx, p.y - cy) / far); + } + // Ends a morph at once; dragging a node needs the drawing to match the layout. + settle() { + if (!this.svg.classList.contains('unfolding')) return; + cancelAnimationFrame(this.intro); this.intro = 0; + this.svg.classList.remove('unfolding'); + this.updatePositions(); this.onChange?.(); + } + frame(zoom: number, pan: Point, animate: boolean) { + this.stop(); + if (!animate) { this.setViewport(zoom, pan); performance.mark('graph:camera-settled'); return; } + const start = performance.now(), z = this.zoom, p = this.pan; + const step = (now: number) => { + const t = Math.min(1, (now-start)/450), ease = t < .5 ? 4*t*t*t : 1-(-2*t+2)**3/2; + this.setViewport(z+(zoom-z)*ease, {x:p.x+(pan.x-p.x)*ease, y:p.y+(pan.y-p.y)*ease}); + this.animation = t < 1 ? requestAnimationFrame(step) : 0; + if (t === 1) performance.mark('graph:camera-settled'); + }; + this.animation = requestAnimationFrame(step); + } + /* Measures the label and places it on one side of its node, returning its screen box. */ + labelBox(node: SceneNode, side: LabelSide = 'below'): Bounds { + const important = ['route', 'start', 'end', 'hover', 'focused', 'reach'].some(c => this.has(node, c)); + const size = important ? 12 : 11, text = important ? node.routeLabel : node.labelText; + const key = size + ':' + text; + if (node.labelKey !== key) { + node.labelKey = key; + this.measure.font = `500 ${size}px "DM Sans", sans-serif`; + const lines = []; + for (const paragraph of text.split('\n')) { + let line = ''; + for (const word of paragraph.split(' ')) { + if (line && this.measure.measureText(line+' '+word).width > 180) { lines.push(line); line = word; } + else line += (line ? ' ' : '') + word; + } + lines.push(line); + } + node.width = Math.max(...lines.map(line => this.measure.measureText(line).width)); + node.height = lines.length * size * 1.25; + node.label.replaceChildren(...lines.map((line, i) => { + // Wrapped lines stay separated for assistive technology, which reads textContent. + const span = document.createElementNS('http://www.w3.org/2000/svg', 'tspan'); + span.setAttribute('x', '0'); span.setAttribute('y', String(5+size+i*size*1.25)); span.textContent = i < lines.length - 1 ? line + ' ' : line; return span; + })); + node.label.setAttribute('font-size', String(size)); + } + // The text block spans [5, 5 + height] below its origin; each side moves that origin in screen pixels. + const r = node.size/2, w = node.width, h = node.height; + const [ox, oy, dx, dy, anchor] = { + below: [0, r, 0, 0, 'middle'], above: [0, -r, 0, -h - 10, 'middle'], + right: [r, 0, 6, -5 - h/2, 'start'], left: [-r, 0, -6, -5 - h/2, 'end'], + }[side] as [number, number, number, number, string]; + node.label.setAttribute('text-anchor', anchor); + node.label.setAttribute('transform', `translate(${ox} ${oy}) scale(${1/this.zoom}) translate(${dx} ${dy})`); + const p = this.position(node), x = p.x + ox*this.zoom + dx, top = p.y + oy*this.zoom + dy + 5; + const x1 = anchor === 'middle' ? x - w/2 : anchor === 'start' ? x : x - w; + return {x1: x1-3, x2: x1+w+3, y1: top-3, y2: top+h+3}; + } +} diff --git a/docs/website/src/pages/graph-startup.ts b/docs/website/src/pages/graph-startup.ts new file mode 100644 index 000000000..5afc161a7 --- /dev/null +++ b/docs/website/src/pages/graph-startup.ts @@ -0,0 +1,24 @@ +import type { GraphSceneData } from '../generated/graph-scene.mjs'; + +// Start independent downloads before the renderer finishes loading. +export const graphStartup: {scene: Promise; worker: Worker | null} = { + scene: fetch(document.body.dataset.scene!).then(async (response) => { + if (!response.ok || !response.body) throw new Error(`Cannot load graph (${response.status})`); + const text = await new Response(response.body.pipeThrough(new DecompressionStream('gzip'))).text(); + performance.mark('graph:scene-decoded'); + const scene = JSON.parse(text) as GraphSceneData; + performance.mark('graph:scene-json-parsed'); + return scene; + }), + worker: null, +}; +graphStartup.scene.catch(() => {}); +if (location.hash === '#directions') try { + const worker = graphStartup.worker = new Worker(new URL('../workers/path-worker.ts', import.meta.url), {type: 'module'}); + performance.mark('graph:worker-started'); + worker.addEventListener('error', (event) => { + event.preventDefault(); + if (graphStartup.worker === worker) { worker.terminate(); graphStartup.worker = null; } + }); +} +catch { /* Path search reports worker creation errors when requested. */ } diff --git a/docs/website/src/pages/graph.ts b/docs/website/src/pages/graph.ts new file mode 100644 index 000000000..0a25eea2a --- /dev/null +++ b/docs/website/src/pages/graph.ts @@ -0,0 +1,1240 @@ +import { graphStartup } from './graph-startup.ts'; +import { renderDetails } from './details.ts'; +import { GraphScene, type SceneNode, type SceneItem, type SceneEdge, type LabelSide } from "./graph-scene.ts"; +import { growth, pathDegree, sizeBadge, compareDegrees } from "../lib/graph-growth.mts"; +import { CLASSES, growthClass, edgeGrowth, activeRules, components, reach, familyRules, depthsFrom, type ClassId, type RuleEdge } from '../lib/graph-structure.mts'; +import { validatePathReply, type PathRequest, type PathReply } from '../lib/path-worker-data.mts'; +import { createSimulation, settleSimulation, settled, type Point, type Simulation } from '../lib/force-layout.mts'; + +import type { Endpoint, Route, Step, WasmPathResult } from '../generated/wasm-paths.mjs'; +import type { WasmComparisonResult } from '../generated/wasm-comparison.mjs'; + +type Side = 'source' | 'target'; +interface Family { name: string; variants: number[]; nbrs: Set } +type PathResult = WasmPathResult & { polynomial_comparison?: WasmComparisonResult; order?: number[] }; +interface State { source: number | null; target: number | null; limit: number; result: PathResult | null; + selected: number | null; picked: boolean; showSlower: boolean; busy: boolean } + +type Bounds = ReturnType; + +(async () => { +"use strict"; +performance.mark("graph:script", {detail: {eager_worker: Boolean(graphStartup.worker)}}); +const $ = (s: string) => document.querySelector(s)!; +const el = (tag: T, cls?: string, text?: string) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; }; +const D = window.REDUCTIONS = await graphStartup.scene; +performance.mark("graph:scene-parsed"); +const defaults = Object.fromEntries(D.nodes.flatMap((n, i) => n.default ? [[n.name, i]] : [])); +const aliasMap = D.aliases; +const reduced = matchMedia("(prefers-reduced-motion: reduce)"); +const phone = matchMedia("(max-width: 760px)"); +// Keep the minimum supported 320px graph viewport beside the 392px and 456px side-panel areas. +const modalDetails = matchMedia("(max-width: 1167px)"); + +// The build validates scene references; the engine owns path correctness. +/* ---------- Registry model ---------- */ +const display = (name: string) => name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z])([A-Z][a-z])/g, "$1 $2"); +const families = new Map(); +D.nodes.forEach((n, i) => { + if (!families.has(n.name)) families.set(n.name, { name: n.name, variants: [], nbrs: new Set() }); + families.get(n.name)!.variants.push(i); +}); +const famEdges = new Map(); +D.edges.forEach((e) => { + const s = D.nodes[e.source]!.name, t = D.nodes[e.target]!.name; + if (s === t) return; + famEdges.set(s + "→" + t, { s, t }); + families.get(s)!.nbrs.add(t); families.get(t)!.nbrs.add(s); +}); +const multi = (name: string) => families.get(name)!.variants.length > 1; + +/* Route existence is measured on concrete variants, independent of display filters. + It does not certify polynomial reductions or registered solver capabilities. */ +type Status = "both" | "toilp" | "fromsat" | "none"; +type FamilyStatus = Status | "mixed"; +const HUBS = new Set(["ILP", "QUBO"]); +const STATUS: Record = { + both: { color: "#6aa64f", label: "Route from SAT · Route to ILP", detail: "A route leads from SAT to this variant and from this variant to ILP" }, + toilp: { color: "#4c93cf", label: "Route to ILP", detail: "A route leads to ILP; no route from SAT is registered" }, + fromsat: { color: "#d4688a", label: "Route from SAT", detail: "A route leads from SAT; no route to ILP is registered" }, + none: { color: "#5b665e", label: "No SAT / ILP route", detail: "No route from SAT or to ILP is registered" }, + mixed: { color: "#a39ac2", label: "Varies by variant", detail: "Variants have different routes; select a variant to inspect them" }, +}; +const HUB_COLOR = "#e8d56b"; +const variantIds = D.nodes.map((_, i) => i), witnessLinks = D.edges.filter(e => e.witness && !e.turing); +const fromSat = depthsFrom(variantIds, witnessLinks, variantIds.filter(i => D.nodes[i]!.name === "Satisfiability")); +const toIlp = depthsFrom(variantIds, witnessLinks.map(e => ({ source: e.target, target: e.source })), variantIds.filter(i => D.nodes[i]!.name === "ILP")); +const variantStatus = new Map(variantIds.map(i => [i, + (fromSat.get(i) != null ? toIlp.get(i) != null ? "both" : "fromsat" : toIlp.get(i) != null ? "toilp" : "none") as Status])); +// A family gets a single status only when all its variants agree. +const familyStatus = new Map([...families.values()].map(f => { + const statuses = new Set(f.variants.map(i => variantStatus.get(i)!)); + return [f.name, statuses.size === 1 ? variantStatus.get(f.variants[0]!)! : "mixed"] as [string, FamilyStatus]; +})); +const keyOf = (node: Endpoint) => node.name + "|" + Object.entries(node.variant).sort().map(([k, v]) => k + "=" + v).join(","); +const indexOf = new Map(D.nodes.map((n, i) => [keyOf(n), i])); + +/* Keyed variant text, e.g. graph: KingsSubgraph weight: i64 */ +function kvNode(variant: Endpoint['variant']) { + const span = el("span", "kv"); + Object.entries(variant).forEach(([k, v], i) => { if (i) span.append(" "); span.append(el("i", "", k + ": "), v); }); + return span; +} + +const prettyField = (f: string) => f.replace(/^num_/, "").replaceAll("_", " "); + +/* ---------- Graph ---------- */ +const nodeSize = (deg: number) => Math.max(6, Math.min(22, 6 + 2.4 * Math.sqrt(deg))); +const pos = new Map(); +for (const f of families.values()) pos.set(f.name, { x: D.layout[f.name]!.x * 1.6, y: D.layout[f.name]!.y * 1.6 }); +const scene = new GraphScene($("#cy"), [...families.values()].map(f => ({id: f.name, label: display(f.name), + size: nodeSize(f.nbrs.size), deg: f.nbrs.size, color: HUBS.has(f.name) ? HUB_COLOR : STATUS[familyStatus.get(f.name)!].color, ...pos.get(f.name)!})), [...famEdges.values()]); +scene.classes(scene.nodes.filter(n => HUBS.has(n.id)), "", "hub-node"); +performance.mark("graph:renderer-created"); +performance.mark("graph:constructed"); + +function freeRect() { + const W = innerWidth, H = innerHeight, top = $(".site-header").getBoundingClientRect().bottom; + const panel = $(".panel").getBoundingClientRect(); + if (phone.matches) return { x: 0, y: top, w: W, h: Math.max(1, panel.top - top) }; + const right = $("#details-drawer").hidden || modalDetails.matches ? W : $("#details-drawer").getBoundingClientRect().left - 16; + // A collapsed panel is only its search bar, so the canvas runs under it to the left edge. + const left = collapsed() ? 0 : 392, below = collapsed() ? panel.bottom : top; + return { x: left, y: below, w: Math.max(1, right - left), h: H - below }; +} +scene.visibleRect = freeRect; +function frame(eles: SceneItem[], maxZoom = 1.3, animate = !reduced.matches) { + if (!eles || !eles.length) return; + const r = freeRect(), b = scene.bounds(eles), pad = 90; + const zoom = Math.max(0.1, Math.min((r.w - 2 * pad) / Math.max(b.w, 1), (r.h - 2 * pad) / Math.max(b.h, 1), maxZoom)); + const pan = { x: r.x + r.w / 2 - (b.x1 + b.x2) / 2 * zoom, y: r.y + r.h / 2 - (b.y1 + b.y2) / 2 * zoom }; + scene.frame(zoom, pan, animate); +} + +/* Labels keep a constant screen size; important labels are placed first and any label that would collide is skipped. */ +const SIDES: LabelSide[] = ["below", "above", "right", "left"]; +// Labels along a straightened route sit across the chain, never on its own edges. +let routeSides = SIDES; +let labelRaf = 0; +const scheduleLabels = () => { cancelAnimationFrame(labelRaf); labelRaf = requestAnimationFrame(placeLabels); }; +function placeLabels() { + const r = freeRect(), boxes: Bounds[] = []; + const important = (n: SceneNode) => ["route", "start", "end", "hover", "focused", "reach"].some(c => scene.has(n, c)); + const overlaps = (b: Bounds, o: Bounds) => b.x1 < o.x2 + 6 && b.x2 > o.x1 - 6 && b.y1 < o.y2 + 4 && b.y2 > o.y1 - 4; + const hit = (b: Bounds) => boxes.some(o => overlaps(b, o)); + // Discs of the problems in focus are obstacles too, so their labels never cover each other's nodes. + const discs = scene.nodes.filter(important).map(n => { + const p = scene.position(n), d = n.size / 2 * scene.zoom; + return { n, b: { x1: p.x - d, x2: p.x + d, y1: p.y - d, y2: p.y + d } }; + }); + const clear = (n: SceneNode, b: Bounds) => !hit(b) && !discs.some(o => o.n !== n && overlaps(b, o.b)); + // The first side that is clear; an important label falls back to below. + const box = (n: SceneNode) => { + for (const side of scene.has(n, "route") ? routeSides : SIDES) { const b = scene.labelBox(n, side); if (clear(n, b)) return b; } + return scene.labelBox(n); + }; + const fits = (b: Bounds) => b.x1 > r.x + 4 && b.x2 < r.x + r.w - 4 && b.y1 > r.y + 4 && b.y2 < r.y + r.h - 4; + scene.classes(scene.nodes, "named"); + scene.nodes.filter(important).forEach(n => boxes.push(box(n))); + const named: SceneNode[] = []; + const cap = exploring && !ex.sel && !ex.query ? (phone.matches ? 6 : 12) : Infinity; + scene.nodes.filter(n => !important(n) && !scene.has(n, "dim") && !scene.has(n, "off")).sort((a,b) => b.deg-a.deg).forEach(n => { + const b = box(n); + if (named.length < cap && fits(b) && clear(n, b)) { boxes.push(b); named.push(n); } + }); + scene.classes(named, "", "named"); + placePop(); +} +scene.onChange = scheduleLabels; +scene.onDrag = node => { cancelLayout(); scheduleLabels(); placePop(); if (!reduced.matches) grab(node); }; +scene.onDragEnd = node => { if (physics) { physics.pin(node.id, null); physics.alphaTarget = 0; } }; +scene.onHover = (n, over) => { + scene.classes([n], over ? "" : "hover", over ? "hover" : ""); + if (exploring && !ex.sel && !ex.query) { + const nbrs = families.get(n.id)!.nbrs; + scene.classes(scene.edges, "lit quiet"); + if (over) { + scene.edges.forEach(e => scene.classes([e], "", e.source === n || e.target === n ? "lit" : "quiet")); + scene.nodes.forEach(o => scene.classes([o], "near", o !== n && nbrs.has(o.id) ? "near" : "")); + } else scene.classes(scene.nodes, "near"); + } + if (!state.result && !exploring) { + if (over) { + scene.classes(scene.elements.filter(e => e !== n && !families.get(n.id)!.nbrs.has(e.id)), "", "dim"); + scene.classes(scene.edges.filter(e => e.source === n || e.target === n), "dim", "nbr"); + } else scene.classes(scene.elements, "dim nbr"); + } + scheduleLabels(); +}; + +/* ---------- State ---------- */ +let exploring = true; +const state: State = { source: null, target: null, limit: 20, result: null, selected: null, picked: false, showSlower: false, busy: false }; + +/* Variant options: one row of pills per variant key. Picking a value keeps the other keys when such a variant exists. */ +function focusedVariantGroup(root: HTMLElement) { + const active = document.activeElement; + return active && root.contains(active) ? active.closest('[role="group"]')?.getAttribute('aria-label') : null; +} +function restoreVariantGroup(root: HTMLElement, name: string | null | undefined) { + if (name) [...root.querySelectorAll('[role="group"]')].find(group => group.getAttribute('aria-label') === name) + ?.querySelector('button[aria-pressed="true"]')?.focus({preventScroll: true}); +} +function variantOptions(container: HTMLElement, index: number, onPick: (index: number) => void) { + const focused = focusedVariantGroup(container); + container.replaceChildren(); + const fam = families.get(D.nodes[index]!.name)!; + const cur = D.nodes[index]!.variant; + const keys = [...new Set(fam.variants.flatMap((i) => Object.keys(D.nodes[i]!.variant)))]; + keys.forEach((k) => { + const values = [...new Set(fam.variants.map((i) => D.nodes[i]!.variant[k]).filter(Boolean))]; + if (values.length < 2) return; + const row = el("div", "opt-row"); row.setAttribute("role", "group"); row.setAttribute("aria-label", k); + row.append(el("span", "opt-key", k)); + values.forEach((v) => { + const b = el("button", "opt", v); b.type = "button"; + b.setAttribute("aria-pressed", String(cur[k] === v)); + b.addEventListener("pointerdown", (ev) => ev.preventDefault()); + b.addEventListener("click", (ev) => { + ev.stopPropagation(); + const score = (i: number) => Object.entries(cur).filter(([kk, vv]) => kk !== k && D.nodes[i]!.variant[kk] === vv).length + (i === defaults[fam.name]! ? 0.5 : 0); + onPick(fam.variants.filter((i) => D.nodes[i]!.variant[k] === v).sort((a, b) => score(b) - score(a))[0]!); + }); + row.append(b); + }); + container.append(row); + }); + container.hidden = !container.childElementCount; + restoreVariantGroup(container, focused); +} + +function syncEnds() { + for (const side of ["source", "target"] as const) { + const input = $("#" + side), opts = $("#" + side + "-options"), i = state[side]; + if (document.activeElement !== input) input.value = i == null ? "" : display(D.nodes[i]!.name); + if (i == null) { opts.hidden = true; opts.replaceChildren(); } else variantOptions(opts, i, (j) => setEnd(side, j)); + } + $("#swap").style.top = ($(".end.to").offsetTop - 15) + "px"; +} + +function setEnd(side: Side, index: number) { + state[side] = index; + closePop(); + syncEnds(); + if (state.source != null && state.target != null) run(); + else { + state.result = null; paintGraph(); renderResults(); + frame([scene.byId.get(D.nodes[index]!.name)!], 1.2); + if (side === "source") $("#target").focus(); + } +} + +/* ---------- Search fields ---------- */ +let suggestFor: Side | null = null, suggestRows: Family[] = [], active = 0; +function matches(q: string) { + q = q.trim().toLowerCase(); + const all = [...families.values()]; + if (!q) return all.sort((a, b) => b.nbrs.size - a.nbrs.size).slice(0, 7); + const rank = (f: Family) => { + const al = (aliasMap[f.name] || []).map((a) => a.toLowerCase()), name = display(f.name).toLowerCase(); + if (al.includes(q)) return 0; + if (name.startsWith(q)) return 1; + if (name.includes(q) || f.name.toLowerCase().includes(q)) return 2; + if (al.some((a) => a.startsWith(q))) return 3; + return 9; + }; + return all.map((f) => [rank(f), f] as const).filter(([r]) => r < 9).sort((a, b) => a[0] - b[0] || b[1].nbrs.size - a[1].nbrs.size).slice(0, 7).map(([, f]) => f); +} +function showSuggest(side: Side) { + suggestFor = side; + const input = $("#" + side), box = $("#suggest"); + suggestRows = matches(input.value === (state[side] == null ? "" : display(D.nodes[state[side]]!.name)) ? "" : input.value); + active = 0; + box.replaceChildren(); + if (!suggestRows.length) { + const empty = el("div", "note", "No matching problem"); + empty.setAttribute("role", "option"); empty.setAttribute("aria-disabled", "true"); box.append(empty); + } + $("#search-status").textContent = suggestRows.length ? "" : "No matching problem"; + suggestRows.forEach((f, i) => { + const b = el("button"); b.type = "button"; b.setAttribute("role", "option"); b.id = "opt-" + i; + b.append(el("span", "", display(f.name))); + const al = (aliasMap[f.name] || [])[0]; if (al) b.append(el("small", "", al)); + b.addEventListener("pointerdown", (e) => { e.preventDefault(); pick(i); }); + box.append(b); + }); + const end = input.closest(".end")!; + box.style.top = (end.offsetTop + 44) + "px"; + box.hidden = false; input.setAttribute("aria-expanded", "true"); + markActive(); +} +function markActive() { + [...$("#suggest").querySelectorAll("button")].forEach((b, i) => b.setAttribute("aria-selected", String(i === active))); + for (const side of ["source", "target"] as const) $("#" + side).removeAttribute("aria-activedescendant"); + if (suggestFor && suggestRows.length) $("#" + suggestFor).setAttribute("aria-activedescendant", "opt-" + active); +} +function hideSuggest() { + $("#suggest").hidden = true; $("#search-status").textContent = ""; + for (const side of ["source", "target"] as const) { + $("#" + side).setAttribute("aria-expanded", "false"); $("#" + side).removeAttribute("aria-activedescendant"); + } + suggestFor = null; +} +function pick(i: number) { const f = suggestRows[i]; if (!f) return; const side = suggestFor!; hideSuggest(); $("#" + side).blur(); setEnd(side, defaults[f.name]!); } +for (const side of ["source", "target"] as const) { + const input = $("#" + side); + input.addEventListener("focus", () => { input.select(); showSuggest(side); }); + input.addEventListener("input", () => showSuggest(side)); + input.addEventListener("blur", () => setTimeout(() => { + if (document.activeElement !== input && suggestFor === side) hideSuggest(); + syncEnds(); + }, 0)); + input.addEventListener("keydown", (e) => { + if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); active = (active + (e.key === "ArrowDown" ? 1 : -1) + suggestRows.length) % Math.max(1, suggestRows.length); markActive(); } + else if (e.key === "Enter") { e.preventDefault(); pick(active); } + else if (e.key === "Escape") { hideSuggest(); input.blur(); } + }); +} +$("#swap").addEventListener("click", () => { [state.source, state.target] = [state.target, state.source]; syncEnds(); if (state.source != null && state.target != null) run(); }); + +/* ---------- Node popover ---------- */ +let popNode: SceneNode | null = null, popAnchor: SceneItem | null = null, popIndex: number | null = null; +function routeVariantOf(name: string) { + const r = state.result?.paths[state.selected! - 1]; if (!r) return null; + const hit = endpoints(r).find((e) => e.name === name); + return hit ? indexOf.get(keyOf(hit)) : null; +} +function openPop(node: SceneNode, {keyboard = false} = {}) { + popNode = popAnchor = node; + const name = node.id; + popIndex = [state.source, state.target].find((i) => i != null && D.nodes[i]!.name === name) ?? routeVariantOf(name) ?? defaults[name]!; + renderPop(); + if (keyboard) $("#pop button").focus({preventScroll: true}); +} +function renderPop() { + const pop = $("#pop"), focusGroup = focusedVariantGroup(pop); + pop.replaceChildren(); + const title = el("h2", "", display(popNode!.id)); title.id = "pop-title"; pop.append(title); + const opts = el("div", "options"); variantOptions(opts, popIndex!, (j) => { popIndex = j; renderPop(); }); pop.append(opts); + const acts = el("div", "pop-actions"); + const a = el("button", "b-start", "Start"), b = el("button", "b-end", "End"); + a.type = b.type = "button"; + a.addEventListener("click", () => setEnd("source", popIndex!)); b.addEventListener("click", () => setEnd("target", popIndex!)); + acts.append(a, b); pop.append(acts); + const details = el("button", "pop-details", "Details"); details.type = "button"; + details.onclick = () => showProblem(D.nodes[popIndex!]!); pop.append(details); + pop.hidden = false; placePop(); + restoreVariantGroup(pop, focusGroup); +} +function placePop() { + if (!popAnchor || $("#pop").hidden) return; + const pop = $("#pop"), p = scene.position(popAnchor), r = freeRect(); + pop.style.maxHeight = Math.max(1, r.h - 24) + "px"; + const w = pop.offsetWidth, h = pop.offsetHeight; + let x = p.x + 20; if (x + w > r.x + r.w - 12) x = p.x - 20 - w; + pop.style.left = Math.max(r.x + 12, x) + "px"; + pop.style.top = Math.max(r.y + 12, Math.min(p.y - h / 2, r.y + r.h - h - 12)) + "px"; +} +function closePop(restoreFocus = false) { + const opener = popNode?.shape; + popNode = popAnchor = null; $("#pop").hidden = true; + if (restoreFocus) opener?.focus({preventScroll: true}); +} +scene.onNodeTap = (node, options) => exploring ? selectProblem(defaults[node.id]!) : openPop(node, options); +scene.onBackgroundTap = () => { closePop(); if (exploring) collapse(); }; +document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { closePop(true); closeDetails(); if (exploring && !document.activeElement!.matches("input")) collapse(); } + if (e.key === "/" && (!modalDetails.matches || $("#details-drawer").hidden) && !document.activeElement!.matches("input, textarea, [contenteditable]")) { e.preventDefault(); $(state.source == null ? "#source" : "#target").focus(); } +}); + +/* ---------- On-demand articles ---------- */ +const variantKey = (variant: Endpoint['variant']) => Object.entries(variant).sort().map(([k, v]) => k + "=" + v).join(","); +const detailId = (endpoint: Endpoint) => endpoint.name + "/" + variantKey(endpoint.variant); +let drawerOpener: HTMLElement | SVGElement | null = null; +const drawerBackground = new Map(); +function syncDrawerModal() { + const drawer = $("#details-drawer"), modal = modalDetails.matches && !drawer.hidden; + if (modal) { + drawer.setAttribute("aria-modal", "true"); + for (const node of document.body.children) { + if (!(node instanceof HTMLElement) || node === drawer || drawerBackground.has(node)) continue; + drawerBackground.set(node, node.inert); node.inert = true; + } + if (!drawer.contains(document.activeElement)) $("#close-details").focus({preventScroll: true}); + } else { + drawer.removeAttribute("aria-modal"); + for (const [node, inert] of drawerBackground) node.inert = inert; + drawerBackground.clear(); + } +} +function showDetails(title: string, key: string, parentKey: string) { + if (state.result) state.picked = true; + const drawer = $("#details-drawer"), body = $("#drawer-body"), atlas = $("#drawer-atlas"); + const opening = drawer.hidden; + if (opening) drawerOpener = $("#pop").contains(document.activeElement) ? popNode!.shape : document.activeElement as HTMLElement | SVGElement | null; + closePop(); drawer.hidden = false; $(".dock").hidden = true; + // The drawer narrows the free canvas; reframe only when it would cover part of the selected route. + if (opening && !modalDetails.matches && state.result && state.selected) { + const r = freeRect(), b = scene.bounds(routeEls(state.selected), true); + if (b.x1 < r.x || b.x2 > r.x + r.w || b.y1 < r.y || b.y2 > r.y + r.h) frame(routeEls(state.selected), 2.4); + } + $("#details-title").textContent = title; body.scrollTop = 0; + const endpoint = (value: string) => value.split("/").map(encodeURIComponent); + if (key.startsWith("problem:")) { + const [name, variant] = endpoint(key.slice(8)); + atlas.href = `./index.html#problem/${name}${variant ? `?variant=${variant}` : ""}`; + } else { + const [source, target] = key.slice(5).split("->").map(endpoint); + atlas.href = `./index.html#reduction/${source![0]}/${target![0]}?from=${source![1]}&to=${target![1]}`; + } + renderDetails(body, "", "", key, parentKey, (problem) => { + const [name, variant] = problem.split("/") as [string, string?]; + const index = variant == null ? defaults[name]! : D.nodes.findIndex((n) => n.name === name && variantKey(n.variant) === variant); + if (index == null || index < 0) return; + showProblem(D.nodes[index]!); pulseAndFrame(name); + }); + $("#close-details").focus({preventScroll: true}); syncDrawerModal(); scheduleLabels(); placePop(); +} +function showProblem(endpoint: Endpoint) { + showDetails(display(endpoint.name), "problem:" + detailId(endpoint), "problem:" + endpoint.name); +} +function showRule(step: Step) { + showDetails(display(step.from.name) + " → " + display(step.to.name), + `rule:${detailId(step.from)}->${detailId(step.to)}`, `rule:${step.from.name}->${step.to.name}`); +} +function closeDetails() { + if ($("#details-drawer").hidden) return; + $("#details-drawer").hidden = true; $(".dock").hidden = false; + syncDrawerModal(); + if (drawerOpener?.isConnected) drawerOpener.focus({preventScroll: true}); + scheduleLabels(); +} +function pulseAndFrame(name: string) { + const node = scene.byId.get(name)!; scene.classes([node], "", "pulse"); + setTimeout(() => scene.classes([node], "pulse"), 1000); frame([node], 1.6); +} +$("#close-details").onclick = closeDetails; +scene.onEdgeTap = (edge) => { + if (exploring) { const f = [...model.fam.values()].find(x => x.source === edge.source.id && x.target === edge.target.id); if (f) selectRules(f.source + "→" + f.target); return; } + if (!scene.has(edge, "route")) return; + const step = state.result?.paths[state.selected! - 1]!.path.find((s) => + s.from.name === edge.source.id && s.to.name === edge.target.id); + if (step) showRule(step); +}; +modalDetails.addEventListener("change", syncDrawerModal); +$("#details-drawer").addEventListener("keydown", (e) => { + if (!modalDetails.matches || e.key !== "Tab") return; + const items = [...$("#details-drawer").querySelectorAll('a[href], button, summary, [tabindex="0"]')].filter((n) => n.getClientRects().length); + const first = items[0], last = items.at(-1); + if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last!.focus(); } + else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first!.focus(); } +}); + +/* ---------- Path engine ---------- */ +let worker = graphStartup.worker, reqId = 0, clock = 0; +graphStartup.worker = null; +const cache = new Map(); +function stopWorker() { worker?.terminate(); worker = null; } +function startWorker(): Worker { + if (!worker) { + const engine = worker = new Worker(new URL('../workers/path-worker.ts', import.meta.url), { type: "module" }); + engine.onerror = event => { + event.preventDefault(); + if (worker === engine) stopWorker(); + }; + } + return worker; +} +function compute(source: number, target: number, limit: number, onPaths: (result: PathResult) => void): Promise { + const key = `${source}-${target}-${limit}`, cached = cache.get(key); + if (cached) { + cache.delete(key); cache.set(key, cached); + onPaths(cached); + if (cached.polynomial_comparison || !cached.paths.length) return Promise.resolve(cached); + } + const request: PathRequest = cached + ? {kind: 'compare', source, target, limit, + cached: {paths: cached.paths, truncated: cached.truncated}} + : {kind: 'search', source, target, limit}; + return new Promise((resolve, reject) => { + const engine = startWorker(); + let result = cached; + engine.onmessage = ({ data }: MessageEvent) => { + if (worker !== engine) return; + try { + const started = performance.now(); + const reply = validatePathReply(data, request, result); + performance.measure(`graph:${reply.phase}-validation`, {start: started}); + performance.mark(`graph:${reply.phase}-received`); + if ('error' in reply) { stopWorker(); reject(new Error(reply.error)); return; } + if (reply.phase === "paths") { + result = reply.result; cache.set(key, result); onPaths(result); + if (cache.size > 8) cache.delete(cache.keys().next().value!); + if (result.paths.length) return; + } else { + result!.polynomial_comparison = reply.result; + } + resolve(result!); + } catch (error) { + stopWorker(); reject(error); + } + }; + engine.onerror = (ev) => { ev.preventDefault(); if (worker !== engine) return; stopWorker(); reject(new Error("engine")); }; + performance.mark("graph:request-posted"); + engine.postMessage(request); + }); +} +function orderPaths(result: PathResult) { + const c = result.polynomial_comparison; + const degrees = c?.profiles.map(pathDegree), front = new Set(c?.front); + result.order = result.paths.map((_, i) => i + 1).sort((a, b) => + (c ? (Number(front.has(b)) - Number(front.has(a))) || compareDegrees(degrees![a - 1], degrees![b - 1]) : 0) || + result.paths[a - 1]!.steps - result.paths[b - 1]!.steps || a - b); +} +async function run() { + const id = ++reqId; + if (state.busy) stopWorker(); + clearInterval(clock); + state.result = null; state.selected = null; state.picked = false; state.showSlower = false; state.busy = true; + paintGraph(); renderResults(); + frame([scene.byId.get(D.nodes[state.source!]!.name)!, scene.byId.get(D.nodes[state.target!]!.name)!], 1.1); + if (state.source === state.target) { state.busy = false; renderResults("same"); return; } + const started = Date.now(); + clock = setInterval(() => { const s = $("#elapsed"); if (s) s.textContent = Math.round((Date.now() - started) / 1000) + " s"; }, 1000); + try { + const result = await compute(state.source!, state.target!, state.limit, (paths) => { + if (id !== reqId || !paths.paths.length) return; + orderPaths(paths); state.result = paths; + select(paths.order![0]!); + performance.mark("graph:routes-rendered"); + }); + if (id !== reqId) return; + clearInterval(clock); state.busy = false; + if (!result.paths.length) { renderResults("none"); paintGraph(); return; } + orderPaths(result); state.result = result; + // Until the user picks a route, show the best one; afterwards the comparison only re-ranks rows. + if (state.picked) { paintGraph(); renderResults(); } else select(result.order![0]!); + performance.mark("graph:comparison-rendered"); + } catch (err) { + if (id !== reqId) return; + clearInterval(clock); state.busy = false; state.result = null; state.selected = null; renderResults("error"); paintGraph(); + } +} +function cancel() { + reqId++; clearInterval(clock); stopWorker(); state.busy = false; state.result = null; state.selected = null; renderResults("cancelled"); paintGraph(); +} + +/* ---------- Path display ---------- */ +function endpoints(route: Route) { return [route.path[0].from, ...route.path.map((s) => s.to)]; } +function routeEls(n: number) { + const ids = new Set(); + state.result!.paths[n - 1]!.path.forEach((s) => { ids.add(s.from.name); ids.add(s.to.name); if (s.from.name !== s.to.name) ids.add(s.from.name + "→" + s.to.name); }); + return [...ids].map(id => scene.byId.get(id)!); +} +function paintGraph() { + if (exploring) return; + scene.classes(scene.elements, "dim route start end nbr " + classOrder.map(c => "e-" + c).join(" ")); + scene.nodes.forEach(n => { n.routeLabel = display(n.id); }); + if (state.result && state.selected) { + scene.classes(scene.elements, "", "dim"); + const selected = routeEls(state.selected); + scene.classes(selected, "dim", "route"); + // Each route edge takes the growth colour of the step it carries, matching the timeline badges. + state.result!.paths[state.selected - 1]!.path.forEach(step => { + if (step.from.name !== step.to.name) scene.classes([scene.byId.get(step.from.name + "→" + step.to.name)!], "", "e-" + stepGrowth(step).cls); + }); + // Selected paths stay above the rest of the scene for reliable edge hit testing. + selected.filter(n => !n.label).forEach(n => scene.world.insertBefore(n.shape, scene.nodes[0]!.shape)); + const labelled = new Set(); + endpoints(state.result!.paths[state.selected! - 1]!).forEach(e => { + if (!multi(e.name) || labelled.has(e.name)) return; + labelled.add(e.name); + const def = D.nodes[defaults[e.name]!]!.variant; + const diff = Object.entries(e.variant).filter(([k,v]) => def[k] !== v).map(([k,v]) => k + ": " + v); + if (diff.length) (scene.byId.get(e.name)! as SceneNode).routeLabel = display(e.name) + "\n" + diff.join(" "); + }); + } else if (state.source != null && state.target != null) scene.classes(scene.elements, "", "dim"); + for (const side of ["source", "target"] as const) if (state[side] != null) + scene.classes([scene.byId.get(D.nodes[state[side]]!.name)!], "dim", side === "source" ? "start" : "end"); + arrangeRoute(state.result && state.selected ? familyChain(state.result.paths[state.selected - 1]!) : []); + scene.nodes.forEach(node => node.shape.setAttribute('aria-label', scene.describe(node))); + scheduleLabels(); +} +function select(n: number) { + state.selected = n; + paintGraph(); renderResults(); + const focused = scene.nodeOf(document.activeElement); + if (focused) scene.reveal(focused); + else frame(routeEls(n), 2.4); +} + +/* Problem families along a route; consecutive variants of one family are a single stop. */ +function familyChain(route: Route) { + const names: string[] = []; endpoints(route).forEach((e) => { if (names.at(-1) !== e.name) names.push(e.name); }); + return names; +} +/* The ends are already in the search box, so a row says only what is in between; the timeline lists every stop. */ +function chain(route: Route) { + const names = familyChain(route), via = names.slice(1, -1); + const span = el("span", "chain", via.length ? `via ${display(via[0]!)}${via.length > 1 ? ` +${via.length - 1}` : ""}` : "Direct"); + span.title = names.map(display).join(" → "); + return span; +} +function pathRow(n: number, slower: boolean) { + const c = state.result!.polynomial_comparison, route = state.result!.paths[n - 1]!, open = state.selected === n; + const wrap = el("div"); + const b = el("button", "path" + (slower ? " slower" : "")); b.type = "button"; + b.setAttribute("aria-expanded", String(open)); b.dataset.path = String(n); + const size = el("span", "size" + (c ? "" : " pending"), c ? sizeBadge(pathDegree(c.profiles[n - 1]!)) : ""); + size.title = c && pathDegree(c.profiles[n - 1]!) == null ? "Size unavailable for one or more parameters" : "Largest growth of the reduced problem"; + const line = el("div", "path-line"); line.append(chain(route), size); + b.append(line); + b.addEventListener("click", () => { state.picked = true; if (state.selected !== n) select(n); }); + wrap.append(b); + if (open) wrap.append(pathDetail(n)); + return wrap; +} +function pathDetail(n: number) { + const route = state.result!.paths[n - 1]!, profile = state.result!.polynomial_comparison?.profiles[n - 1]!; + const d = el("div", "detail"); + const ol = el("ol", "steps"); + // Consecutive points of one family are variant conversions: show the family once with each variant it passes through. + // Every step, conversions included, carries its rule's growth on the line of the point it leaves. + const points = endpoints(route), groups: {name: string; points: number[]}[] = []; + points.forEach((e, k) => { if (groups.at(-1)?.name === e.name) groups.at(-1)!.points.push(k); else groups.push({ name: e.name, points: [k] }); }); + const stepLink = (k: number) => { + const step = route.path[k]!, g = stepGrowth(step), badge = overhead(g.cls, g.accuracy), rule = el("button", "rule-link"); rule.type = "button"; + const what = step.from.name === step.to.name ? `${display(step.from.name)} variant change` : `${display(step.from.name)} to ${display(step.to.name)}`; + rule.title = `Reduction rule: ${what}. ${badge.title}`; + rule.onclick = () => showRule(step); + rule.append(el("span", "sr-only", `Rule ${what}, `), badge); + return rule; + }; + const last = points.length - 1; + groups.forEach((g) => { + const li = el("li"), btn = el("button"); btn.type = "button"; + btn.append(el("span", "name", display(g.name))); + btn.addEventListener("click", () => { showProblem(points[g.points[0]!]!); pulseAndFrame(g.name); }); + li.append(el("span", "dot"), btn); + if (!multi(g.name)) { if (g.points.at(-1)! < last) li.append(stepLink(g.points.at(-1)!)); } + else g.points.forEach((k) => { + const line = el("button", "variant-line"); line.type = "button"; line.append(kvNode(points[k]!.variant)); + line.addEventListener("click", () => { showProblem(points[k]!); pulseAndFrame(g.name); }); + li.append(line); + if (k < last) li.append(stepLink(k)); + }); + ol.append(li); + }); + const dl = el("dl", "growth"); + Object.entries(profile?.fields || {}).forEach(([name, f]) => dl.append(el("dt", "", prettyField(name)), el("dd", "", growth(f).lead))); + const label = "Copy route for pred reduce --via"; + const copy = el("button", "copy link", label); copy.type = "button"; + copy.addEventListener("click", async () => { + state.picked = true; + const json = JSON.stringify({ steps: route.steps, path: route.path }, null, 2); + try { await navigator.clipboard.writeText(json); copy.textContent = "Copied"; setTimeout(() => (copy.textContent = label), 1500); } + catch { const t = el("textarea", "copy-text"); t.readOnly = true; t.value = json; copy.after(t); t.select(); } + }); + d.append(ol); + if (dl.children.length) d.append(el("p", "growth-title", "Result size"), dl); + d.append(copy); + return d; +} +function examples() { + const c = el("div", "chips"); + ([["SAT → QUBO", "Satisfiability", "QUBO"], ["MIS → QUBO", "MaximumIndependentSet", "QUBO"], ["Factoring → ILP", "Factoring", "ILP"], ["HC → QUBO", "HamiltonianCircuit", "QUBO"]] as const).forEach(([label, s, t]) => { + const b = el("button", "chip", label); b.type = "button"; + b.addEventListener("click", () => { state.source = defaults[s]!; state.target = defaults[t]!; state.limit = 20; syncEnds(); run(); }); + c.append(b); + }); + return c; +} +function renderResults(status?: 'same' | 'none' | 'error' | 'cancelled') { + const box = $("#results"); box.replaceChildren(); + if (state.source == null || state.target == null) { + const n = el("div", "note"); n.append(el("p", "", "Pick a start and an end problem, by searching or by clicking the graph. Try:"), examples()); box.append(n); return; + } + if (state.busy && !state.result) { + const s = el("div", "summary"), left = el("span", "", "Comparing paths "), t = el("span", "", ""); t.id = "elapsed"; left.append(t); + const c = el("button", "link", "Cancel"); c.type = "button"; c.addEventListener("click", cancel); s.append(left, c); + box.append(s); for (let i = 0; i < 4; i++) box.append(el("div", "skeleton")); return; + } + const msg = status && { same: "Start and end are the same problem variant.", none: "No reduction path connects these two. Try another variant of either problem.", + error: "Cannot load the path engine. Reload this release and try again.", cancelled: "Search cancelled." }[status]; + if (msg) { + const n = el("div", "note"); n.append(el("p", "", msg)); + if (status === "error") n.append(examples()); + if (status === "cancelled") { const r = el("button", "link", "Search again"); r.type = "button"; r.onclick = run; n.append(r); } + box.append(n); return; + } + if (!state.result) return; + const c = state.result!.polynomial_comparison; + // Merge same-chain variants by rank, retaining the exact route currently selected. + const seen = new Set(), chainKey = (n: number) => endpoints(state.result!.paths[n - 1]!).map((e) => e.name).filter((x, i, a) => x !== a[i - 1]).join(">"); + const selectedChain = chainKey(state.selected!); + const shown = state.result.order!.filter((n) => { + const k = chainKey(n); + if (seen.has(k) || (k === selectedChain && n !== state.selected)) return false; + seen.add(k); return true; + }); + const onFront = new Set(c?.front); + const front = c ? shown.filter((n) => onFront.has(n)) : shown, slower = c ? shown.filter((n) => !onFront.has(n)) : []; + const s = el("div", "summary"), left = el("span"); + if (c) left.append(el("strong", "", `${front.length} size-bound candidate${front.length === 1 ? "" : "s"}`), ` of ${shown.length}`); + else { const elapsed = el("span"); elapsed.id = "elapsed"; left.append("Comparing paths ", elapsed); } + s.append(left); + if (state.busy) { + const cancelButton = el("button", "link", "Cancel"); cancelButton.type = "button"; cancelButton.onclick = cancel; s.append(cancelButton); + } else if (state.result.truncated && state.limit < 999) { + const more = el("button", "link", "Search more"); more.type = "button"; + more.addEventListener("click", () => { state.limit = state.limit < 50 ? 50 : state.limit < 100 ? 100 : 999; run(); }); + s.append(more); + } + box.append(s); + if (c) box.append(el("p", "note", "Size bounds compare the routes found for general parameter growth. Special inputs can change the order.")); + front.forEach((n) => box.append(pathRow(n, false))); + if (slower.length && !front.length) slower.forEach((n) => box.append(pathRow(n, true))); + else if (slower.length) { + const selectedSlower = !onFront.has(state.selected!), open = state.showSlower || selectedSlower; + const t = el("button", "link more", open ? "Hide other routes" : `Show ${slower.length} other route${slower.length === 1 ? "" : "s"}`); t.type = "button"; + t.addEventListener("click", () => { state.showSlower = !open; if (!state.showSlower && selectedSlower) { select(front[0]!); return; } renderResults(); }); + box.append(t); + if (open) slower.forEach((n) => box.append(pathRow(n, true))); + } +} + + +/* ---------- Explore ---------- */ +/* Analysis runs on concrete variants. A family link only groups the exact variant rules behind it. */ +const rules = D.edges as RuleEdge[]; +// Routes follow solution-mapping rules; a registry has at most one per exact variant pair. +const stepRule = new Map(rules.flatMap((r, i) => r.witness ? [[r.source + ">" + r.target, i] as const] : [])); +const stepGrowth = (step: Step) => { + const r = stepRule.get(indexOf.get(keyOf(step.from)) + ">" + indexOf.get(keyOf(step.to))); + return r == null ? { cls: "unknown" as ClassId, accuracy: "exact" as const } : edgeGrowth(rules[r]!); +}; +type Reach = "direct" | "down" | "up"; +type Selection = { kind: "problem"; variant: number; reach: Reach; dir?: "out" | "in" } | { kind: "rules"; key: string; from?: number; index?: number } | null; +/* `open`: the browse lists are showing; `view`: which problems the map lays out. */ +type View = "all" | "nohub" | "fromsat"; +interface Explore { turing: boolean; limit: ClassId; tab: "problems" | "rules"; query: string; sel: Selection; all: boolean; open: boolean; view: View } +const ex: Explore = { turing: false, limit: "unknown", tab: "problems", query: "", sel: null, all: false, open: false, view: "all" }; +const VIEWS: Record boolean }> = { + all: { label: "All rules", keep: () => true }, + nohub: { label: "Without ILP & QUBO", keep: name => !HUBS.has(name) }, + fromsat: { label: "With routes from SAT", keep: name => families.get(name)!.variants.some(i => fromSat.get(i) != null) }, +}; +const inView = (name: string) => VIEWS[ex.view].keep(name); +/* With nothing to show, the panel folds to its search bar and the graph takes the page. */ +const collapsed = () => exploring && !ex.open && !ex.sel && !ex.query.trim(); +const classOrder = CLASSES.map(c => c.id); +const CLEAN = "turing dim route start end nbr off reach sel hub lit quiet near " + classOrder.map(c => "e-" + c).join(" "); +const vtext = (i: number) => Object.entries(D.nodes[i]!.variant).map(([k, v]) => k + ": " + v).join(" ") || "default"; +const badge = (cls: ClassId) => { + const c = CLASSES.find(x => x.id === cls)!, b = el("span", "gc g-" + cls, c.label); + b.title = c.name; return b; +}; +const famText = (name: string) => [display(name), name, ...(aliasMap[name] ?? [])].map(t => t.toLowerCase()); +const TURING_HINT = "Turing reduction: solves an optimization problem by asking its yes/no version (is there a solution of value at least k?) several times, with binary search on k. Routes skip these because they need many solver calls."; +const matchesQuery = (name: string) => { const q = ex.query.trim().toLowerCase(); return !q || famText(name).some(t => t.includes(q)); }; +const pluralize = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`; + +function buildModel() { + /* Solution-mapping rules always; Turing rules only when asked for. */ + const inView = (limit: ClassId) => activeRules(rules, { scope: ex.turing ? ["witness", "turing"] : "witness", limit }); + const active = inView(ex.limit), links = active.map(i => rules[i]!); + const fam = familyRules(D.nodes, rules, active), groups = components(D.nodes.length, links); + const counts = new Map(); + inView("unknown").forEach(i => { const c = edgeGrowth(rules[i]!).cls; counts.set(c, (counts.get(c) ?? 0) + 1); }); + const out = new Map(), inn = new Map(); + active.forEach(i => { + const r = rules[i]!; + (out.get(r.source) ?? out.set(r.source, []).get(r.source)!).push(i); + (inn.get(r.target) ?? inn.set(r.target, []).get(r.target)!).push(i); + }); + const degree = new Map(); + fam.forEach(f => { degree.set(f.source, (degree.get(f.source) ?? 0) + 1); degree.set(f.target, (degree.get(f.target) ?? 0) + 1); }); + const core = new Set([...degree].sort((a, b) => b[1] - a[1]).slice(0, 40).map(([n]) => n)); + return { active, links, fam, groups, counts, out, inn, degree, core }; +} +let model = buildModel(); +const ruleClass = (i: number) => edgeGrowth(rules[i]!).cls; +const endpoint = (i: number) => ({ name: D.nodes[i]!.name, variant: D.nodes[i]!.variant }); +const ruleStep = (i: number): Step => ({ from: endpoint(rules[i]!.source), to: endpoint(rules[i]!.target) }); +const reachOf = (v: number, dir: "down" | "up") => reach(D.nodes.length, model.links, v, dir); + +/* Frame the dense middle; a few far-flung isolated problems should not shrink everything else. */ +function frameAll(animate = !reduced.matches) { + const linked = scene.nodes.filter(n => families.get(n.id)!.nbrs.size > 1 && inView(n.id)); + const cx = linked.reduce((a, n) => a + n.x, 0) / linked.length, cy = linked.reduce((a, n) => a + n.y, 0) / linked.length; + const near = linked.map(n => [Math.hypot(n.x - cx, n.y - cy), n] as const).sort((a, b) => a[0] - b[0]); + frame(near.slice(0, Math.ceil(near.length * (phone.matches ? .5 : .85))).map(([, n]) => n), 1.6, animate); +} + +/* Canvas state follows the selection; with none, rules are faint and ILP's are fainter still. */ +function paintExplore() { + scene.classes(scene.elements, CLEAN); + scene.nodes.forEach(n => { n.routeLabel = display(n.id); }); + const sel = ex.sel, q = ex.query.trim(); + let litRules: Set | null = null, litNodes: Set | null = null, selected = ""; + if (sel?.kind === "problem") { + const v = sel.variant, near = new Set([v]); + selected = D.nodes[v]!.name; + if (sel.reach === "direct") { + litRules = new Set([...(model.out.get(v) ?? []), ...(model.inn.get(v) ?? [])]); + litRules.forEach(i => { near.add(rules[i]!.source); near.add(rules[i]!.target); }); + } else { + reachOf(v, sel.reach).forEach(w => near.add(w)); + litRules = new Set(model.active.filter(i => near.has(rules[i]!.source) && near.has(rules[i]!.target))); + } + litNodes = new Set([...near].map(i => D.nodes[i]!.name)); + } else if (sel) { + const f = ruleGroup(sel.key); + if (f) { litRules = new Set(f.rules); litNodes = new Set([f.source, f.target]); } + } else if (q) litNodes = new Set([...families.keys()].filter(matchesQuery)); + scene.edges.forEach(edge => { + const f = model.fam.get(edge.id); + if (!f || !inView(f.source) || !inView(f.target)) { scene.classes([edge], "", "off"); return; } + scene.classes([edge], "", "e-" + f.cls + (f.rules.every(i => rules[i]!.turing) ? " turing" : "")); + if (litRules) scene.classes([edge], "", f.rules.some(i => litRules.has(i)) ? "lit" : "dim"); + else if (litNodes) scene.classes([edge], "", litNodes.has(f.source) && litNodes.has(f.target) ? "lit" : "dim"); + else if (HUBS.has(edge.source.id) || HUBS.has(edge.target.id)) scene.classes([edge], "", "hub"); + else if (!(model.core.has(f.source) && model.core.has(f.target))) scene.classes([edge], "", "quiet"); + }); + scene.nodes.forEach(node => { + if (!inView(node.id)) scene.classes([node], "", "off"); + else if (litNodes) scene.classes([node], "", litNodes.has(node.id) ? "reach" : "dim"); + else if (!model.degree.has(node.id)) scene.classes([node], "", "dim"); + if (node.id === selected) scene.classes([node], "", "sel"); + node.shape.setAttribute("aria-label", scene.describe(node)); + }); + scheduleLabels(); +} + +// Force-layout neighbourhoods are compact, so a selection may zoom in further than the whole map. +function focusLit() { frame(scene.nodes.filter(n => scene.has(n, "reach")), 2.4); } +/* The active rules between two problems. Variant rules inside one family have no graph edge, so they are found by name. */ +function ruleGroup(key: string) { + const f = model.fam.get(key); + if (f) return f; + const [source, target] = key.split("→"), group = model.active.filter(i => D.nodes[rules[i]!.source]!.name === source && D.nodes[rules[i]!.target]!.name === target); + return group.length ? { source: source!, target: target!, rules: group } : undefined; +} +function selectProblem(variant: number, reach: Reach = "direct", focus = true) { + if (!inView(D.nodes[variant]!.name)) setView("all"); + ex.sel = { kind: "problem", variant, reach }; ex.all = false; closeDetails(); + paintExplore(); renderExplore(); + if (focus) focusLit(); +} +function selectRules(key: string, from?: number, index?: number) { + ex.sel = { kind: "rules", key, ...(from == null ? {} : { from }), ...(index == null ? {} : { index }) }; + paintExplore(); renderExplore(); focusLit(); +} +/* Leaving a card goes back to the lists it was reached from. */ +function clearSelection() { ex.sel = null; ex.open = true; paintExplore(); renderExplore(); } +/* Back to the bare map: no selection, no lists. A typed query stays, since the list is its answer. */ +function collapse() { + if (!ex.sel && !ex.open) return; + ex.sel = null; ex.open = false; closeDetails(); paintExplore(); renderExplore(); +} +function refreshExplore() { model = buildModel(); paintExplore(); renderExplore(); renderLegend(); } + +$("#turing-toggle").title = TURING_HINT; +$("#turing-toggle").addEventListener("click", () => { ex.turing = !ex.turing; ex.sel = null; refreshExplore(); }); +/* Each view keeps a subset and lays it out with the same force model as the published map, so taking the hubs + away shows the structure they hide. */ +const published = new Map(scene.nodes.map(n => [n.id, {x: n.x, y: n.y}])); +let layout: AbortController | null = null; +function cancelLayout() { layout?.abort(); layout = null; } +function finishLayout(sim: Simulation, apply: (positions: Map) => void) { + cancelLayout(); + const pending = layout = new AbortController(); + void settleSimulation(sim, pending.signal).then(done => { + if (!done) return; + layout = null; + apply(sim.positions()); + }); +} +function setView(view: View) { + if (view === ex.view) return; + cancelLayout(); scene.settle(); + const before = new Map(scene.nodes.map(n => [n.id, {x: n.x, y: n.y}])); + ex.view = view; ex.sel = null; physics = null; + paintExplore(); renderExplore(); renderLegend(); + const apply = (target: ReadonlyMap) => { + scene.nodes.forEach(n => Object.assign(n, target.get(n.id) ?? before.get(n.id))); + frameAll(); + if (reduced.matches) scene.updatePositions(); else scene.morph(n => before.get(n.id)!); + }; + if (view === "all") apply(published); + else finishLayout(createSimulation(physicsInput(before)), apply); +} +/* ---------- Live physics ---------- */ +/* A dragged problem pulls its neighbours along. The loop stops once the model is at rest. */ +let physics: Simulation | null = null, physicsFrame = 0; +const physicsInput = (start: ReadonlyMap) => ({ ids: scene.nodes.filter(n => inView(n.id)).map(n => n.id), hubs: HUBS, + links: scene.edges.map(e => [e.source.id, e.target.id] as const), radius: (id: string) => nodeSize(families.get(id)!.nbrs.size) / 2, start }); +function kick() { if (!physicsFrame) physicsFrame = requestAnimationFrame(stepPhysics); } +function stepPhysics() { + physicsFrame = 0; + if (!physics) return; + physics.tick(); + const at = physics.positions(); + scene.nodes.forEach(n => { const p = at.get(n.id); if (p) { n.x = p.x; n.y = p.y; } }); + scene.updatePositions(); scheduleLabels(); placePop(); + if (settled(physics)) physics = null; else kick(); +} +/* ---------- Straightened routes ---------- */ +/* The selected route is drawn as an evenly spaced chain from its start toward its end, so its own edges never cross. + The rest of the map relaxes around the pinned chain from the published layout; a gentle start keeps its shape. */ +const ROUTE_GAP = 48; +let arranged = ""; +function routeLayout(names: string[]) { + const a = published.get(names[0]!)!, b = published.get(names.at(-1)!)!; + const d = Math.hypot(b.x - a.x, b.y - a.y), ux = d ? (b.x - a.x) / d : 1, uy = d ? (b.y - a.y) / d : 0; + // Discs keep a clear gap between them; a long route keeps its ends where they are and spreads the stops between. + const radius = (id: string) => nodeSize(families.get(id)!.nbrs.size) / 2, at = [0]; + names.slice(1).forEach((id, i) => at.push(at[i]! + radius(names[i]!) + radius(id) + ROUTE_GAP)); + const len = at.at(-1)!, scale = Math.max(1, d / len), mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2; + const sim = createSimulation(physicsInput(published)); + names.forEach((id, i) => { const t = (at[i]! - len / 2) * scale; sim.pin(id, { x: mx + ux * t, y: my + uy * t }); }); + sim.alpha = 0.3; + sim.tick(); + // A label below a stop clears the chain only when it runs nearly level; otherwise it goes beside the stop. + return { sim, sides: Math.abs(uy) > 0.4 * Math.abs(ux) ? ["right", "left", "below", "above"] as LabelSide[] : SIDES }; +} +function arrangeRoute(names: string[]) { + const key = names.join(">"); + if (key === arranged) return; + arranged = key; physics = null; cancelLayout(); + const before = new Map(scene.nodes.map(n => [n.id, {x: n.x, y: n.y}])); + const apply = (positions: ReadonlyMap) => { + scene.nodes.forEach(n => Object.assign(n, positions.get(n.id) ?? before.get(n.id))); + if (reduced.matches) scene.updatePositions(); else scene.morph(n => before.get(n.id)!); + scheduleLabels(); + }; + routeSides = SIDES; + if (names.length < 2) { apply(published); return; } + const { sim, sides } = routeLayout(names); + routeSides = sides; + // Pin the route immediately so its camera and controls work while the surrounding map settles. + const pinned = sim.positions(); + names.forEach(id => Object.assign(scene.byId.get(id)!, pinned.get(id))); + if (reduced.matches) scene.updatePositions(); + finishLayout(sim, apply); +} +/* Dragging starts from the drawing as it is, so only the neighbourhood of the dragged problem reacts. */ +function grab(node: SceneNode) { + if (!physics) { physics = createSimulation(physicsInput(new Map(scene.nodes.map(n => [n.id, {x: n.x, y: n.y}])))); physics.alpha = 0; } + physics.alphaTarget = 0.3; + physics.pin(node.id, { x: node.x, y: node.y }); + kick(); +} +function renderViews() { + $("#views").replaceChildren(...(Object.keys(VIEWS) as View[]).map(id => { + const b = el("button", "", VIEWS[id].label); b.type = "button"; b.setAttribute("aria-pressed", String(ex.view === id)); + b.addEventListener("click", () => setView(id)); return b; + })); + const counts = new Map(); + familyStatus.forEach((st, name) => { if (!HUBS.has(name)) counts.set(st, (counts.get(st) ?? 0) + 1); }); + const swatch = (color: string, label: string, n: number | null, title: string) => { + const item = el("span", "status-item"); item.title = title; item.style.setProperty("--c", color); + item.append(el("i"), label); if (n != null) item.append(el("b", "", String(n))); return item; + }; + $("#status-legend").replaceChildren( + ...(Object.keys(STATUS) as FamilyStatus[]).map(st => swatch(STATUS[st].color, STATUS[st].label, counts.get(st) ?? 0, st === "mixed" ? STATUS[st].detail : "Every variant: " + STATUS[st].detail)), + swatch(HUB_COLOR, "Common targets", null, "ILP and QUBO; inspect each variant for its routes")); +} +function renderLegend() { + renderViews(); + const box = $("#growth-legend"), at = classOrder.indexOf(ex.limit); + const turing = $("#turing-toggle"); + turing.classList.toggle("on", ex.turing); turing.setAttribute("aria-checked", String(ex.turing)); + box.replaceChildren(...CLASSES.flatMap((c, i) => { + const n = model.counts.get(c.id) ?? 0; + if (!n) return []; + const b = el("button", "legend-chip g-" + c.id + (i <= at ? " on" : "")); b.type = "button"; b.setAttribute("role", "radio"); + b.setAttribute("aria-checked", String(c.id === ex.limit && ex.limit !== "unknown")); + b.title = `${c.name}: ${n} reductions. Show those up to this growth.`; + b.append(el("b", "", c.label), el("span", "", String(n))); + b.addEventListener("click", () => { ex.limit = ex.limit === c.id ? "unknown" : c.id; ex.sel = null; refreshExplore(); }); + return [b]; + })); +} + +/* ---------- Explore panel ---------- */ +/* Find: search plus two browsable lists. Explore: one place at a time. */ +function renderExplore(enter = true) { + const body = $("#explore-body"), tabs = $("#tabs"), keep = body.scrollTop; + body.replaceChildren(); + $(".panel").classList.toggle("collapsed", collapsed()); + tabs.hidden = !!ex.sel; + if (collapsed()) { scheduleLabels(); return; } + if (ex.sel?.kind === "problem") placeCard(body, ex.sel); + else if (ex.sel) ruleCard(body, ex.sel); + else { renderTabs(tabs); list(body); } + body.scrollTop = enter ? 0 : keep; + if (enter && !reduced.matches) { body.classList.remove("enter"); void body.offsetWidth; body.classList.add("enter"); } +} +const problemMatches = () => { + const q = ex.query.trim().toLowerCase(); + const score = (n: string) => !q ? 0 : famText(n).some(t => t === q) ? 0 : famText(n).some(t => t.startsWith(q)) ? 1 : 2; + return [...families.keys()].filter(matchesQuery).sort((a, b) => score(a) - score(b) || display(a).localeCompare(display(b))); +}; +const ruleMatches = () => [...model.fam.values()].filter(f => matchesQuery(f.source) || matchesQuery(f.target)) + .sort((a, b) => display(a.source).localeCompare(display(b.source)) || display(a.target).localeCompare(display(b.target))); +const dir = (sel: Extract) => sel.dir ?? ((model.out.get(sel.variant) ?? []).length || !(model.inn.get(sel.variant) ?? []).length ? "out" : "in"); +const letter = (name: string) => display(name)[0]!.toUpperCase(); +/* Only what differs from the family's default variant; the default itself needs no label. */ +function variantTokens(i: number, all = false) { + const n = D.nodes[i]!, base = D.nodes[defaults[n.name]!]!.variant, box = el("span", "vars"); + Object.entries(n.variant).filter(([k, v]) => all || base[k] !== v).forEach(([k, v]) => { const t = el("span", "var"); t.append(el("i", "", k), v); box.append(t); }); + return box; +} +function overhead(cls: ClassId, accuracy: "exact" | "upper_bound" = "exact") { + const c = CLASSES.find(x => x.id === cls)!, b = el("span", "oh g-" + cls, (accuracy === "upper_bound" && cls !== "unknown" ? "≤ " : "") + c.label); + b.title = `${c.name} growth${cls === "unknown" ? "" : accuracy === "exact" ? ", exact" : ", upper bound"}`; + return b; +} +const ruleOverhead = (i: number) => { const g = edgeGrowth(rules[i]!); return overhead(g.cls, g.accuracy); }; +function topbar(body: HTMLElement, text: string, action: () => void) { + const bar = el("div", "topbar"), b = el("button", "crumb"); b.type = "button"; + b.innerHTML = ''; + b.append(text); b.addEventListener("click", action); bar.append(b); body.append(bar); +} + +/* Lists: one row per thing, grouped by initial so a long list has landmarks. */ +function problemRow(name: string) { + const row = el("button", "row"); row.type = "button"; row.dataset.name = name; + row.append(el("span", "row-name", display(name))); + const alias = (aliasMap[name] ?? [])[0]; if (alias) row.append(el("small", "row-alias", alias)); + row.addEventListener("click", () => selectProblem(defaults[name]!)); + return row; +} +function ruleRow(f: { source: string; target: string; cls: ClassId; rules: number[] }) { + const row = el("button", "row rule"); row.type = "button"; + const text = el("span", "row-stack"); text.append(el("span", "row-name", display(f.source)), el("span", "row-sub", "→ " + display(f.target))); + row.append(text, overhead(f.cls)); + row.addEventListener("click", () => selectRules(f.source + "→" + f.target)); + return row; +} +function renderTabs(box: HTMLElement) { + const n = { problems: problemMatches().length, rules: ruleMatches().length }; + box.replaceChildren(...(["problems", "rules"] as const).map(id => { + const t = el("button", "tab"); t.type = "button"; t.setAttribute("role", "tab"); t.setAttribute("aria-selected", String(ex.tab === id)); + t.append(id === "problems" ? "Problems" : "Reductions", el("span", "", String(n[id]))); + t.addEventListener("click", () => { ex.tab = id; renderExplore(); }); return t; + })); +} +function list(body: HTMLElement) { + const grouped = !ex.query.trim(); + const items = ex.tab === "problems" ? problemMatches().map(n => [letter(n), problemRow(n)] as const) : ruleMatches().map(f => [letter(f.source), ruleRow(f)] as const); + if (!items.length) { body.append(el("p", "empty", "Nothing matches")); return; } + let last = ""; + items.forEach(([l, row]) => { if (grouped && l !== last) { body.append(el("div", "letter", l)); last = l; } body.append(row); }); +} + +/* A problem: identity, then variant, then its reductions. */ +function reductionRow(i: number, side: "source" | "target", sel: Extract) { + const r = rules[i]!, other = r[side], row = el("button", "red"); row.type = "button"; + const main = el("span", "red-main"), top = el("span", "red-top"); + top.append(el("span", "red-dir", side === "target" ? "→" : "←"), el("span", "red-name", display(D.nodes[other]!.name))); + main.append(top); const tokens = variantTokens(other); if (tokens.childElementCount) main.append(tokens); + if (r.turing) { const k = el("span", "red-kind", "Turing"); k.title = TURING_HINT; main.append(k); } + row.append(main, ruleOverhead(i)); + row.addEventListener("click", () => selectRules(D.nodes[r.source]!.name + "→" + D.nodes[r.target]!.name, sel.variant, i)); + return row; +} +function reductions(body: HTMLElement, sel: Extract) { + const v = sel.variant, outs = model.out.get(v) ?? [], ins = model.inn.get(v) ?? []; + if (!outs.length && !ins.length) { body.append(el("p", "empty", "No reductions at this growth limit")); return; } + const which = dir(sel), indexes = which === "out" ? outs : ins, side = which === "out" ? "target" : "source", reachId = which === "out" ? "down" : "up"; + const tabs = el("div", "tabs inline"); tabs.setAttribute("role", "tablist"); tabs.setAttribute("aria-label", "Reductions"); + ([["out", "Reduces to", outs.length], ["in", "Reduced from", ins.length]] as const).forEach(([id, text, n]) => { + const t = el("button", "tab"); t.type = "button"; t.setAttribute("role", "tab"); t.setAttribute("aria-selected", String(which === id)); t.disabled = !n; + t.append(text, el("span", "", String(n))); + t.addEventListener("click", () => { sel.dir = id; sel.reach = "direct"; ex.all = false; paintExplore(); renderExplore(false); }); tabs.append(t); + }); + body.append(tabs); + const on = sel.reach === reachId, bar = el("div", "trace"); + const toggle = el("button", "link", on ? "Stop tracing" : `Trace everything ${reachId === "down" ? "downstream" : "upstream"}`); toggle.type = "button"; + toggle.addEventListener("click", () => { sel.reach = on ? "direct" : reachId; paintExplore(); renderExplore(false); focusLit(); }); + bar.append(on ? el("span", "", pluralize(reachOf(v, reachId).size, "variant") + " on the graph") : el("span", "", "On the graph"), toggle); + body.append(bar); + const rows = el("div", "reds"); + (ex.all ? indexes : indexes.slice(0, 8)).forEach(i => rows.append(reductionRow(i, side, sel))); + body.append(rows); + if (indexes.length > 8) { + const more = el("button", "link more", ex.all ? "Show fewer" : `Show all ${indexes.length}`); more.type = "button"; + more.addEventListener("click", () => { ex.all = !ex.all; renderExplore(false); }); body.append(more); + } +} +function placeCard(body: HTMLElement, sel: Extract) { + const v = sel.variant, name = D.nodes[v]!.name; + topbar(body, ex.tab === "problems" ? "Problems" : "Reductions", clearSelection); + const head = el("header", "identity"); + head.append(el("h1", "place-name", display(name))); + const depth = fromSat.get(v), meta = el("p", "place-meta"); + const alias = (aliasMap[name] ?? [])[0]; if (alias) meta.append(el("code", "", alias)); + const st = variantStatus.get(v)!, badge = el("span", "status", STATUS[st].label); + badge.style.setProperty("--c", STATUS[st].color); badge.title = STATUS[st].detail; + meta.prepend(badge); + if (depth != null) meta.append(depth === 0 ? "SAT" : `${pluralize(depth, "step")} from SAT`); + head.append(meta); + const acts = el("div", "acts"); + const go = el("button", "act primary", "Directions"); go.type = "button"; + go.addEventListener("click", () => { state.source = v; if (state.target === v) state.target = null; setMode("directions"); if (state.target != null) run(); }); + const more = el("button", "act", "Article"); more.type = "button"; more.addEventListener("click", () => showProblem(D.nodes[v]!)); + acts.append(go, more); head.append(acts); body.append(head); + if (multi(name)) { + const sec = el("section", "sec"); sec.append(el("h2", "eyebrow", "Variant")); + const opts = el("div", "options"); variantOptions(opts, v, j => selectProblem(j, sel.reach, false)); sec.append(opts); body.append(sec); + } + const sec = el("section", "sec"); reductions(sec, sel); body.append(sec); + const group = model.groups.groups[model.groups.comp[v]!]!; + if (group.length > 1) { + const wrap = el("details", "fold"), sum = el("summary"); sum.append(el("span", "", "Mutually reducible"), el("small", "", String(group.length - 1))); + wrap.append(sum); + const chips = el("div", "chips"); + group.filter(w => w !== v).forEach(w => { const c = el("button", "chip", display(D.nodes[w]!.name) + (multi(D.nodes[w]!.name) ? " · " + vtext(w) : "")); c.type = "button"; c.addEventListener("click", () => selectProblem(w)); chips.append(c); }); + wrap.append(chips); body.append(wrap); + } +} + +/* One reduction: its two ends, how large the result grows, and every variant it covers. */ +const sup = (d: string) => [...d].map(c => "⁰¹²³⁴⁵⁶⁷⁸⁹"[Number(c)]).join(""); +/* Certificates are sums of `coef * var^k` terms; show them highest degree first, with real minus signs and no unit coefficients. */ +function formula(text: string | null) { + if (text === null) return "unavailable"; + const terms = text.split(" + ").map(term => { + const factors = term.split(" * "), lead = /^-?[\d./]+$/.test(factors[0]!) ? factors.shift()! : "1"; + const negative = lead.startsWith("-"), coef = negative ? lead.slice(1) : lead; + const degree = factors.reduce((sum, f) => sum + Number(f.split("^")[1] ?? 1), 0); + const vars = factors.map(f => f.replace(/^num_/, "").replace(/\^(\d+)$/, (_, d: string) => sup(d))).join("·"); + return { negative, degree, body: !vars ? coef : coef === "1" ? vars : coef + "·" + vars }; + }).sort((a, b) => b.degree - a.degree); + return terms.map((t, i) => (i ? (t.negative ? " − " : " + ") : t.negative ? "−" : "") + t.body).join(""); +} +function ruleCard(body: HTMLElement, sel: Extract) { + const f = ruleGroup(sel.key); + const origin = sel.from != null ? display(D.nodes[sel.from]!.name) : ex.tab === "problems" ? "Problems" : "Reductions"; + topbar(body, origin, () => { if (sel.from != null) selectProblem(sel.from, "direct", false); else clearSelection(); }); + if (!f) return; + const siblings = [...f.rules].sort((a, b) => classOrder.indexOf(ruleClass(a)) - classOrder.indexOf(ruleClass(b))); + const i = sel.index != null && f.rules.includes(sel.index) ? sel.index : siblings[0]!, rule = rules[i]!, g = edgeGrowth(rule); + body.append(el("p", "eyebrow lead", "Reduction")); + const flow = el("div", "flow"); + for (const [side, role] of [["source", "from"], ["target", "to"]] as const) { + const node = rule[side], end = el("button", "end-node " + role); end.type = "button"; + end.addEventListener("click", () => selectProblem(node, "direct", true)); + const text = el("span", "end-text"); text.append(el("span", "end-name", display(D.nodes[node]!.name))); + const tokens = variantTokens(node, true); if (tokens.childElementCount) text.append(tokens); + end.append(el("span", "end-dot"), text); flow.append(end); + if (role === "from") { const link = el("div", "flow-link"); link.append(overhead(g.cls, g.accuracy), el("span", "", g.cls === "unknown" ? "Growth undetermined" : CLASSES.find(c => c.id === g.cls)!.name + (g.accuracy === "exact" ? ", exact" : ", upper bound"))); flow.append(link); } + } + body.append(flow); + const sec = el("section", "sec"); sec.append(el("h2", "eyebrow", "Size of the result")); + const dl = el("dl", "fields"); + g.fields.forEach(x => { + const row = el("div", "field"), field = rule.fields[x.name]!; + const exact = field.accuracy.kind === "exact", mark = field.accuracy.kind === "unavailable" ? "" : exact ? "=" : "≤"; + const val = el("dd"), text = el("span", "formula"); + text.append(el("span", "mark", mark), formula(field.certificate)); + val.append(text, x.degree === null ? el("span", "oh g-unknown", "?") : overhead(growthClass(x.degree), exact ? "exact" : "upper_bound")); + row.append(el("dt", "", prettyField(x.name)), val); + dl.append(row); + }); + sec.append(dl); + sec.append(el("p", "foot", "Growth treats every source parameter as scaling together.")); + body.append(sec); + const acts = el("div", "acts"), art = el("button", "act primary", "Rule article"); art.type = "button"; art.addEventListener("click", () => showRule(ruleStep(i))); + acts.append(art); body.append(acts); + if (siblings.length > 1) { + const vs = el("section", "sec"); vs.append(el("h2", "eyebrow", `Covers ${siblings.length} variants`)); + const rows = el("div", "reds"); + siblings.forEach(j => { + const r = rules[j]!, row = el("button", "red"); row.type = "button"; row.setAttribute("aria-current", String(j === i)); + const main = el("span", "red-main"); main.append(variantPair(r.source, r.target)); row.append(main, ruleOverhead(j)); + row.addEventListener("click", () => { ex.sel = { ...sel, index: j }; paintExplore(); renderExplore(false); }); rows.append(row); + }); + vs.append(rows); body.append(vs); + } +} +function variantPair(a: number, b: number) { + const box = el("span", "pair"); + [[a, "from"], [b, "to"]].forEach(([i, role]) => { + const line = el("span", "pair-line " + role), tokens = variantTokens(i as number, true); + line.append(el("span", "pair-dot"), tokens.childElementCount ? tokens : el("span", "vars", "default")); box.append(line); + }); + return box; +} +$("#find").addEventListener("focus", () => { if (!ex.open && !ex.sel) { ex.open = true; renderExplore(); } }); +$("#find").addEventListener("input", (e) => { + ex.query = (e.target as HTMLInputElement).value; ex.sel = null; paintExplore(); renderExplore(); +}); +$("#find").addEventListener("keydown", (e) => { + if (e.key === "Enter") $("#explore-body .row")?.click(); + if (e.key !== "Escape") return; + if (ex.query) { ex.query = ""; ($("#find") as HTMLInputElement).value = ""; paintExplore(); renderExplore(); } + else { ($("#find") as HTMLInputElement).blur(); collapse(); } +}); + +/* ---------- Modes: explore the graph, or ask for directions ---------- */ +function setMode(mode: "explore" | "directions") { + exploring = mode === "explore"; + $(".panel").classList.toggle("collapsed", collapsed()); + $("#view-explore").hidden = !exploring; $("#view-directions").hidden = exploring; + document.body.classList.toggle("explore", exploring); + closePop(); + if (exploring) { + arrangeRoute([]); + refreshExplore(); frameAll(); + history.replaceState(null, "", location.pathname + location.search); + } else { + try { startWorker(); } catch { /* Search reports startup failures when requested. */ } + setView("all"); + scene.classes(scene.elements, CLEAN); + syncEnds(); renderResults(); paintGraph(); + history.replaceState(null, "", "#directions"); + if (state.source == null) $("#source").focus({ preventScroll: true }); else if (state.target == null) $("#target").focus({ preventScroll: true }); + } +} +$("#go-directions").addEventListener("click", () => setMode("directions")); +$("#back-explore").addEventListener("click", () => setMode("explore")); + +/* ---------- Dock and resize ---------- */ +const zoomBy = (k: number) => { const r = freeRect(); scene.stop(); scene.zoomAt(scene.zoom * k, { x: r.x + r.w / 2, y: r.y + r.h / 2 }); }; +$("#zoom-in").onclick = () => zoomBy(1.3); +$("#zoom-out").onclick = () => zoomBy(1 / 1.3); +$("#fit").onclick = () => frame(scene.elements, 1); +addEventListener("resize", () => { scheduleLabels(); placePop(); }); +new ResizeObserver(() => { + document.documentElement.style.setProperty("--header-height", $(".site-header").getBoundingClientRect().height + "px"); + scheduleLabels(); +}).observe($(".site-header")); + +/* ---------- Start on a real example ---------- */ +if (location.hash === "#directions") { + exploring = false; $("#view-explore").hidden = true; $("#view-directions").hidden = false; document.body.classList.remove("explore"); + state.source = defaults.Satisfiability!; state.target = defaults.QUBO!; + syncEnds(); frame(scene.elements, 1, false); run(); +} else { + document.body.classList.add("explore"); + // The camera starts framed; the graph itself unfolds into place. + refreshExplore(); frameAll(false); + if (!reduced.matches) scene.unfold(); +} +performance.mark("graph:interactive"); +document.fonts?.ready.then(() => { + scene.nodes.forEach(n => { n.labelKey = null; }); + const focused = scene.nodes.find(node => scene.has(node, 'focused')); + if (focused) scene.reveal(focused); + scheduleLabels(); +}); +})().catch(() => { + graphStartup.worker?.terminate(); + graphStartup.worker = null; + const error = document.querySelector('#graph-error')!; + error.textContent = 'Cannot load graph. Reload this release and try again.'; + error.hidden = false; +}); diff --git a/docs/website/src/pages/hero-graph.ts b/docs/website/src/pages/hero-graph.ts new file mode 100644 index 000000000..dcd588014 --- /dev/null +++ b/docs/website/src/pages/hero-graph.ts @@ -0,0 +1,203 @@ +/* Animated home-page overview: a small force simulation over problem families, drawn into the + existing hero SVG classes so the site's palette and node styles stay authoritative. The + integration follows d3-force (alpha cooling, velocity decay, degree-biased link springs); + at about two hundred nodes the exact pairwise repulsion is cheaper than a quadtree. */ + +import { make } from './svg.ts'; + +export interface HeroNode { + id: string; + title: string; + href: string; + label?: string; + // A fixed point in the 630 × 430 view that holds a solver hub in place. + anchor?: [number, number]; +} +export interface HeroGraph { + nodes: HeroNode[]; + links: [string, string][]; + featured: [string, string]; +} + +interface SimNode extends HeroNode { + x: number; y: number; vx: number; vy: number; + focus: boolean; degree: number; r: number; + el: SVGGElement; dot: SVGCircleElement; text: SVGTextElement | null; +} +interface SimLink { s: SimNode; t: SimNode; hub: boolean; length: number; el: SVGLineElement } + +const WIDTH = 630, HEIGHT = 430, MARGIN = 12; + +export function mountHeroGraph(container: HTMLElement, graph: HeroGraph) { + const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches; + const svg = make("svg", { viewBox: `0 0 ${WIDTH} ${HEIGHT}`, class: "hero-force", + "aria-label": "Problem-level overview; connections can involve different variants" }); + const edgeLayer = make("g", { "aria-hidden": "true" }), nodeLayer = make("g"); + svg.append(edgeLayer, nodeLayer); + + const adjacent = new Map(graph.nodes.map(n => [n.id, new Set([n.id])])); + for (const [a, b] of graph.links) { adjacent.get(a)!.add(b); adjacent.get(b)!.add(a); } + // Labelled problems draw last so their circles and labels sit above the crowd. + const ordered = [...graph.nodes].sort((a, b) => Number(!!a.label) - Number(!!b.label)); + const nodes: SimNode[] = ordered.map(node => { + const focus = graph.featured.includes(node.id), r = focus ? 8 : node.label ? 5.5 : 2.8; + const el = make("g", { class: "hero-node" }); + let dot: SVGCircleElement, text: SVGTextElement | null = null; + if (node.label) { + const link = make("a", { class: `network-node major${focus ? " focus" : ""}${node.anchor ? " hub" : ""}`, + href: node.href, "aria-label": `Explore ${node.title}` }); + const title = make("title"); title.textContent = node.title; + dot = make("circle", { r }); + text = make("text", { "text-anchor": "middle" }); text.textContent = node.label; + link.append(title, dot, text); el.append(link); + } else { + dot = make("circle", { r, fill: "var(--graph-node)", stroke: "var(--paper)", "stroke-width": 1, "aria-hidden": "true" }); + el.append(dot); + } + nodeLayer.append(el); + // Start in a tight cloud at the centre; the simulation unfolds it into place. + const angle = Math.random() * Math.PI * 2, spread = Math.random() * 40; + return { ...node, x: WIDTH / 2 + Math.cos(angle) * spread, y: HEIGHT / 2 + Math.sin(angle) * spread, + vx: 0, vy: 0, focus, degree: adjacent.get(node.id)!.size - 1, r, el, dot, text }; + }); + const byId = new Map(nodes.map(n => [n.id, n])); + const links: SimLink[] = graph.links.map(([a, b]) => { + const s = byId.get(a)!, t = byId.get(b)!, hub = !!(s.anchor || t.anchor), featured = s.focus && t.focus; + const el = make("line", { class: "network-edge" }); + edgeLayer.append(el); + return { s, t, hub, length: hub ? 80 : featured ? 64 : 24, el }; + }); + + // The dragged node is pinned under the pointer while the rest of the layout reacts. + let dragged: SimNode | null = null, pin: { x: number; y: number } | null = null, moved = false; + let alpha = 1, alphaTarget = 0; + const alphaMin = 0.002, alphaDecay = 1 - Math.pow(alphaMin, 1 / 320), velocityDecay = 0.42; + function tick() { + alpha += (alphaTarget - alpha) * alphaDecay; + for (const { s, t, length, hub } of links) { + let dx = t.x + t.vx - s.x - s.vx, dy = t.y + t.vy - s.y - s.vy; + const len = Math.hypot(dx, dy) || 1e-6; + const k = ((len - length) / len) * alpha * (hub ? 0.05 : 0.5); + dx *= k; dy *= k; + const bias = s.degree / (s.degree + t.degree); + t.vx -= dx * bias; t.vy -= dy * bias; s.vx += dx * (1 - bias); s.vy += dy * (1 - bias); + } + for (let i = 0; i < nodes.length; i++) { + const a = nodes[i]!; + for (let j = i + 1; j < nodes.length; j++) { + const b = nodes[j]!; + const dx = b.x - a.x, dy = b.y - a.y, d2 = Math.max(dx * dx + dy * dy, 1); + // Labelled problems repel each other harder so their names do not pile up. + const push = (a.label && b.label ? -700 : -18) * alpha / d2; + a.vx += dx * push; a.vy += dy * push; b.vx -= dx * push; b.vy -= dy * push; + const min = a.r + b.r + 2; + if (d2 < min * min) { + const d = Math.sqrt(d2), shift = ((min - d) / d) * 0.35; + a.vx -= dx * shift; a.vy -= dy * shift; b.vx += dx * shift; b.vy += dy * shift; + } + } + } + for (const n of nodes) { + const [cx, cy] = n.anchor ?? [WIDTH / 2, HEIGHT / 2]; + const pull = n.anchor ? 0.3 : n.degree ? 0.035 : 0.07; + n.vx += (cx - n.x) * pull * alpha; + n.vy += (cy - n.y) * pull * 1.4 * alpha; + if (n === dragged && pin) { n.x = pin.x; n.y = pin.y; n.vx = n.vy = 0; continue; } + n.x = Math.min(WIDTH - MARGIN, Math.max(MARGIN, n.x + (n.vx *= 1 - velocityDecay))); + n.y = Math.min(HEIGHT - MARGIN, Math.max(MARGIN, n.y + (n.vy *= 1 - velocityDecay))); + } + } + function draw() { + for (const { s, t, el } of links) { + el.setAttribute("x1", s.x.toFixed(1)); el.setAttribute("y1", s.y.toFixed(1)); + el.setAttribute("x2", t.x.toFixed(1)); el.setAttribute("y2", t.y.toFixed(1)); + } + for (const n of nodes) { + n.dot.setAttribute("cx", n.x.toFixed(1)); n.dot.setAttribute("cy", n.y.toFixed(1)); + if (n.text) { n.text.setAttribute("x", n.x.toFixed(1)); n.text.setAttribute("y", (n.y + (n.focus ? 28 : 21)).toFixed(1)); } + } + } + + // Animate only while the hero is attached and on screen; requestAnimationFrame already sleeps in hidden tabs. + let frame = 0, visible = true; + const observer = new IntersectionObserver(([entry]) => { + visible = !!entry?.isIntersecting; + if (visible) restart(); else { cancelAnimationFrame(frame); frame = 0; } + }); + function loop() { + frame = 0; + if (!svg.isConnected) { observer.disconnect(); return; } + tick(); draw(); + if (alpha > alphaMin || alphaTarget > 0) restart(); + } + function restart() { if (!frame && !reduce && visible) frame = requestAnimationFrame(loop); } + + // Hover lights a problem's direct reductions and names it; the rest of the network recedes. + // Classes change only when the hovered problem changes, not on every pointer move. + const hoverLabel = make("text", { class: "hero-force-label", "text-anchor": "middle" }); + svg.append(hoverLabel); + let focused: SimNode | null = null; + function focus(node: SimNode | null) { + if (node !== focused) { + focused = node; + svg.classList.toggle("is-focused", !!node); + const near = node ? adjacent.get(node.id)! : null; + for (const n of nodes) n.el.classList.toggle("is-near", !!near?.has(n.id)); + for (const l of links) l.el.classList.toggle("is-near", !!node && (l.s === node || l.t === node)); + hoverLabel.textContent = node && !node.label ? node.title : ""; + } + if (node && !node.label) { hoverLabel.setAttribute("x", node.x.toFixed(1)); hoverLabel.setAttribute("y", (node.y - 10).toFixed(1)); } + } + const point = (event: PointerEvent) => { + const m = svg.getScreenCTM(); + if (!m) return { x: 0, y: 0 }; + const p = new DOMPoint(event.clientX, event.clientY).matrixTransform(m.inverse()); + return { x: p.x, y: p.y }; + }; + const nearest = (event: PointerEvent) => { + const { x, y } = point(event); + let best: SimNode | null = null, bestD = 14 * 14; + for (const n of nodes) { const d = (n.x - x) ** 2 + (n.y - y) ** 2; if (d < Math.max(bestD, n.r * n.r)) { bestD = d; best = n; } } + return best; + }; + svg.addEventListener("pointermove", event => { + if (dragged) { + pin = point(event); moved = true; + if (reduce) { dragged.x = pin.x; dragged.y = pin.y; draw(); } + focus(dragged); + return; + } + const node = nearest(event); + svg.style.cursor = node ? (node.label ? "pointer" : "grab") : ""; + focus(node); + }); + svg.addEventListener("pointerleave", () => { if (!dragged) focus(null); }); + svg.addEventListener("pointerdown", event => { + const node = nearest(event); + if (!node || event.button !== 0) return; + dragged = node; moved = false; + pin = { x: node.x, y: node.y }; + svg.setPointerCapture(event.pointerId); + alphaTarget = 0.25; restart(); + }); + const release = () => { + if (!dragged) return; + dragged = null; pin = null; alphaTarget = 0; + }; + svg.addEventListener("pointerup", release); + svg.addEventListener("pointercancel", release); + // A drag must not follow the labelled node's link. + svg.addEventListener("click", event => { if (moved) { event.preventDefault(); moved = false; } }, true); + + container.replaceChildren(svg); + observer.observe(svg); + if (!reduce) { draw(); restart(); return; } + // Without motion the layout settles off-screen in short slices, so loading never blocks input. + svg.style.visibility = "hidden"; + const settle = () => { + for (let i = 0; i < 24 && alpha > alphaMin; i++) tick(); + if (alpha > alphaMin && svg.isConnected) { setTimeout(settle); return; } + alpha = 0; draw(); svg.style.visibility = ""; + }; + settle(); +} diff --git a/docs/website/assets/site.js b/docs/website/src/pages/site.ts similarity index 54% rename from docs/website/assets/site.js rename to docs/website/src/pages/site.ts index ab6adb0fd..9c0897841 100644 --- a/docs/website/assets/site.js +++ b/docs/website/src/pages/site.ts @@ -1,9 +1,24 @@ +import { loadDetailsIndex, renderDetails } from './details.ts'; +import { mountHeroGraph, type HeroGraph } from './hero-graph.ts'; +import type { AtlasSummary, AtlasRecord, RecordEdge, SummaryEdge, Variant } from '../generated/atlas-data.mjs'; + +declare global { interface Window { REDUCTIONS: unknown } } + /* The site is a static client of the same registry exports as the book and paper. */ (() => { "use strict"; - let data = window.REDUCTIONS; - let fullData; - const main = document.querySelector("main"); + const $ = (selector: string) => document.querySelector(selector)!; + const main = $('main'); + function showError(error: unknown) { + const alert = Object.assign(document.createElement('p'), {textContent: error instanceof Error ? error.message : String(error)}); + alert.setAttribute('role', 'alert'); + main.replaceChildren(alert); + } + const summary = window.REDUCTIONS as AtlasSummary; + const records = new Map>(); + const names = new Map(summary.schemas.map(schema => [schema.name, schema])); + let atlasRows: Map | undefined; + // The website build validates summary and record references before publishing. const homeHTML = main.innerHTML; history.scrollRestoration = "manual"; const baseTitle = "Problem Reductions"; @@ -15,7 +30,7 @@ algebraic: "Algebra", misc: "Other structures", }; - const aliases = { + const aliases: Record = { MIS: "MaximumIndependentSet", MVC: "MinimumVertexCover", SAT: "Satisfiability", @@ -24,9 +39,9 @@ }; let filter = "all"; let query = ""; - let toastTimer; + let toastTimer: ReturnType | undefined; let focusSearchAfterNavigation = false; - const escape = (value) => + const escape = (value: unknown) => String(value ?? "").replace( /[&<>"']/g, (c) => @@ -36,111 +51,97 @@ ">": ">", '"': """, "'": "'", - })[c], + } as Record)[c]!, ); - const nameOf = (name) => - data.schemas.find((s) => s.name === name)?.display_name || + const nameOf = (name: string) => + names.get(name)?.display_name || name .replace(/([a-z0-9])([A-Z])/g, "$1 $2") .replace(/([A-Z])([A-Z][a-z])/g, "$1 $2"); - const variantKey = (node) => + const variantKey = (node: {variant: Variant}) => Object.entries(node.variant) .sort(([a], [b]) => a.localeCompare(b)) .map(([k, v]) => `${k}=${v}`) .join(","); - const variantLabel = (node) => + const variantLabel = (node: {variant: Variant}) => Object.entries(node.variant) .map(([k, v]) => `${k}: ${v}`) .join(" · ") || "Default variant"; - const problemHref = (name, node) => + const problemHref = (name: string, node?: {variant: Variant}) => `#problem/${encodeURIComponent(name)}${node ? `?variant=${encodeURIComponent(variantKey(node))}` : ""}`; - const ruleHref = (edge) => - `#reduction/${encodeURIComponent(data.nodes[edge.source].name)}/${encodeURIComponent(data.nodes[edge.target].name)}?from=${encodeURIComponent(variantKey(data.nodes[edge.source]))}&to=${encodeURIComponent(variantKey(data.nodes[edge.target]))}`; - const schemaOf = (name) => data.schemas.find((s) => s.name === name); - const description = (name) => + const ruleHref = (edge: SummaryEdge, nodes: AtlasSummary['nodes'] | AtlasRecord['nodes'] = summary.nodes) => + `#reduction/${encodeURIComponent(nodes[edge.source]!.name)}/${encodeURIComponent(nodes[edge.target]!.name)}?from=${encodeURIComponent(variantKey(nodes[edge.source]!))}&to=${encodeURIComponent(variantKey(nodes[edge.target]!))}`; + const schemaOf = (name: string) => summary.schemas.find((s) => s.name === name); + const description = (name: string) => schemaOf(name)?.description || "Explore the registered variants, reduction contracts, and implementation of this computational problem."; - const apiHref = (path) => `./api/problemreductions/${path}`; - const sourceHref = (edge) => `${repo}/blob/main/${edge.source_path}`; - const families = [...new Set(data.nodes.map((n) => n.name))].sort(); - const variantsOf = (name) => + const apiHref = (path: string) => `./api/problemreductions/${path}`; + const sourceHref = (edge: RecordEdge) => `${repo}/blob/main/${edge.source_path}`; + const families = [...new Set(summary.nodes.map((n) => n.name))].sort(); + const variantsOf = (name: string, data: AtlasRecord) => data.nodes .map((n, i) => ({ ...n, index: i })) .filter((n) => n.name === name); - const edgesOf = (name) => - data.edges.filter( + const edgesOf = (name: string) => + summary.edges.filter( (e) => - data.nodes[e.source].name === name || - data.nodes[e.target].name === name, + summary.nodes[e.source]!.name === name || + summary.nodes[e.target]!.name === name, ); - const featuredEdge = data.edges.find( + const featuredEdge = summary.edges.find( (e) => - data.nodes[e.source].name === "MinimumVertexCover" && - data.nodes[e.target].name === "MaximumIndependentSet", + summary.nodes[e.source]!.name === "MinimumVertexCover" && + summary.nodes[e.target]!.name === "MaximumIndependentSet", ); + const catalog = families.map(name => { + const variants = summary.nodes.filter(node => node.name === name), category = variants[0]!.category; + return {name, category, + search: `${name} ${nameOf(name)} ${description(name)} ${(names.get(name)?.aliases || []).join(' ')}`.toLowerCase(), + html: `

${escape(nameOf(name))}

${escape(description(name))}

${escape(categories[category] || category)}${variants.length} variant${variants.length === 1 ? '' : 's'}${edgesOf(name).length} connection${edgesOf(name).length === 1 ? '' : 's'}
`}; + }).sort((a, b) => nameOf(a.name).localeCompare(nameOf(b.name))); - function notify(message) { - const toast = document.querySelector(".toast"); + function notify(message: string) { + const toast = $(".toast"); toast.textContent = message; toast.classList.add("visible"); clearTimeout(toastTimer); toastTimer = setTimeout(() => toast.classList.remove("visible"), 2800); } - function graphArt() { - const width = 630, - height = 430; - const highlights = { - MaximumIndependentSet: [310, 242, "Independent Set"], - MinimumVertexCover: [452, 155, "Vertex Cover"], - Satisfiability: [145, 162, "Satisfiability"], - KSatisfiability: [220, 74, "k-SAT"], - MaximumSetPacking: [449, 314, "Set Packing"], - QUBO: [158, 335, "QUBO"], - ILP: [330, 370, "ILP"], - Coloring: [92, 249, "Coloring"], + function heroGraph(): HeroGraph { + const labels: Record = { + MaximumIndependentSet: "Independent Set", + MinimumVertexCover: "Vertex Cover", + Satisfiability: "Satisfiability", + KSatisfiability: "k-SAT", + MaximumSetPacking: "Set Packing", + QUBO: "QUBO", + ILP: "ILP", + KColoring: "Coloring", + }; + const featured: [string, string] = ["MinimumVertexCover", "MaximumIndependentSet"]; + // Solver hubs are held near fixed points so the rules into them fan out as rings. + const anchors: Record = { ILP: [250, 250], QUBO: [470, 300] }; + const seen = new Set(); + const links: [string, string][] = []; + for (const e of summary.edges) { + const from = summary.nodes[e.source]!.name, to = summary.nodes[e.target]!.name; + const key = [from, to].sort().join("/"); + if (from === to || seen.has(key)) continue; + seen.add(key); + links.push([from, to]); + } + return { + nodes: families.map(name => ({ + id: name, + title: nameOf(name), + href: problemHref(name), + ...(labels[name] ? { label: labels[name] } : {}), + ...(anchors[name] ? { anchor: anchors[name] } : {}), + })), + links, + featured, }; - const positions = {}; - families.forEach((name, i) => { - const angle = i * 2.39996323; - const radius = Math.sqrt((i + 1) / families.length); - positions[name] = highlights[name] || [ - 315 + Math.cos(angle) * radius * 287, - 218 + Math.sin(angle) * radius * 191, - ]; - }); - const seen = new Set(); - const lines = data.edges - .map((e) => { - const from = data.nodes[e.source].name, - to = data.nodes[e.target].name; - if (from === to) return ""; - const key = [from, to].sort().join("/"); - if (seen.has(key)) return ""; - seen.add(key); - const [x1, y1] = positions[from], - [x2, y2] = positions[to]; - const featured = - [from, to].includes("MinimumVertexCover") && - [from, to].includes("MaximumIndependentSet"); - return ``; - }) - .join(""); - const nodes = [...families] - .sort((a, b) => Number(!!highlights[a]) - Number(!!highlights[b])) - .map((name) => { - const [x, y] = positions[name]; - const isFocus = [ - "MaximumIndependentSet", - "MinimumVertexCover", - ].includes(name); - const label = highlights[name]?.[2]; - if (!label) - return `