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..8ec0452c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "problemreductions-expr", "problemreductions-macros", "problemreductions-cli", + "problemreductions-web", ] [package] @@ -22,7 +23,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 +61,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/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.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..7510f608a --- /dev/null +++ b/docs/website/graph-scene.schema.json @@ -0,0 +1,48 @@ +{ + "$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", "turing"], + "properties": { + "source": {"type": "integer", "minimum": 0, "maximum": 9007199254740991}, + "target": {"type": "integer", "minimum": 0, "maximum": 9007199254740991}, + "witness": {"type": "boolean"}, + "turing": {"type": "boolean"} + } + } + }, + "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..dcb029482 100644 --- a/docs/website/graph.html +++ b/docs/website/graph.html @@ -1,127 +1,69 @@ - - - - - - - Reduction Graph — Problem Reductions - - - - - - - - - - - - - + + + + + + Reduction Paths — Problem Reductions + + + + + + + + + + + + + +
-
-
-
- Reduction graph - __PROBLEM_COUNT__ families · __VARIANT_COUNT__ variants · - __RULE_COUNT__ directed reductions -
-
- - - -
-
+
+

Reduction paths

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

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

-
+ + - - -
-
- - - + + 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/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..b4294dc88 --- /dev/null +++ b/docs/website/src/lib/graph-growth.mts @@ -0,0 +1,52 @@ +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 != 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); + const lead = [...new Set(terms.filter(term => term.d === degree).map(term => term.txt))]; + out = { degree, lead: (field.accuracy.kind === 'exact' ? 'Θ(' : '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/assets/graph-layout.js b/docs/website/src/lib/graph-layout.js similarity index 100% rename from docs/website/assets/graph-layout.js rename to docs/website/src/lib/graph-layout.js 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..636ab61a7 --- /dev/null +++ b/docs/website/src/pages/graph-scene.ts @@ -0,0 +1,309 @@ +// The scene is small and already laid out at build time; SVG owns hit testing and painting. +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; + 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 } +interface Gesture { item: SceneItem | undefined; moved: boolean; start: Point } + +export class GraphScene { + zoom: number; + pan: Point; + animation: 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; + onHover?: (node: SceneNode, over: boolean) => void; + + constructor(container: HTMLElement, nodes: SceneNodeInput[], edges: { s: string; t: string }[]) { + const make = (tag: Tag, attrs: Record = {}) => { + const node = document.createElementNS('http://www.w3.org/2000/svg', tag); + for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, String(value)); + return node; + }; + this.zoom = 1; this.pan = {x: 0, y: 0}; this.animation = 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); 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 edge = {id: s + '→' + t, source, target, + shape: make('line', {class: 'scene-edge', 'data-edge': s + '→' + t, 'aria-hidden': 'true'})}; + this.byId.set(edge.id, edge); this.world.append(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]') : null; + const id = target?.getAttribute('data-node') || target?.getAttribute('data-edge'); + 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 (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); + } + } + updatePositions() { + for (const node of this.nodes) node.shape.setAttribute('transform', `translate(${node.x} ${node.y})`); + for (const edge of this.edges) { + const a = edge.source, b = edge.target, d = Math.hypot(b.x-a.x, b.y-a.y) || 1, r = b.size/2 + 4; + edge.shape.setAttribute('x1', String(a.x)); edge.shape.setAttribute('y1', String(a.y)); + edge.shape.setAttribute('x2', String(b.x - (b.x-a.x)*r/d)); edge.shape.setAttribute('y2', String(b.y - (b.y-a.y)*r/d)); + } + } + moveNode(node: SceneNode, dx: number, dy: number) { + this.stop(); + 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(3, 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; } + 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); + } + labelBox(node: SceneNode): Bounds { + const important = ['route', 'start', 'end', 'hover', 'focused'].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) => { + 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 = line; return span; + })); + node.label.setAttribute('font-size', String(size)); + } + node.label.setAttribute('transform', `translate(0 ${node.size/2}) scale(${1/this.zoom})`); + const p = this.position(node), top = p.y + node.size/2*this.zoom + 5; + return {x1:p.x-node.width/2-3, x2:p.x+node.width/2+3, y1:top-3, y2:top+node.height+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..8df6b26a2 --- /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(() => {}); +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..db49746b8 --- /dev/null +++ b/docs/website/src/pages/graph.ts @@ -0,0 +1,650 @@ +import { graphStartup } from './graph-startup.ts'; +import { renderDetails } from './details.ts'; +import { GraphScene, type SceneNode, type SceneItem } from "./graph-scene.ts"; +import { growth, pathDegree, sizeBadge, compareDegrees } from "../lib/graph-growth.mts"; +import { validatePathReply, type PathRequest, type PathReply } from '../lib/path-worker-data.mts'; + +import type { Endpoint, Route, Step, WasmPathResult } from '../generated/wasm-paths.mjs'; +import type { WasmComparisonResult } from '../generated/wasm-comparison.mjs'; +import type { GraphSceneData } from '../generated/graph-scene.mjs'; + +type Side = 'source' | 'target'; +interface Family { name: string; category: GraphSceneData['nodes'][number]['category']; 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, category: n.category, 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; +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 cat = { graph: "#a9d68a", formula: "#8cc4c7", set: "#e0a988", algebraic: "#e2cf73", misc: "#c39bd3" }; +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: cat[f.category] || cat.misc, ...pos.get(f.name)!})), [...famEdges.values()]); +performance.mark("graph:renderer-created"); +performance.mark("graph:constructed"); + +function freeRect() { + const W = innerWidth, H = innerHeight, top = $(".site-header").getBoundingClientRect().bottom; + if (phone.matches) return { x: 0, y: top, w: W, h: Math.max(1, $(".panel").getBoundingClientRect().top - top) }; + const right = $("#details-drawer").hidden || modalDetails.matches ? W : $("#details-drawer").getBoundingClientRect().left - 16; + return { x: 392, y: top, w: Math.max(1, right - 392), h: H - top }; +} +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. */ +let labelRaf = 0; +const scheduleLabels = () => { cancelAnimationFrame(labelRaf); labelRaf = requestAnimationFrame(placeLabels); }; +function placeLabels() { + const r = freeRect(), boxes: Bounds[] = []; + const box = (n: SceneNode) => scene.labelBox(n); + const important = (n: SceneNode) => ["route", "start", "end", "hover", "focused"].some(c => scene.has(n, c)); + 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; + const hit = (b: Bounds) => boxes.some(o => b.x1 < o.x2 + 6 && b.x2 > o.x1 - 6 && b.y1 < o.y2 + 4 && b.y2 > o.y1 - 4); + scene.classes(scene.nodes, "named"); + scene.nodes.filter(important).forEach(n => boxes.push(box(n))); + const named: SceneNode[] = []; + scene.nodes.filter(n => !important(n) && !scene.has(n, "dim")).sort((a,b) => b.deg-a.deg).forEach(n => { + const b = box(n); + if (fits(b) && !hit(b)) { boxes.push(b); named.push(n); } + }); + scene.classes(named, "", "named"); + placePop(); +} +scene.onChange = scheduleLabels; +scene.onDrag = () => { scheduleLabels(); placePop(); }; +scene.onHover = (n, over) => { + scene.classes([n], over ? "" : "hover", over ? "hover" : ""); + if (!state.result) { + 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 ---------- */ +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, 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 = 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 (!popNode || $("#pop").hidden) return; + const pop = $("#pop"), p = scene.position(popNode), 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 = null; $("#pop").hidden = true; + if (restoreFocus) opener?.focus({preventScroll: true}); +} +scene.onNodeTap = openPop; +scene.onBackgroundTap = () => closePop(); +document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { closePop(true); closeDetails(); } + 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), 1.3); + } + $("#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 (!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 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) => { + try { worker ||= new Worker(new URL('../workers/path-worker.ts', import.meta.url), { type: "module" }); } catch (err) { reject(err); return; } + const engine = worker; + let result = cached; + worker.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); + } + }; + worker.onerror = (ev) => { ev.preventDefault(); if (worker !== engine) return; stopWorker(); reject(new Error("engine")); }; + performance.mark("graph:request-posted"); + worker.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() { + scene.classes(scene.elements, "dim ghost route start end nbr"); + scene.nodes.forEach(n => { n.routeLabel = display(n.id); }); + if (state.result && state.selected) { + scene.classes(scene.elements, "", "dim"); + (state.result!.polynomial_comparison?.front || []).forEach(n => scene.classes(routeEls(n), "dim", "ghost")); + const selected = routeEls(state.selected); + scene.classes(selected, "dim ghost", "route"); + // 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"); + 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), 1.3); +} + +/* 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 steps inside one family are variant conversions: show the family once with each variant it passes through. + const groups: {name: string; variants: Endpoint['variant'][]}[] = []; + endpoints(route).forEach((e) => { if (groups.at(-1)?.name === e.name) groups.at(-1)!.variants.push(e.variant); else groups.push({ name: e.name, variants: [e.variant] }); }); + groups.forEach((e, i) => { + const li = el("li"), btn = el("button"); btn.type = "button"; + btn.append(el("span", "name", display(e.name))); + if (multi(e.name)) e.variants.forEach((v) => btn.append(kvNode(v))); + btn.addEventListener("click", () => { showProblem({name: e.name, variant: e.variants[0]!}); pulseAndFrame(e.name); }); + li.append(el("span", "dot"), btn); ol.append(li); + if (i < groups.length - 1) { + const next = groups[i + 1]!, rule = el("button", "rule-link", "rule"); rule.type = "button"; + rule.title = `Reduction rule: ${display(e.name)} to ${display(next.name)}`; + rule.onclick = () => showRule({from: {name: e.name, variant: e.variants.at(-1)!}, to: {name: next.name, variant: next.variants[0]!}}); + li.append(rule); + } + }); + 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), front = shown.filter((n) => !c || onFront.has(n)), slower = shown.filter((n) => c && !onFront.has(n)); + const s = el("div", "summary"), left = el("span"); + if (c) left.append(el("strong", "", `${front.length} best route${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); + 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 open = state.showSlower || slower.includes(state.selected!); + const t = el("button", "link more", open ? "Hide slower routes" : `Show ${slower.length} slower routes`); t.type = "button"; + t.addEventListener("click", () => { state.showSlower = !open; if (!state.showSlower && slower.includes(state.selected!)) { select(front[0]!); return; } renderResults(); }); + box.append(t); + if (open) slower.forEach((n) => box.append(pathRow(n, true))); + } +} + +/* ---------- 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 ---------- */ +state.source = defaults.Satisfiability!; state.target = defaults.QUBO!; +syncEnds(); frame(scene.elements, 1, false); run(); +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; + document.querySelector('#results')!.textContent = 'Cannot load graph. Reload this release and try again.'; +}); diff --git a/docs/website/assets/site.js b/docs/website/src/pages/site.ts similarity index 59% rename from docs/website/assets/site.js rename to docs/website/src/pages/site.ts index ab6adb0fd..7a62cd694 100644 --- a/docs/website/assets/site.js +++ b/docs/website/src/pages/site.ts @@ -1,9 +1,23 @@ +import { loadDetailsIndex, renderDetails } from './details.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 +29,7 @@ algebraic: "Algebra", misc: "Other structures", }; - const aliases = { + const aliases: Record = { MIS: "MaximumIndependentSet", MVC: "MinimumVertexCover", SAT: "Satisfiability", @@ -24,9 +38,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,51 +50,57 @@ ">": ">", '"': """, "'": "'", - })[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); @@ -90,7 +110,7 @@ function graphArt() { const width = 630, height = 430; - const highlights = { + const highlights: Record = { MaximumIndependentSet: [310, 242, "Independent Set"], MinimumVertexCover: [452, 155, "Vertex Cover"], Satisfiability: [145, 162, "Satisfiability"], @@ -100,7 +120,7 @@ ILP: [330, 370, "ILP"], Coloring: [92, 249, "Coloring"], }; - const positions = {}; + const positions: Record = {}; families.forEach((name, i) => { const angle = i * 2.39996323; const radius = Math.sqrt((i + 1) / families.length); @@ -109,17 +129,17 @@ 218 + Math.sin(angle) * radius * 191, ]; }); - const seen = new Set(); - const lines = data.edges + const seen = new Set(); + const lines = summary.edges .map((e) => { - const from = data.nodes[e.source].name, - to = data.nodes[e.target].name; + const from = summary.nodes[e.source]!.name, + to = summary.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 [x1, y1] = positions[from]!, + [x2, y2] = positions[to]!; const featured = [from, to].includes("MinimumVertexCover") && [from, to].includes("MaximumIndependentSet"); @@ -129,7 +149,7 @@ const nodes = [...families] .sort((a, b) => Number(!!highlights[a]) - Number(!!highlights[b])) .map((name) => { - const [x, y] = positions[name]; + const [x, y] = positions[name]!; const isFocus = [ "MaximumIndependentSet", "MinimumVertexCover", @@ -149,12 +169,12 @@ [200, 190], [100, 190], [69, 94], - ]; + ] as const; function cycleSVG(mode = "independent", labels = false) { const selected = mode === "independent" ? [0, 2] : [1, 3, 4]; return `${cyclePoints .map(([x, y], i) => { - const next = cyclePoints[(i + 1) % cyclePoints.length]; + const next = cyclePoints[(i + 1) % cyclePoints.length]!; return ``; }) .join( @@ -163,12 +183,12 @@ } function complementArt() { - const graph = (offset, selected) => - `${cyclePoints.map(([x, y], i) => ``).join("")}${cyclePoints.map(([x, y], i) => ``).join("")}`; - return `${graph(15, [1, 3, 4])}${graph(305, [0, 2])}V ∖ SMinimum Vertex CoverMaximum Independent Set3 selected vertices2 selected vertices`; + const graph = (offset: number, selected: number[]) => + `${cyclePoints.map(([x, y], i) => ``).join("")}${cyclePoints.map(([x, y], i) => ``).join("")}`; + return `${graph(15, [1, 3, 4])}${graph(305, [0, 2])}keep theothersMinimum Vertex CoverMaximum Independent Setevery edge touches a filled vertexno two filled vertices share an edge`; } - function miniArt(name) { + function miniArt(name: string) { if (name === "MaximumIndependentSet") return cycleSVG(); if (name === "Satisfiability") return 'Example Boolean clauses'; @@ -176,21 +196,21 @@ } function hydrateHome() { - document.querySelector("#hero-graph").innerHTML = graphArt(); - document.querySelector("#problem-showcase").innerHTML = [ + $("#hero-graph").innerHTML = graphArt(); + $("#problem-showcase").innerHTML = [ "MaximumIndependentSet", "Satisfiability", "QUBO", ] .filter((name) => families.includes(name)) .map((name) => { - const variants = variantsOf(name); - return `${escape(categories[variants[0].category] || "Computational problem")}

${escape(nameOf(name))}

${escape(description(name))}.

${miniArt(name)}
${variants.length} variant${variants.length === 1 ? "" : "s"} · ${edgesOf(name).length} incident reductions
`; + const variants = summary.nodes.filter(node => node.name === name); + return `${escape(categories[variants[0]!.category] || "Computational problem")}

${escape(nameOf(name))}

${escape(description(name))}.

${miniArt(name)}
${variants.length} variant${variants.length === 1 ? "" : "s"} · ${edgesOf(name).length} incident reductions
`; }) .join(""); - document.querySelector("#featured-art").innerHTML = complementArt(); + $("#featured-art").innerHTML = complementArt(); if (featuredEdge) { - document.querySelector("#featured-result-link").href = + $("#featured-result-link").href = ruleHref(featuredEdge); } } @@ -207,7 +227,7 @@
@@ -227,82 +247,73 @@ />/ Explore the reduction graph Explore the reduction graph
`; renderResults(); - document - .querySelector("#atlas-search") + $("#atlas-search") .addEventListener("input", (event) => { - query = event.target.value; + query = (event.currentTarget as HTMLInputElement).value; renderResults(); }); - document.querySelectorAll("[data-filter]").forEach((button) => + document.querySelectorAll("[data-filter]").forEach((button) => button.addEventListener("click", () => { - filter = button.dataset.filter; + filter = button.dataset.filter!; document - .querySelectorAll("[data-filter]") - .forEach((b) => b.setAttribute("aria-pressed", b === button)); + .querySelectorAll("[data-filter]") + .forEach((b) => b.setAttribute("aria-pressed", String(b === button))); renderResults(); }), ); } function renderResults() { + if (!atlasRows) { + const rows = document.createElement('div'); + rows.innerHTML = catalog.map(entry => entry.html).join(''); + atlasRows = new Map(catalog.map((entry, i) => [entry.name, rows.children[i]!])); + } const search = query.trim().toLowerCase(); const alias = aliases[query.trim().toUpperCase()] || ""; - const results = families.filter((name) => { - const schema = schemaOf(name); - return ( - (filter === "all" || variantsOf(name)[0].category === filter) && - (!search || - name === alias || - `${name} ${nameOf(name)} ${description(name)} ${(schema?.aliases || []).join(" ")}` - .toLowerCase() - .includes(search)) - ); - }).sort((a, b) => nameOf(a).localeCompare(nameOf(b))); - document.querySelector(".result-count").textContent = + const results = catalog.filter(entry => + (filter === 'all' || entry.category === filter) && + (!search || entry.name === alias || entry.search.includes(search))); + $(".result-count").textContent = `${results.length} problem${results.length === 1 ? "" : "s"}`; - document.querySelector("#atlas-results").innerHTML = results.length - ? results - .map((name) => { - const variants = variantsOf(name); - return `

${escape(nameOf(name))}

${escape(description(name))}

${escape(categories[variants[0].category] || variants[0].category)}${variants.length} variant${variants.length === 1 ? "" : "s"}${edgesOf(name).length} connections
`; - }) - .join("") - : `

No matching problems.

Try a broader term or explore another input structure.

`; - document.querySelector("#reset-search")?.addEventListener("click", () => { + const box = $('#atlas-results'); + if (results.length) box.replaceChildren(...results.map(entry => atlasRows!.get(entry.name)!)); + else box.innerHTML = `

No matching problems.

Try a broader term or explore another input structure.

`; + $("#reset-search")?.addEventListener("click", () => { query = ""; filter = "all"; atlasPage(); - document.querySelector("#atlas-search").focus(); + $("#atlas-search").focus(); }); } - function relation(edge, currentIndex) { - const source = data.nodes[edge.source], - target = data.nodes[edge.target]; + function relation(edge: RecordEdge, currentIndex: number, data: AtlasRecord) { + const source = data.nodes[edge.source]!, + target = data.nodes[edge.target]!; const outgoing = edge.source === currentIndex; const other = outgoing ? target : source; const variant = Object.keys(other.variant).length ? variantLabel(other) : ""; - return `
${outgoing ? "→ " : ""}${escape(nameOf(other.name))}${outgoing ? "" : " →"}
${variant ? `

${escape(variant)}

` : ""}
${edge.turing ? 'Turing reduction' : ""}
`; + return `
${outgoing ? "→ " : ""}${escape(nameOf(other.name))}${outgoing ? "" : " →"}
${variant ? `

${escape(variant)}

` : ""}
${edge.turing ? 'Turing reduction' : ""}
`; } - function codePanel(command) { + function codePanel(command: string) { return `
${escape(command)}
`; } - function openReferenceProblem(key) { - const [name, variant] = key.split("/"); + function openReferenceProblem(key: string) { + const [name, variant] = key.split("/") as [string, string?]; location.hash = `#problem/${encodeURIComponent(name)}${variant ? `?variant=${encodeURIComponent(variant)}` : ""}`; } - function problemPage(name, params) { - const variants = variantsOf(name); + function problemPage(name: string, params: URLSearchParams, data: AtlasRecord) { + const variants = variantsOf(name, data); if (!variants.length) return notFound(); const requested = params.get("variant"); if ( @@ -315,14 +326,14 @@ variants.find( (n) => n.variant.graph === "SimpleGraph" && n.variant.weight === "One", ) || - variants[0]; - const schema = schemaOf(name); + variants[0]!; + const schema = data.schemas[0]; const incoming = data.edges.filter((e) => e.target === node.index); const outgoing = data.edges.filter((e) => e.source === node.index); document.title = `${nameOf(name)} — ${baseTitle}`; main.innerHTML = /* HTML */ `