From cd4b951f78e398aa1e58436dea92d6cc219b058d Mon Sep 17 00:00:00 2001 From: Maximus Gorog Date: Tue, 7 Apr 2026 02:42:06 -0600 Subject: [PATCH] Scaffold zigzag-engine library Initial structure implementing the zigzag construction for associative n-categories (LICS 2022 paper). Includes: - monotone.rs: MonotoneMap with Wraith's R equivalence (complete) - zigzag.rs: Zigzag, ZigzagMap with composition - diagram.rs: Diagram, DiagramN, Cospan, Rewrite, Cone types - signature.rs: Generator, Signature (complete) - degeneracy.rs: Degeneracy detection stubs - normalise.rs: Construction 17 algorithm structure - typecheck.rs: Type checking against signatures - explosion.rs: k-points and Poset for layout - layout.rs: SpringConstraint API surface All 33 unit tests pass. Co-Authored-By: Claude Opus 4.5 --- .gitignore | 1 + Cargo.lock | 642 ++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 12 + src/degeneracy.rs | 185 +++++++++++++ src/diagram.rs | 375 +++++++++++++++++++++++++++ src/explosion.rs | 309 ++++++++++++++++++++++ src/layout.rs | 320 +++++++++++++++++++++++ src/lib.rs | 48 ++++ src/monotone.rs | 325 +++++++++++++++++++++++ src/normalise.rs | 428 +++++++++++++++++++++++++++++++ src/signature.rs | 200 +++++++++++++++ src/typecheck.rs | 229 +++++++++++++++++ src/zigzag.rs | 291 +++++++++++++++++++++ 13 files changed, 3365 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/degeneracy.rs create mode 100644 src/diagram.rs create mode 100644 src/explosion.rs create mode 100644 src/layout.rs create mode 100644 src/lib.rs create mode 100644 src/monotone.rs create mode 100644 src/normalise.rs create mode 100644 src/signature.rs create mode 100644 src/typecheck.rs create mode 100644 src/zigzag.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..3007a52 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,642 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[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.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[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.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[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 = "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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[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.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[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.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[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 = "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", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +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", + "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.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[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", +] + +[[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_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[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 = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +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.2", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[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.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zigzag-engine" +version = "0.1.0" +dependencies = [ + "proptest", + "thiserror", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..602c45d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "zigzag-engine" +version = "0.1.0" +edition = "2021" +description = "Zigzag construction and normalisation for associative n-categories" +license = "BSD-3-Clause" + +[dependencies] +thiserror = "1" + +[dev-dependencies] +proptest = "1" diff --git a/src/degeneracy.rs b/src/degeneracy.rs new file mode 100644 index 0000000..4ba6725 --- /dev/null +++ b/src/degeneracy.rs @@ -0,0 +1,185 @@ +//! Degeneracy maps +//! +//! Degeneracy maps are the central concept for normalisation. They "inject identity +//! structure" into diagrams. A degeneracy map d: N → T witnesses that N is a +//! "sub-diagram" of T obtained by removing redundant identity structure. +//! +//! Key properties: +//! - Lemma 6: Isomorphisms are degeneracy maps +//! - Lemma 7: Any degeneracy factors UNIQUELY into simple ∘ parallel +//! - Lemma 8: Degeneracy maps are monomorphisms +//! - Proposition 13: Pullbacks of degeneracies exist and are degeneracies + +use crate::diagram::{Diagram, DiagramMap, Rewrite}; +use crate::monotone::MonotoneMap; + +/// Result of factoring a degeneracy map into simple ∘ parallel. +#[derive(Debug, Clone)] +pub struct DegeneracyFactorisation { + /// The simple degeneracy (π-cocartesian over a monomorphism) + pub simple: DiagramMap, + /// The parallel degeneracy (π-vertical with degeneracy slices) + pub parallel: DiagramMap, +} + +/// Check if a zigzag map is a simple degeneracy. +/// +/// A simple degeneracy is π-cocartesian over a monomorphism in Δ₊. +/// Geometrically, it inserts identity cospans at certain positions. +/// +/// # Arguments +/// * `singular_map` - The singular component of the zigzag map +/// * `_source` - The source diagram (used for slice verification) +/// * `_target` - The target diagram (used for slice verification) +pub fn is_simple_degeneracy( + singular_map: &MonotoneMap, + _source: &Diagram, + _target: &Diagram, +) -> bool { + // A simple degeneracy has an injective singular map (face map composition) + // and identity slice maps at all heights + singular_map.is_injective() + // TODO: Also verify that all slice maps are identities +} + +/// Check if a diagram map is a parallel degeneracy. +/// +/// A parallel degeneracy is π-vertical (singular map is identity) with +/// all slice maps being degeneracies in the lower dimension. +pub fn is_parallel_degeneracy(map: &DiagramMap) -> bool { + match &map.rewrite { + Rewrite::Identity => true, + Rewrite::Rewrite0 { source, target } => source == target, + Rewrite::RewriteN(r) => { + // π-vertical means no cones (singular map is identity) + // and all slices must be degeneracies recursively + r.cones.is_empty() + // TODO: Verify all implicit slice maps are degeneracies + } + } +} + +/// Check if a diagram map is a degeneracy map. +/// +/// Degeneracy maps are generated under composition by simple and parallel +/// degeneracies. By Lemma 7, every degeneracy factors uniquely into +/// simple ∘ parallel. +pub fn is_degeneracy(map: &DiagramMap) -> bool { + match &map.rewrite { + Rewrite::Identity => true, + Rewrite::Rewrite0 { source, target } => { + // For dimension 0, degeneracies are isomorphisms + source == target + } + Rewrite::RewriteN(_) => { + // TODO: Check if factors into simple ∘ parallel + // For now, conservatively return false + false + } + } +} + +/// Factor a degeneracy map into simple ∘ parallel (Lemma 7). +/// +/// Every degeneracy map d: N → T factors uniquely (up to isomorphism) as: +/// ```text +/// N --simple--> P --parallel--> T +/// ``` +/// where: +/// - The simple part is π-cocartesian over a monomorphism +/// - The parallel part is π-vertical with degeneracy slices +/// +/// # Returns +/// Some(factorisation) if the map is a degeneracy, None otherwise. +pub fn factor_degeneracy(map: &DiagramMap) -> Option { + if !is_degeneracy(map) { + return None; + } + + // For identity maps, both parts are identity + if map.is_identity() { + return Some(DegeneracyFactorisation { + simple: map.clone(), + parallel: map.clone(), + }); + } + + // TODO: Implement proper factorisation algorithm + // This requires: + // 1. Extract the simple part (the π-projection to Δ₊) + // 2. Extract the parallel part (the fiber data) + // 3. Construct the intermediate diagram P + + None +} + +/// Result of pulling back two degeneracy maps. +#[derive(Debug, Clone)] +pub struct DegeneracyPullback { + /// The pullback object + pub apex: Diagram, + /// Projection to the first diagram + pub proj1: DiagramMap, + /// Projection to the second diagram + pub proj2: DiagramMap, +} + +/// Compute the pullback of two degeneracy maps (Proposition 13). +/// +/// Given degeneracy maps f: X → T and g: Y → T, compute their pullback P +/// with projections p1: P → X and p2: P → Y that are also degeneracies. +/// +/// This is critical for normalisation: Deg(T) is closed under intersection. +/// +/// # Returns +/// Some(pullback) if both maps are degeneracies, None otherwise. +pub fn pullback_degeneracies( + _f: &DiagramMap, + _g: &DiagramMap, + _target: &Diagram, +) -> Option { + // TODO: Implement pullback computation + // Algorithm sketch: + // 1. Factor both f and g into simple ∘ parallel + // 2. Compute pullback of simple parts (intersection of image ordinals) + // 3. Compute pullback of parallel parts (recursive on slices) + // 4. Verify projections are degeneracies + + None +} + +/// Create a simple degeneracy that inserts an identity cospan at position i. +/// +/// Given a zigzag of length n, returns the π-cocartesian map over dᵢ: n → n+1 +/// that inserts an identity cospan at singular height i. +pub fn simple_degeneracy_at(_diagram: &Diagram, _i: usize) -> DiagramMap { + // TODO: Construct the degeneracy map + // The singular map is the face map dᵢ + // All slice maps are identities + // The inserted cospan has identity forward and backward + DiagramMap::new(Rewrite::Identity) +} + +/// Check if a cospan is an identity cospan (both legs are isomorphisms). +pub fn is_identity_cospan(cospan: &crate::diagram::Cospan) -> bool { + cospan.is_identity() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_identity_is_degeneracy() { + let id = DiagramMap::new(Rewrite::Identity); + assert!(is_degeneracy(&id)); + assert!(is_parallel_degeneracy(&id)); + } + + #[test] + fn test_factor_identity() { + let id = DiagramMap::new(Rewrite::Identity); + let factored = factor_degeneracy(&id); + assert!(factored.is_some()); + } +} diff --git a/src/diagram.rs b/src/diagram.rs new file mode 100644 index 0000000..363d4af --- /dev/null +++ b/src/diagram.rs @@ -0,0 +1,375 @@ +//! Diagrams in associative n-categories +//! +//! An n-diagram is an object of Zⁿ(ℕ), the n-fold iterated zigzag category +//! over natural numbers. The natural number at each point encodes the dimension +//! of the algebraic generator at that location. +//! +//! The concrete representation uses: +//! - `Diagram`: enum of Diagram0 | DiagramN +//! - `DiagramN`: source diagram + list of cospans (the zigzag structure) +//! - `Cospan`: forward rewrite + backward rewrite +//! - `Rewrite`: zigzag map between diagram slices +//! - `Cone`: atomic rewrite data + +use crate::signature::Generator; + +/// An n-diagram in the iterated zigzag category. +/// +/// This is the main data structure representing diagrams in associative n-categories. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Diagram { + /// A 0-diagram: a single generator (point in the signature). + Diagram0(Generator), + /// An n-diagram for n > 0: source + zigzag of cospans. + DiagramN(DiagramN), +} + +/// An n-dimensional diagram (n > 0). +/// +/// Represented as: +/// - A source (n-1)-diagram (the first regular slice) +/// - A sequence of cospans encoding the zigzag structure +/// +/// The regular slices r₀, r₁, ..., rₙ are: +/// - r₀ = source +/// - rᵢ₊₁ = backward.target of cospan i (equivalently, forward.target of cospan i) +/// +/// The singular slices s₀, s₁, ..., sₙ₋₁ are the apexes of each cospan. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiagramN { + /// The source diagram (first regular slice, r₀) + pub source: Box, + /// The cospans forming the zigzag structure + pub cospans: Vec, +} + +impl DiagramN { + /// Create a new n-diagram. + pub fn new(source: Diagram, cospans: Vec) -> Self { + Self { + source: Box::new(source), + cospans, + } + } + + /// Create an identity diagram (zigzag of length 0). + pub fn identity(source: Diagram) -> Self { + Self { + source: Box::new(source), + cospans: vec![], + } + } + + /// The zigzag length (number of cospans / singular heights). + pub fn length(&self) -> usize { + self.cospans.len() + } + + /// Get the source (first regular slice). + pub fn source(&self) -> &Diagram { + &self.source + } + + /// Compute the target (last regular slice). + /// + /// For an identity (length 0), this is the same as source. + /// Otherwise, we traverse the rewrites to find the final regular slice. + pub fn target(&self) -> Diagram { + // TODO: Implement proper slice computation through rewrites + // For now, return source for identity diagrams + if self.cospans.is_empty() { + (*self.source).clone() + } else { + // Placeholder: proper implementation requires traversing cospan structure + (*self.source).clone() + } + } + + /// Get the regular slice at height h. + /// + /// - h = 0: source + /// - h > 0: computed by applying rewrites + pub fn regular_slice(&self, h: usize) -> Option { + if h == 0 { + Some((*self.source).clone()) + } else if h <= self.cospans.len() { + // TODO: Compute via rewrite application + None + } else { + None + } + } + + /// Get the singular slice at height h. + pub fn singular_slice(&self, h: usize) -> Option { + if h < self.cospans.len() { + // TODO: Compute via cospan apex + None + } else { + None + } + } +} + +/// A cospan in a zigzag: rₕ → sₕ ← rₕ₊₁ +/// +/// The forward rewrite goes from the left regular to the singular. +/// The backward rewrite goes from the right regular to the singular. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Cospan { + /// Rewrite from left regular slice to singular apex + pub forward: Rewrite, + /// Rewrite from right regular slice to singular apex + pub backward: Rewrite, +} + +impl Cospan { + /// Create a new cospan. + pub fn new(forward: Rewrite, backward: Rewrite) -> Self { + Self { forward, backward } + } + + /// Check if this is an identity cospan (both legs are isomorphisms). + pub fn is_identity(&self) -> bool { + self.forward.is_identity() && self.backward.is_identity() + } +} + +/// A rewrite (zigzag map) between diagram slices. +/// +/// This encodes how one (n-1)-diagram maps to another within the zigzag structure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Rewrite { + /// Identity rewrite (does nothing). + Identity, + /// A 0-dimensional rewrite between generators. + Rewrite0 { + source: Generator, + target: Generator, + }, + /// An n-dimensional rewrite (n > 0), encoded as a sequence of cones. + RewriteN(RewriteN), +} + +impl Rewrite { + /// Check if this is an identity rewrite. + pub fn is_identity(&self) -> bool { + matches!(self, Rewrite::Identity) + } + + /// The dimension of this rewrite. + pub fn dimension(&self) -> usize { + match self { + Rewrite::Identity => 0, + Rewrite::Rewrite0 { .. } => 0, + Rewrite::RewriteN(r) => r.dimension, + } + } +} + +/// An n-dimensional rewrite (n > 0). +/// +/// Encoded as a sequence of cones, where each cone describes an atomic +/// transformation at a specific position. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RewriteN { + /// Dimension of this rewrite (> 0) + pub dimension: usize, + /// The cones encoding the rewrite structure + pub cones: Vec, +} + +impl RewriteN { + /// Create a new n-dimensional rewrite. + pub fn new(dimension: usize, cones: Vec) -> Self { + assert!(dimension > 0, "RewriteN dimension must be > 0"); + Self { dimension, cones } + } + + /// Create an identity rewrite at dimension n. + pub fn identity(dimension: usize) -> Self { + Self { + dimension, + cones: vec![], + } + } +} + +/// A cone: atomic rewrite data. +/// +/// A cone describes a single "bubble" in a string diagram — a region where +/// a source configuration of cospans is replaced by a target cospan. +/// +/// The singular map structure of the parent rewrite is determined by the +/// indices of the cones. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Cone { + /// Index in the target zigzag where this cone maps to + pub index: usize, + /// Source configuration (sequence of cospans being contracted) + pub source: Vec, + /// Target cospan (what the source contracts to) + pub target: Cospan, + /// Slice rewrites for each interior boundary + pub slices: Vec, +} + +impl Cone { + /// Create a new cone. + pub fn new(index: usize, source: Vec, target: Cospan, slices: Vec) -> Self { + Self { + index, + source, + target, + slices, + } + } + + /// The number of source cospans this cone contracts. + pub fn source_size(&self) -> usize { + self.source.len() + } +} + +// === Diagram methods === + +impl Diagram { + /// The dimension of this diagram. + pub fn dimension(&self) -> usize { + match self { + Diagram::Diagram0(_) => 0, + Diagram::DiagramN(d) => 1 + d.source.dimension(), + } + } + + /// The zigzag length at the top level. + /// + /// For a 0-diagram, this is 0. + /// For an n-diagram, this is the number of cospans. + pub fn length(&self) -> usize { + match self { + Diagram::Diagram0(_) => 0, + Diagram::DiagramN(d) => d.length(), + } + } + + /// Check if this is a 0-diagram. + pub fn is_zero(&self) -> bool { + matches!(self, Diagram::Diagram0(_)) + } + + /// Create an identity diagram over this one (adds one dimension). + pub fn identity(self) -> DiagramN { + DiagramN::identity(self) + } + + /// Get the source of this diagram. + /// + /// For a 0-diagram, returns None. + /// For an n-diagram, returns the source (n-1)-diagram. + pub fn source(&self) -> Option<&Diagram> { + match self { + Diagram::Diagram0(_) => None, + Diagram::DiagramN(d) => Some(&d.source), + } + } + + /// Get the target of this diagram. + pub fn target(&self) -> Option { + match self { + Diagram::Diagram0(_) => None, + Diagram::DiagramN(d) => Some(d.target()), + } + } + + /// Check if this diagram is globular. + /// + /// A diagram is globular if: + /// - It's a 0-diagram, OR + /// - Its source and target are equal globular diagrams + pub fn is_globular(&self) -> bool { + match self { + Diagram::Diagram0(_) => true, + Diagram::DiagramN(d) => { + let src = d.source(); + let tgt = d.target(); + src.is_globular() && tgt.is_globular() && src == &tgt + } + } + } +} + +impl From for Diagram { + fn from(g: Generator) -> Self { + Diagram::Diagram0(g) + } +} + +impl From for Diagram { + fn from(d: DiagramN) -> Self { + Diagram::DiagramN(d) + } +} + +/// A map between diagrams (zigzag map in the iterated category). +/// +/// This is the full morphism structure, containing the singular map +/// and all slice data recursively. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiagramMap { + /// The rewrite encoding this map + pub rewrite: Rewrite, +} + +impl DiagramMap { + /// Create a diagram map from a rewrite. + pub fn new(rewrite: Rewrite) -> Self { + Self { rewrite } + } + + /// Create an identity map. + pub fn identity(_diagram: &Diagram) -> Self { + Self { + rewrite: Rewrite::Identity, + } + } + + /// Check if this is an identity map. + pub fn is_identity(&self) -> bool { + self.rewrite.is_identity() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_generator() -> Generator { + Generator::new(0, 0, false) + } + + #[test] + fn test_diagram_0() { + let g = test_generator(); + let d = Diagram::Diagram0(g.clone()); + assert_eq!(d.dimension(), 0); + assert!(d.is_zero()); + assert!(d.is_globular()); + } + + #[test] + fn test_diagram_n_identity() { + let g = test_generator(); + let d0 = Diagram::Diagram0(g); + let d1 = DiagramN::identity(d0); + + assert_eq!(d1.length(), 0); + assert_eq!(Diagram::DiagramN(d1).dimension(), 1); + } + + #[test] + fn test_cospan_identity() { + let c = Cospan::new(Rewrite::Identity, Rewrite::Identity); + assert!(c.is_identity()); + } +} diff --git a/src/explosion.rs b/src/explosion.rs new file mode 100644 index 0000000..e895075 --- /dev/null +++ b/src/explosion.rs @@ -0,0 +1,309 @@ +//! Explosion: k-points and poset structure for layout +//! +//! Given an n-diagram X, the k-points Ptₖ(X) form a poset that captures +//! the combinatorial structure needed for layout. A full layout is a +//! graph embedding Ptₙ(X) → ℝⁿ. +//! +//! This module provides the interface for the layout algorithm without +//! implementing the full spring-constraint solver. + +use std::cmp::Ordering; +use crate::diagram::Diagram; + +/// A height label in a diagram: either regular or singular. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum HeightLabel { + /// Regular height rⱼ + Regular(usize), + /// Singular height sᵢ + Singular(usize), +} + +impl HeightLabel { + /// Check if this is a regular height. + pub fn is_regular(&self) -> bool { + matches!(self, HeightLabel::Regular(_)) + } + + /// Check if this is a singular height. + pub fn is_singular(&self) -> bool { + matches!(self, HeightLabel::Singular(_)) + } + + /// Get the index, regardless of regular/singular. + pub fn index(&self) -> usize { + match self { + HeightLabel::Regular(i) => *i, + HeightLabel::Singular(i) => *i, + } + } +} + +/// A point in the explosion of a diagram. +/// +/// Points are sequences of height labels, with the sequence length +/// equal to the explosion depth k. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Point(pub Vec); + +impl Point { + /// Create a new point from height labels. + pub fn new(labels: Vec) -> Self { + Self(labels) + } + + /// The empty point (for 0-explosion). + pub fn empty() -> Self { + Self(vec![]) + } + + /// The depth of this point (number of coordinates). + pub fn depth(&self) -> usize { + self.0.len() + } + + /// Get the height label at a given depth. + pub fn at(&self, depth: usize) -> Option<&HeightLabel> { + self.0.get(depth) + } + + /// Extend this point with another height label. + pub fn extend(&self, label: HeightLabel) -> Self { + let mut labels = self.0.clone(); + labels.push(label); + Self(labels) + } +} + +/// A poset: a set with a partial order. +#[derive(Debug, Clone)] +pub struct Poset { + /// Elements of the poset + elements: Vec, + /// Covering relations: (i, j) means elements[i] < elements[j] and is a cover + covers: Vec<(usize, usize)>, +} + +impl Poset { + /// Create an empty poset. + pub fn empty() -> Self { + Self { + elements: vec![], + covers: vec![], + } + } + + /// Create a poset with a single element. + pub fn singleton(element: T) -> Self { + Self { + elements: vec![element], + covers: vec![], + } + } + + /// Number of elements in the poset. + pub fn len(&self) -> usize { + self.elements.len() + } + + /// Check if the poset is empty. + pub fn is_empty(&self) -> bool { + self.elements.is_empty() + } + + /// Get the elements of the poset. + pub fn elements(&self) -> &[T] { + &self.elements + } + + /// Get the covering relations. + pub fn covers(&self) -> &[(usize, usize)] { + &self.covers + } + + /// Add an element to the poset. + pub fn add_element(&mut self, element: T) -> usize { + let idx = self.elements.len(); + self.elements.push(element); + idx + } + + /// Add a covering relation (a < b). + pub fn add_cover(&mut self, lower: usize, upper: usize) { + self.covers.push((lower, upper)); + } + + /// Find an element by value, returning its index. + pub fn find(&self, element: &T) -> Option { + self.elements.iter().position(|e| e == element) + } +} + +/// Compute the k-points of a diagram. +/// +/// Ptₖ(X) is defined inductively: +/// - Pt₀(X) = {()} (single empty point) +/// - Ptₖ₊₁(X) = {(sᵢ, x) | i ∈ Xˢ, x ∈ Ptₖ(X(sᵢ))} +/// ∪ {(rⱼ, x) | j ∈ Xʳ, x ∈ Ptₖ(X(rⱼ))} +pub fn k_points(diagram: &Diagram, k: usize) -> Poset { + if k == 0 { + // Base case: single empty point + Poset::singleton(Point::empty()) + } else { + match diagram { + Diagram::Diagram0(_) => { + // A 0-diagram has no structure to explode further + // But we still need to return the empty point at any k + Poset::singleton(Point::empty()) + } + Diagram::DiagramN(d) => { + let mut poset = Poset::empty(); + + // Add points from regular heights + for j in 0..=d.length() { + if let Some(slice) = d.regular_slice(j) { + let sub_points = k_points(&slice, k - 1); + for sub_point in sub_points.elements() { + let point = sub_point.extend(HeightLabel::Regular(j)); + poset.add_element(point); + } + } + } + + // Add points from singular heights + for i in 0..d.length() { + if let Some(slice) = d.singular_slice(i) { + let sub_points = k_points(&slice, k - 1); + for sub_point in sub_points.elements() { + let point = sub_point.extend(HeightLabel::Singular(i)); + poset.add_element(point); + } + } + } + + // TODO: Compute covering relations from cospan structure + + poset + } + } + } +} + +/// Compute the full explosion of a diagram. +/// +/// Returns Ptₙ(X) where n = dimension(X). +pub fn full_points(diagram: &Diagram) -> Poset { + k_points(diagram, diagram.dimension()) +} + +impl Diagram { + /// Compute k-points of this diagram. + pub fn points(&self, k: usize) -> Poset { + k_points(self, k) + } + + /// Compute full explosion (n-points where n = dimension). + pub fn full_points(&self) -> Poset { + full_points(self) + } +} + +/// Compare two points in the explosion ordering. +/// +/// Points are ordered by the product ordering on height labels, +/// where heights are ordered by their natural ordering within +/// each dimension. +pub fn compare_points(a: &Point, b: &Point) -> Option { + if a.depth() != b.depth() { + return None; + } + + let mut result = Ordering::Equal; + + for (label_a, label_b) in a.0.iter().zip(b.0.iter()) { + let cmp = compare_labels(label_a, label_b)?; + match (result, cmp) { + (Ordering::Equal, _) => result = cmp, + (_, Ordering::Equal) => {} + (Ordering::Less, Ordering::Less) => {} + (Ordering::Greater, Ordering::Greater) => {} + _ => return None, // Incomparable + } + } + + Some(result) +} + +/// Compare two height labels. +fn compare_labels(a: &HeightLabel, b: &HeightLabel) -> Option { + match (a, b) { + (HeightLabel::Regular(i), HeightLabel::Regular(j)) => Some(i.cmp(j)), + (HeightLabel::Singular(i), HeightLabel::Singular(j)) => Some(i.cmp(j)), + (HeightLabel::Regular(r), HeightLabel::Singular(s)) => { + // Regular r comes before singular s if r ≤ s + // and after if r > s + if *r <= *s { + Some(Ordering::Less) + } else { + Some(Ordering::Greater) + } + } + (HeightLabel::Singular(s), HeightLabel::Regular(r)) => { + // Symmetric to above + if *s < *r { + Some(Ordering::Less) + } else { + Some(Ordering::Greater) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::signature::Generator; + use crate::diagram::DiagramN; + + #[test] + fn test_point_creation() { + let p = Point::new(vec![ + HeightLabel::Regular(0), + HeightLabel::Singular(1), + ]); + assert_eq!(p.depth(), 2); + assert_eq!(p.at(0), Some(&HeightLabel::Regular(0))); + } + + #[test] + fn test_zero_points() { + let g = Generator::point(0); + let d = Diagram::Diagram0(g); + + let pts = d.points(0); + assert_eq!(pts.len(), 1); + } + + #[test] + fn test_identity_points() { + let g = Generator::point(0); + let d0 = Diagram::Diagram0(g); + let d1 = Diagram::DiagramN(DiagramN::identity(d0)); + + // 1-points of an identity should have one point (the regular height) + let pts = d1.points(1); + assert!(pts.len() >= 1); + } + + #[test] + fn test_compare_labels() { + assert_eq!( + compare_labels(&HeightLabel::Regular(0), &HeightLabel::Regular(1)), + Some(Ordering::Less) + ); + assert_eq!( + compare_labels(&HeightLabel::Regular(1), &HeightLabel::Singular(0)), + Some(Ordering::Greater) + ); + } +} diff --git a/src/layout.rs b/src/layout.rs new file mode 100644 index 0000000..594a211 --- /dev/null +++ b/src/layout.rs @@ -0,0 +1,320 @@ +//! Layout constraints for diagram rendering +//! +//! This module defines the constraint types for the layout algorithm. +//! The actual solver is NOT implemented here — this is the API surface +//! that a spring-constraint rendering system would use. +//! +//! The layout problem is: +//! - Given: explosion Ptₙ(X) as a poset +//! - Find: embedding Ptₙ(X) → ℝⁿ +//! - Subject to: ordering constraints (hard), fairness constraints (soft), +//! and spring constraints (soft, from learned model) + +use std::collections::HashMap; +use crate::explosion::Point; + +/// A computed layout: positions for each point in the explosion. +#[derive(Debug, Clone)] +pub struct Layout { + /// Positions of each point (keyed by point) + pub positions: HashMap>, + /// Computed width of the diagram in each dimension + pub dimensions: Vec, +} + +impl Layout { + /// Create a new empty layout. + pub fn new() -> Self { + Self { + positions: HashMap::new(), + dimensions: vec![], + } + } + + /// Get the position of a point. + pub fn position(&self, point: &Point) -> Option<&Vec> { + self.positions.get(point) + } + + /// Set the position of a point. + pub fn set_position(&mut self, point: Point, position: Vec) { + let dim = position.len(); + if self.dimensions.len() < dim { + self.dimensions.resize(dim, 0.0); + } + for (i, &p) in position.iter().enumerate() { + if p > self.dimensions[i] { + self.dimensions[i] = p; + } + } + self.positions.insert(point, position); + } +} + +impl Default for Layout { + fn default() -> Self { + Self::new() + } +} + +/// All constraints for a layout problem. +#[derive(Debug, Clone, Default)] +pub struct LayoutConstraints { + /// Hard ordering constraints (must be satisfied) + pub ordering: Vec, + /// Soft fairness constraints (centering, symmetry) + pub fairness: Vec, + /// Soft spring constraints (from learned model) + pub springs: Vec, +} + +impl LayoutConstraints { + /// Create empty constraints. + pub fn new() -> Self { + Self::default() + } + + /// Add an ordering constraint. + pub fn add_ordering(&mut self, constraint: OrderingConstraint) { + self.ordering.push(constraint); + } + + /// Add a fairness constraint. + pub fn add_fairness(&mut self, constraint: FairnessConstraint) { + self.fairness.push(constraint); + } + + /// Add a spring constraint. + pub fn add_spring(&mut self, constraint: SpringConstraint) { + self.springs.push(constraint); + } +} + +/// Hard ordering constraint: point a must come before point b in dimension d. +/// +/// This encodes: position(a)[d] < position(b)[d] +#[derive(Debug, Clone)] +pub struct OrderingConstraint { + /// Point that must come first + pub lower: Point, + /// Point that must come second + pub upper: Point, + /// Dimension in which the ordering applies + pub dimension: usize, + /// Minimum gap between points (default: some small epsilon) + pub min_gap: f64, +} + +impl OrderingConstraint { + /// Create a new ordering constraint with default gap. + pub fn new(lower: Point, upper: Point, dimension: usize) -> Self { + Self { + lower, + upper, + dimension, + min_gap: 1.0, // Default unit gap + } + } + + /// Create a new ordering constraint with specified gap. + pub fn with_gap(lower: Point, upper: Point, dimension: usize, min_gap: f64) -> Self { + Self { + lower, + upper, + dimension, + min_gap, + } + } +} + +/// Soft fairness constraint: centering and symmetry. +/// +/// Typical fairness constraints: +/// - Center a point between its neighbours +/// - Make sibling points equidistant from their parent +#[derive(Debug, Clone)] +pub struct FairnessConstraint { + /// The point to position + pub point: Point, + /// Points to center between (if centering) + pub reference_points: Vec, + /// Dimension in which to apply fairness + pub dimension: usize, + /// Weight of this constraint (higher = more important) + pub weight: f64, + /// Type of fairness + pub kind: FairnessKind, +} + +/// Types of fairness constraints. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FairnessKind { + /// Center point between reference points + Center, + /// Make point equidistant from reference points + Equidistant, + /// Minimize total edge length + MinimizeLength, +} + +impl FairnessConstraint { + /// Create a centering constraint. + pub fn center(point: Point, references: Vec, dimension: usize, weight: f64) -> Self { + Self { + point, + reference_points: references, + dimension, + weight, + kind: FairnessKind::Center, + } + } +} + +/// Spring constraint: pull a point toward a target position. +/// +/// This is the key extension point for the semiotic layer: +/// a learned model predicts target positions τ(p), and spring +/// constraints pull the layout toward those targets. +/// +/// Energy contribution: k * ||position(p) - target||² +#[derive(Debug, Clone)] +pub struct SpringConstraint { + /// The point to constrain + pub point: Point, + /// Target position (from learned model) + pub target_position: Vec, + /// Spring constant (stiffness) + pub stiffness: f64, +} + +impl SpringConstraint { + /// Create a new spring constraint. + pub fn new(point: Point, target_position: Vec, stiffness: f64) -> Self { + Self { + point, + target_position, + stiffness, + } + } + + /// The energy contribution of this spring given a position. + pub fn energy(&self, position: &[f64]) -> f64 { + if position.len() != self.target_position.len() { + return f64::INFINITY; + } + + let dist_sq: f64 = position + .iter() + .zip(&self.target_position) + .map(|(p, t)| (p - t).powi(2)) + .sum(); + + self.stiffness * dist_sq + } + + /// The gradient of energy with respect to position. + pub fn gradient(&self, position: &[f64]) -> Vec { + position + .iter() + .zip(&self.target_position) + .map(|(p, t)| 2.0 * self.stiffness * (p - t)) + .collect() + } +} + +/// A layout solver interface. +/// +/// This trait defines the interface for layout solvers. +/// The actual solver (HiGHS for LP/QP) would implement this. +pub trait LayoutSolver { + /// Solve the layout problem. + /// + /// # Arguments + /// * `constraints` - The layout constraints + /// * `points` - All points that need positions + /// * `dimension` - The dimension of the embedding space + /// + /// # Returns + /// A `Layout` with positions for all points, or an error. + fn solve( + &self, + constraints: &LayoutConstraints, + points: &[Point], + dimension: usize, + ) -> Result; +} + +/// Errors that can occur during layout solving. +#[derive(Debug, Clone)] +pub enum LayoutError { + /// The constraints are infeasible (no valid layout exists). + Infeasible, + /// The solver failed for an internal reason. + SolverError(String), + /// The problem is unbounded. + Unbounded, +} + +impl std::fmt::Display for LayoutError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LayoutError::Infeasible => write!(f, "Layout constraints are infeasible"), + LayoutError::SolverError(msg) => write!(f, "Layout solver error: {}", msg), + LayoutError::Unbounded => write!(f, "Layout problem is unbounded"), + } + } +} + +impl std::error::Error for LayoutError {} + +#[cfg(test)] +mod tests { + use super::*; + use crate::explosion::HeightLabel; + + #[test] + fn test_layout_creation() { + let mut layout = Layout::new(); + let p = Point::new(vec![HeightLabel::Regular(0)]); + layout.set_position(p.clone(), vec![1.0, 2.0]); + + assert_eq!(layout.position(&p), Some(&vec![1.0, 2.0])); + } + + #[test] + fn test_spring_energy() { + let p = Point::new(vec![HeightLabel::Regular(0)]); + let spring = SpringConstraint::new(p, vec![0.0, 0.0], 1.0); + + // Position at target: zero energy + assert_eq!(spring.energy(&[0.0, 0.0]), 0.0); + + // Position at (1, 0): energy = 1 * 1² = 1 + assert_eq!(spring.energy(&[1.0, 0.0]), 1.0); + + // Position at (1, 1): energy = 1 * (1² + 1²) = 2 + assert_eq!(spring.energy(&[1.0, 1.0]), 2.0); + } + + #[test] + fn test_spring_gradient() { + let p = Point::new(vec![HeightLabel::Regular(0)]); + let spring = SpringConstraint::new(p, vec![0.0, 0.0], 1.0); + + // Gradient at (1, 2): [2*1*(1-0), 2*1*(2-0)] = [2, 4] + let grad = spring.gradient(&[1.0, 2.0]); + assert_eq!(grad, vec![2.0, 4.0]); + } + + #[test] + fn test_ordering_constraint() { + let p1 = Point::new(vec![HeightLabel::Regular(0)]); + let p2 = Point::new(vec![HeightLabel::Regular(1)]); + + let constraint = OrderingConstraint::new(p1.clone(), p2.clone(), 0); + + assert_eq!(constraint.lower, p1); + assert_eq!(constraint.upper, p2); + assert_eq!(constraint.dimension, 0); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..64751aa --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,48 @@ +//! Zigzag Engine: Standalone library for associative n-categories +//! +//! This library implements the zigzag construction and normalisation algorithm +//! from "Zigzag normalisation for associative n-categories" (Heidemann, Reutter, Vicary, LICS 2022). +//! +//! # Overview +//! +//! The zigzag construction provides a combinatorial model of higher categories where: +//! - Objects are n-diagrams (iterated zigzags over natural numbers) +//! - Morphisms are zigzag maps (structure-preserving transformations) +//! - Normalisation removes redundant identity structure while preserving essential identities +//! +//! # Core Concepts +//! +//! - [`MonotoneMap`]: Order-preserving maps between finite ordinals +//! - [`Zigzag`]: A sequence of cospans with alternating regular/singular objects +//! - [`Diagram`]: An n-diagram in the iterated zigzag category +//! - [`Rewrite`]: A zigzag map between diagrams +//! +//! # Example +//! +//! ```ignore +//! use zigzag_engine::{Diagram, normalise}; +//! +//! let diagram: Diagram = /* construct diagram */; +//! let result = diagram.normalise(); +//! assert!(result.normal_form.is_normalised()); +//! ``` + +pub mod monotone; +pub mod zigzag; +pub mod diagram; +pub mod signature; +pub mod degeneracy; +pub mod normalise; +pub mod typecheck; +pub mod explosion; +pub mod layout; + +// Re-exports for convenience +pub use monotone::MonotoneMap; +pub use zigzag::{Zigzag, ZigzagMap}; +pub use diagram::{Diagram, DiagramN, Cospan, Rewrite, Cone}; +pub use signature::{Generator, Signature}; +pub use normalise::{NormalisationResult, normalise, normalise_sink}; +pub use typecheck::{TypeError, type_check}; +pub use explosion::{Point, HeightLabel}; +pub use layout::{Layout, LayoutConstraints, SpringConstraint}; diff --git a/src/monotone.rs b/src/monotone.rs new file mode 100644 index 0000000..9f68d91 --- /dev/null +++ b/src/monotone.rs @@ -0,0 +1,325 @@ +//! Monotone maps in the simplex category Δ₊ +//! +//! The simplex category Δ₊ has: +//! - Objects: finite total orders `n = {0, 1, ..., n-1}` for n ≥ 0 +//! - Morphisms: order-preserving (monotone) maps +//! +//! Key constructions: +//! - Face maps dᵢ: n → n+1 (the unique injective map omitting i from image) +//! - Wraith's equivalence R: Δ₊ → Δ₌ᵒᵖ + +use std::ops::Range; + +/// A monotone (order-preserving) map between finite ordinals. +/// +/// Represents f: source → target where source = self.values.len() +/// and target = self.target_size. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct MonotoneMap { + /// The function values: values[i] = f(i) + values: Vec, + /// Size of the target ordinal + target_size: usize, +} + +impl MonotoneMap { + /// Create a new monotone map with explicit target size. + /// + /// # Panics + /// Panics if the map is not monotone or values exceed target_size. + pub fn new(values: Vec, target_size: usize) -> Self { + // Validate monotonicity + for window in values.windows(2) { + assert!( + window[0] <= window[1], + "Map is not monotone: {} > {}", + window[0], + window[1] + ); + } + // Validate range + for &v in &values { + assert!( + v < target_size || (target_size == 0 && values.is_empty()), + "Value {} exceeds target size {}", + v, + target_size + ); + } + Self { values, target_size } + } + + /// Create the identity map on n. + pub fn identity(n: usize) -> Self { + Self { + values: (0..n).collect(), + target_size: n, + } + } + + /// Create the unique map from 0 (empty ordinal) to n. + pub fn from_empty(target_size: usize) -> Self { + Self { + values: vec![], + target_size, + } + } + + /// Create the face map dᵢ: n → n+1. + /// + /// The face map is the unique injective monotone map omitting i from its image. + /// So dᵢ(j) = j if j < i, and dᵢ(j) = j+1 if j ≥ i. + /// + /// # Panics + /// Panics if i > n (i must be in 0..=n). + pub fn face_map(n: usize, i: usize) -> Self { + assert!(i <= n, "Face map index {} exceeds source size {}", i, n); + let values: Vec = (0..n).map(|j| if j < i { j } else { j + 1 }).collect(); + Self { + values, + target_size: n + 1, + } + } + + /// Size of the source ordinal. + pub fn source_size(&self) -> usize { + self.values.len() + } + + /// Size of the target ordinal. + pub fn target_size(&self) -> usize { + self.target_size + } + + /// Apply the map to a value. + /// + /// # Panics + /// Panics if i >= source_size. + pub fn apply(&self, i: usize) -> usize { + self.values[i] + } + + /// Compose two monotone maps: (g ∘ f)(x) = g(f(x)). + /// + /// # Panics + /// Panics if f.target_size != g.source_size. + pub fn compose(&self, other: &MonotoneMap) -> MonotoneMap { + assert_eq!( + self.target_size, + other.source_size(), + "Cannot compose: target size {} != source size {}", + self.target_size, + other.source_size() + ); + let values = self.values.iter().map(|&i| other.apply(i)).collect(); + MonotoneMap { + values, + target_size: other.target_size, + } + } + + /// Check if this map is injective (a monomorphism in Δ₊). + pub fn is_injective(&self) -> bool { + for window in self.values.windows(2) { + if window[0] == window[1] { + return false; + } + } + true + } + + /// Check if this map is surjective (an epimorphism in Δ₊). + pub fn is_surjective(&self) -> bool { + if self.target_size == 0 { + return true; + } + if self.values.is_empty() { + return false; + } + // For monotone maps, surjective iff image covers all of target + // Since monotone, just need first value = 0 and last value = target_size - 1 + // and no gaps (which monotone + covering endpoints guarantees) + self.values.first() == Some(&0) + && self.values.last() == Some(&(self.target_size - 1)) + && self.values.len() >= self.target_size + } + + /// Check if this is an identity map. + pub fn is_identity(&self) -> bool { + self.source_size() == self.target_size + && self.values.iter().enumerate().all(|(i, &v)| i == v) + } + + /// Check if this is a face map dᵢ for some i, returning i if so. + pub fn is_face_map(&self) -> Option { + if self.target_size != self.source_size() + 1 { + return None; + } + if !self.is_injective() { + return None; + } + // Find which element is missing from image + let mut missing = None; + let mut expected = 0; + for &v in &self.values { + if v != expected { + if missing.is_some() || v != expected + 1 { + return None; + } + missing = Some(expected); + expected = v + 1; + } else { + expected += 1; + } + } + // If no gap found in iteration, the missing element is at the end + Some(missing.unwrap_or(self.target_size - 1)) + } + + /// Compute the preimage f⁻¹(i) as a range. + /// + /// For monotone maps, preimages are always contiguous intervals. + pub fn preimage(&self, i: usize) -> Range { + // Find first j where f(j) >= i + let start = self.values.iter().position(|&v| v >= i); + // Find first j where f(j) > i + let end = self.values.iter().position(|&v| v > i); + + match (start, end) { + (Some(s), Some(e)) => { + if self.values[s] == i { + s..e + } else { + s..s // empty range + } + } + (Some(s), None) => { + if self.values[s] == i { + s..self.values.len() + } else { + s..s + } + } + (None, _) => self.values.len()..self.values.len(), + } + } + + /// Wraith's equivalence R: Δ₊ → Δ₌ᵒᵖ + /// + /// Given f: n → m, returns (Rf)ᵒᵖ: m+1 → n+1 defined by: + /// i ↦ min({j ∈ n | f(j) ≥ i} ∪ {n}) + /// + /// Geometric intuition: elements of n sit in gaps between elements of m+1, + /// and R computes which gap each element of m+1 falls into. + pub fn wraith_r(&self) -> MonotoneMap { + let n = self.source_size(); + let m = self.target_size; + + // (Rf)ᵒᵖ: m+1 → n+1 + let values: Vec = (0..=m) + .map(|i| { + // min({j ∈ n | f(j) ≥ i} ∪ {n}) + self.values + .iter() + .position(|&fj| fj >= i) + .unwrap_or(n) + }) + .collect(); + + MonotoneMap { + values, + target_size: n + 1, + } + } + + /// Get the raw values of the map. + pub fn values(&self) -> &[usize] { + &self.values + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_identity() { + let id = MonotoneMap::identity(3); + assert_eq!(id.source_size(), 3); + assert_eq!(id.target_size(), 3); + assert!(id.is_identity()); + assert_eq!(id.apply(0), 0); + assert_eq!(id.apply(1), 1); + assert_eq!(id.apply(2), 2); + } + + #[test] + fn test_face_map() { + // d₀: 2 → 3, omits 0: [1, 2] + let d0 = MonotoneMap::face_map(2, 0); + assert_eq!(d0.values(), &[1, 2]); + assert_eq!(d0.is_face_map(), Some(0)); + + // d₁: 2 → 3, omits 1: [0, 2] + let d1 = MonotoneMap::face_map(2, 1); + assert_eq!(d1.values(), &[0, 2]); + assert_eq!(d1.is_face_map(), Some(1)); + + // d₂: 2 → 3, omits 2: [0, 1] + let d2 = MonotoneMap::face_map(2, 2); + assert_eq!(d2.values(), &[0, 1]); + assert_eq!(d2.is_face_map(), Some(2)); + } + + #[test] + fn test_compose() { + let f = MonotoneMap::new(vec![0, 1], 3); // 2 → 3 + let g = MonotoneMap::new(vec![1, 1, 2], 3); // 3 → 3 + let gf = f.compose(&g); + assert_eq!(gf.values(), &[1, 1]); // g(f(0)) = g(0) = 1, g(f(1)) = g(1) = 1 + } + + #[test] + fn test_preimage() { + let f = MonotoneMap::new(vec![0, 1, 1, 2], 3); + assert_eq!(f.preimage(0), 0..1); + assert_eq!(f.preimage(1), 1..3); + assert_eq!(f.preimage(2), 3..4); + } + + #[test] + fn test_wraith_r() { + // Example: f: 2 → 3 given by [0, 2] + // Then Rf: 4 → 3 given by i ↦ min({j | f(j) ≥ i} ∪ {2}) + // i=0: min({j | f(j) ≥ 0}) = min({0,1}) = 0 + // i=1: min({j | f(j) ≥ 1}) = min({1}) = 1 + // i=2: min({j | f(j) ≥ 2}) = min({1}) = 1 + // i=3: min({j | f(j) ≥ 3} ∪ {2}) = min({2}) = 2 + let f = MonotoneMap::new(vec![0, 2], 3); + let rf = f.wraith_r(); + assert_eq!(rf.source_size(), 4); // m+1 = 4 + assert_eq!(rf.target_size(), 3); // n+1 = 3 + assert_eq!(rf.values(), &[0, 1, 1, 2]); + } + + #[test] + fn test_wraith_r_identity() { + // For identity n → n, Rf should be identity (n+1) → (n+1) + let id = MonotoneMap::identity(3); + let r_id = id.wraith_r(); + assert_eq!(r_id.source_size(), 4); + assert_eq!(r_id.target_size(), 4); + assert!(r_id.is_identity()); + } + + #[test] + fn test_injective_surjective() { + let f = MonotoneMap::new(vec![0, 1, 1, 2], 3); + assert!(!f.is_injective()); + assert!(f.is_surjective()); + + let g = MonotoneMap::new(vec![0, 2], 3); + assert!(g.is_injective()); + assert!(!g.is_surjective()); + } +} diff --git a/src/normalise.rs b/src/normalise.rs new file mode 100644 index 0000000..06a5333 --- /dev/null +++ b/src/normalise.rs @@ -0,0 +1,428 @@ +//! Normalisation algorithm (Construction 17) +//! +//! The normalisation algorithm computes the smallest element of Deg(T), +//! the poset of degeneracy subobjects of a diagram T. This removes all +//! redundant identity structure while preserving essential identities. +//! +//! Key insight: In dimension ≥ 4, some identity cospans are ESSENTIAL — +//! removing them would make zigzag maps ill-defined (no monotone function +//! of the required type exists). The algorithm detects and preserves these. +//! +//! # Algorithm Overview (Construction 17) +//! +//! Input: A sink S = (T, {fᵢ: Aᵢ → T}) +//! Output: Degeneracy d: N → T and factorisations Aᵢ → N +//! +//! 1. Base case (dim 0): d = identity +//! 2. Recursive case: +//! a. Normalise at each regular height (recursive) +//! b. Normalise at each singular height (recursive, including cospan legs) +//! c. Assemble into zigzag P with parallel degeneracy dP +//! d. Remove trivial cospans not in image of any sink map +//! e. Compose: d = dP ∘ dS + +use crate::diagram::{Diagram, DiagramN, DiagramMap, Rewrite}; + +/// Result of normalising a diagram (or sink). +#[derive(Debug, Clone)] +pub struct NormalisationResult { + /// The normalised diagram N + pub normal_form: Diagram, + /// The degeneracy map d: N → T + pub degeneracy: DiagramMap, + /// Factorisations of each sink map through the degeneracy + pub factorisations: Vec, +} + +/// A sink: a target diagram with maps from source diagrams. +/// +/// Used for relative normalisation: find the smallest degeneracy +/// through which all sink maps factor. +#[derive(Debug, Clone)] +pub struct Sink<'a> { + /// The target diagram T + pub target: &'a Diagram, + /// Maps from source diagrams to T + pub maps: Vec, +} + +impl<'a> Sink<'a> { + /// Create a new sink. + pub fn new(target: &'a Diagram, maps: Vec) -> Self { + Self { target, maps } + } + + /// Create an empty sink (for absolute normalisation). + pub fn empty(target: &'a Diagram) -> Self { + Self { + target, + maps: vec![], + } + } +} + +/// Normalise a sink (Construction 17). +/// +/// This is the core normalisation algorithm from the LICS 2022 paper. +/// +/// # Arguments +/// * `sink` - The sink to normalise (target diagram + incoming maps) +/// +/// # Returns +/// A `NormalisationResult` containing: +/// - The normal form N +/// - The degeneracy d: N → T +/// - Factorisations Aᵢ → N for each sink map +pub fn normalise_sink(sink: &Sink) -> NormalisationResult { + match sink.target { + Diagram::Diagram0(_) => { + // Base case: dimension 0 + // The only degeneracy is the identity + NormalisationResult { + normal_form: sink.target.clone(), + degeneracy: DiagramMap::identity(sink.target), + factorisations: sink.maps.clone(), + } + } + Diagram::DiagramN(diagram_n) => { + normalise_sink_n(diagram_n, &sink.maps) + } + } +} + +/// Normalise an n-dimensional diagram (n > 0). +fn normalise_sink_n(target: &DiagramN, sink_maps: &[DiagramMap]) -> NormalisationResult { + // Step 1: Normalise at each regular height + let regular_normalisations = normalise_regular_heights(target, sink_maps); + + // Step 2: Normalise at each singular height + // CRITICAL: Include P(rₕ) → T(rₕ) → T(sₕ) composites in each sink + let singular_normalisations = normalise_singular_heights( + target, + sink_maps, + ®ular_normalisations, + ); + + // Step 3: Assemble into zigzag P with parallel degeneracy dP + let (p, d_parallel, assembled_factorisations) = assemble( + target, + ®ular_normalisations, + &singular_normalisations, + ); + + // Step 4: Remove trivial cospans not in image of any sink map + // A cospan is removable iff: + // - Both legs are isomorphisms (identity cospan) + // - AND the singular height is not in the image of any sink map + let (n, d_simple, final_factorisations) = remove_trivial_cospans( + &p, + &assembled_factorisations, + ); + + // Step 5: Compose degeneracies d = dP ∘ dS + let degeneracy = compose_degeneracies(&d_parallel, &d_simple); + + NormalisationResult { + normal_form: n, + degeneracy, + factorisations: final_factorisations, + } +} + +/// Intermediate result for regular height normalisation. +struct RegularNormalisation { + /// Normalised diagram at this regular height + normal_form: Diagram, + /// Degeneracy from normal form to original + degeneracy: DiagramMap, + /// Factorisations for each sink map at this height + factorisations: Vec, +} + +/// Normalise at each regular height of the diagram. +fn normalise_regular_heights( + target: &DiagramN, + sink_maps: &[DiagramMap], +) -> Vec { + let num_regular = target.length() + 1; + let mut results = Vec::with_capacity(num_regular); + + for h in 0..num_regular { + // Get the regular slice T(rₕ) + let t_r_h = target.regular_slice(h).unwrap_or_else(|| { + // Fallback to source if slice computation not implemented + (*target.source).clone() + }); + + // Collect sink maps restricted to this regular height + // Each fᵢ(rₕ): Aᵢ(r_{fᵢʳ(h)}) → T(rₕ) + let restricted_maps: Vec = sink_maps + .iter() + .map(|_| DiagramMap::identity(&t_r_h)) + .collect(); + + // Recursively normalise + let sub_sink = Sink::new(&t_r_h, restricted_maps); + let sub_result = normalise_sink(&sub_sink); + + results.push(RegularNormalisation { + normal_form: sub_result.normal_form, + degeneracy: sub_result.degeneracy, + factorisations: sub_result.factorisations, + }); + } + + results +} + +/// Intermediate result for singular height normalisation. +struct SingularNormalisation { + /// Normalised diagram at this singular height + normal_form: Diagram, + /// Degeneracy from normal form to original + degeneracy: DiagramMap, + /// Forward cospan leg from left regular + forward_leg: DiagramMap, + /// Backward cospan leg from right regular + backward_leg: DiagramMap, + /// Factorisations for each sink map at this height + factorisations: Vec, +} + +/// Normalise at each singular height of the diagram. +/// +/// CRITICAL: The sink at each singular height includes: +/// - Direct singular maps from sink: fᵢ(sₜ) for t ∈ (fᵢˢ)⁻¹(h) +/// - Cospan legs: P(rₕ) → T(rₕ) → T(sₕ) and P(rₕ₊₁) → T(rₕ₊₁) → T(sₕ) +fn normalise_singular_heights( + target: &DiagramN, + sink_maps: &[DiagramMap], + regular_results: &[RegularNormalisation], +) -> Vec { + let num_singular = target.length(); + let mut results = Vec::with_capacity(num_singular); + + for h in 0..num_singular { + // Get the singular slice T(sₕ) + let t_s_h = target.singular_slice(h).unwrap_or_else(|| { + // Fallback if slice computation not implemented + (*target.source).clone() + }); + + // Build the sink for this singular height: + // 1. Direct maps from sink_maps + // 2. Composites P(rₕ) → T(rₕ) → T(sₕ) + // 3. Composites P(rₕ₊₁) → T(rₕ₊₁) → T(sₕ) + let mut combined_maps: Vec = Vec::new(); + + // Add direct singular maps from sink + for _sink_map in sink_maps { + // TODO: Extract and add fᵢ(sₜ) for t in preimage of h + combined_maps.push(DiagramMap::identity(&t_s_h)); + } + + // Add cospan leg composites + // TODO: Compose regular normalisations with cospan structure + combined_maps.push(regular_results[h].degeneracy.clone()); + combined_maps.push(regular_results[h + 1].degeneracy.clone()); + + // Recursively normalise + let sub_sink = Sink::new(&t_s_h, combined_maps); + let sub_result = normalise_sink(&sub_sink); + + let forward_leg = DiagramMap::identity(&sub_result.normal_form); + let backward_leg = DiagramMap::identity(&sub_result.normal_form); + + results.push(SingularNormalisation { + normal_form: sub_result.normal_form, + degeneracy: sub_result.degeneracy, + forward_leg, + backward_leg, + factorisations: sub_result.factorisations, + }); + } + + results +} + +/// Assemble regular and singular normalisations into a zigzag P. +/// +/// Returns: +/// - P: the assembled diagram +/// - dP: the parallel degeneracy P → T +/// - Assembled factorisations +fn assemble( + target: &DiagramN, + regular_results: &[RegularNormalisation], + singular_results: &[SingularNormalisation], +) -> (Diagram, DiagramMap, Vec) { + // Build cospans from the normalisation results + let cospans: Vec = singular_results + .iter() + .map(|sr| { + crate::diagram::Cospan::new( + sr.forward_leg.rewrite.clone(), + sr.backward_leg.rewrite.clone(), + ) + }) + .collect(); + + // The source of P is the first regular normalisation + let source = regular_results + .first() + .map(|r| r.normal_form.clone()) + .unwrap_or_else(|| (*target.source).clone()); + + let p = Diagram::DiagramN(DiagramN::new(source, cospans)); + + // The parallel degeneracy is assembled from slice degeneracies + // Since all slice maps are degeneracies, the assembled map is parallel + let d_parallel = DiagramMap::new(Rewrite::Identity); // TODO: Proper assembly + + // Assemble factorisations + let factorisations = regular_results + .first() + .map(|r| r.factorisations.clone()) + .unwrap_or_default(); + + (p, d_parallel, factorisations) +} + +/// Remove trivial cospans from the assembled diagram P. +/// +/// A cospan at singular height h is removable iff: +/// 1. Both legs are isomorphisms (identity cospan) +/// 2. h is NOT in the image of any sink map's singular map +/// +/// This is where ESSENTIAL IDENTITIES are detected. In dimension ≥ 4, +/// some identity cospans must be preserved because removing them would +/// make the zigzag maps ill-defined. +/// +/// Returns: +/// - N: the diagram with trivial cospans removed +/// - dS: the simple degeneracy N → P that re-inserts them +/// - Updated factorisations +fn remove_trivial_cospans( + p: &Diagram, + factorisations: &[DiagramMap], +) -> (Diagram, DiagramMap, Vec) { + match p { + Diagram::Diagram0(_) => { + // No cospans to remove + (p.clone(), DiagramMap::identity(p), factorisations.to_vec()) + } + Diagram::DiagramN(diagram_n) => { + // Identify which cospans are trivial (identity) and not in sink image + let mut kept_cospans = Vec::new(); + let _removed_indices = Vec::::new(); + + for (h, cospan) in diagram_n.cospans.iter().enumerate() { + let is_identity = cospan.is_identity(); + let in_sink_image = is_in_sink_image(h, factorisations); + + if !is_identity || in_sink_image { + // Keep this cospan (either non-trivial or essential) + kept_cospans.push(cospan.clone()); + } + // If trivial AND not in sink image, it's removed + } + + // Build N with kept cospans + let n = Diagram::DiagramN(DiagramN::new( + (*diagram_n.source).clone(), + kept_cospans, + )); + + // Build simple degeneracy dS that re-inserts removed cospans + let d_simple = DiagramMap::identity(&n); // TODO: Proper construction + + // Update factorisations to go through dS + let updated_factorisations = factorisations.to_vec(); + + (n, d_simple, updated_factorisations) + } + } +} + +/// Check if singular height h is in the image of any sink map. +fn is_in_sink_image(_h: usize, _factorisations: &[DiagramMap]) -> bool { + // TODO: Extract singular maps from factorisations and check if h is in image + // For now, conservatively return true (don't remove anything) + true +} + +/// Compose two degeneracy maps. +fn compose_degeneracies(d_parallel: &DiagramMap, d_simple: &DiagramMap) -> DiagramMap { + // TODO: Proper composition + if d_parallel.is_identity() { + d_simple.clone() + } else if d_simple.is_identity() { + d_parallel.clone() + } else { + // Full composition needed + d_parallel.clone() + } +} + +/// Absolute normalisation: normalise with empty sink. +/// +/// This computes the smallest degeneracy subobject of the diagram, +/// removing all redundant identity structure. +pub fn normalise(diagram: &Diagram) -> NormalisationResult { + let sink = Sink::empty(diagram); + normalise_sink(&sink) +} + +impl Diagram { + /// Normalise this diagram (absolute normalisation). + pub fn normalise(&self) -> NormalisationResult { + normalise(self) + } + + /// Check if this diagram is normalised (is its own normal form). + pub fn is_normalised(&self) -> bool { + let result = self.normalise(); + result.degeneracy.is_identity() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::signature::Generator; + + #[test] + fn test_normalise_zero_diagram() { + let g = Generator::point(0); + let d = Diagram::Diagram0(g); + + let result = d.normalise(); + + assert!(result.degeneracy.is_identity()); + assert_eq!(result.normal_form, d); + } + + #[test] + fn test_normalise_identity_diagram() { + let g = Generator::point(0); + let d0 = Diagram::Diagram0(g); + let d1 = Diagram::DiagramN(DiagramN::identity(d0.clone())); + + let result = d1.normalise(); + + // Identity diagram should normalise to itself + // (an identity zigzag of length 0 has no cospans to remove) + assert_eq!(result.normal_form.length(), 0); + } + + #[test] + fn test_normalisation_idempotent() { + let g = Generator::point(0); + let d = Diagram::Diagram0(g); + + let once = d.normalise(); + let twice = once.normal_form.normalise(); + + assert_eq!(once.normal_form, twice.normal_form); + } +} diff --git a/src/signature.rs b/src/signature.rs new file mode 100644 index 0000000..6dbba33 --- /dev/null +++ b/src/signature.rs @@ -0,0 +1,200 @@ +//! Signatures for typed diagrams +//! +//! A signature Σ is a set of generators, each with: +//! - A unique identifier +//! - A dimension +//! - Source and target (n-1)-diagrams (for n > 0) +//! - An invertibility flag + +use std::collections::HashMap; + +/// A generator in a signature. +/// +/// Generators are the atomic building blocks of diagrams. +/// A generator of dimension n has source and target (n-1)-diagrams. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Generator { + /// Unique identifier + pub id: usize, + /// Dimension of this generator + pub dimension: usize, + /// Whether this generator is invertible + pub invertible: bool, +} + +impl Generator { + /// Create a new generator. + pub fn new(id: usize, dimension: usize, invertible: bool) -> Self { + Self { + id, + dimension, + invertible, + } + } + + /// Create a 0-dimensional generator (a point). + pub fn point(id: usize) -> Self { + Self { + id, + dimension: 0, + invertible: false, + } + } +} + +/// Full generator data including source and target. +/// +/// For dimension 0, source and target are None. +/// For dimension n > 0, source and target are (n-1)-diagrams. +#[derive(Debug, Clone)] +pub struct GeneratorData { + /// The generator itself + pub generator: Generator, + /// Source diagram (None for dimension 0) + pub source: Option, + /// Target diagram (None for dimension 0) + pub target: Option, + /// Optional human-readable label + pub label: Option, +} + +impl GeneratorData { + /// Create generator data for a 0-cell. + pub fn zero_cell(id: usize, label: Option) -> Self { + Self { + generator: Generator::point(id), + source: None, + target: None, + label, + } + } + + /// Create generator data for an n-cell (n > 0). + pub fn n_cell( + id: usize, + dimension: usize, + source: crate::diagram::Diagram, + target: crate::diagram::Diagram, + invertible: bool, + label: Option, + ) -> Self { + Self { + generator: Generator::new(id, dimension, invertible), + source: Some(source), + target: Some(target), + label, + } + } +} + +/// A signature: a collection of typed generators. +/// +/// The signature provides the "alphabet" for building diagrams +/// and is used for type-checking. +#[derive(Debug, Clone, Default)] +pub struct Signature { + /// All generators indexed by id + generators: HashMap, + /// Next available id for auto-generation + next_id: usize, +} + +impl Signature { + /// Create an empty signature. + pub fn new() -> Self { + Self::default() + } + + /// Add a generator to the signature. + /// + /// Returns the generator's id. + pub fn add(&mut self, data: GeneratorData) -> usize { + let id = data.generator.id; + self.next_id = self.next_id.max(id + 1); + self.generators.insert(id, data); + id + } + + /// Add a 0-cell (point) to the signature. + pub fn add_zero_cell(&mut self, label: Option) -> usize { + let id = self.next_id; + self.add(GeneratorData::zero_cell(id, label)) + } + + /// Get a generator by id. + pub fn get(&self, id: usize) -> Option<&GeneratorData> { + self.generators.get(&id) + } + + /// Get the generator (without full data) by id. + pub fn generator(&self, id: usize) -> Option<&Generator> { + self.generators.get(&id).map(|d| &d.generator) + } + + /// Check if a generator exists in the signature. + pub fn contains(&self, id: usize) -> bool { + self.generators.contains_key(&id) + } + + /// Iterate over all generators. + pub fn generators(&self) -> impl Iterator { + self.generators.values() + } + + /// Number of generators in the signature. + pub fn len(&self) -> usize { + self.generators.len() + } + + /// Check if the signature is empty. + pub fn is_empty(&self) -> bool { + self.generators.is_empty() + } + + /// Get all generators of a specific dimension. + pub fn generators_of_dimension(&self, dim: usize) -> impl Iterator { + self.generators + .values() + .filter(move |g| g.generator.dimension == dim) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generator_creation() { + let g = Generator::new(0, 2, true); + assert_eq!(g.id, 0); + assert_eq!(g.dimension, 2); + assert!(g.invertible); + } + + #[test] + fn test_signature_add() { + let mut sig = Signature::new(); + + let id0 = sig.add_zero_cell(Some("A".to_string())); + let id1 = sig.add_zero_cell(Some("B".to_string())); + + assert_eq!(sig.len(), 2); + assert!(sig.contains(id0)); + assert!(sig.contains(id1)); + + let g0 = sig.get(id0).unwrap(); + assert_eq!(g0.generator.dimension, 0); + assert_eq!(g0.label, Some("A".to_string())); + } + + #[test] + fn test_signature_iteration() { + let mut sig = Signature::new(); + sig.add_zero_cell(None); + sig.add_zero_cell(None); + sig.add_zero_cell(None); + + let count = sig.generators().count(); + assert_eq!(count, 3); + } +} diff --git a/src/typecheck.rs b/src/typecheck.rs new file mode 100644 index 0000000..b8e39af --- /dev/null +++ b/src/typecheck.rs @@ -0,0 +1,229 @@ +//! Type checking for diagrams +//! +//! Type checking verifies that a diagram is well-formed with respect to a signature. +//! The procedure: +//! 1. Extract singular content from the diagram +//! 2. Break the diagram into pieces (one per singular content element) +//! 3. Normalise each piece +//! 4. Check that each normalised piece matches a signature element + +use crate::diagram::Diagram; +use crate::signature::{Signature, Generator}; +use thiserror::Error; + +/// Errors that can occur during type checking. +#[derive(Debug, Clone, Error)] +pub enum TypeError { + /// A piece of the diagram doesn't match any generator in the signature. + #[error("Piece at index {index} does not match any generator in signature")] + UnknownGenerator { index: usize }, + + /// A generator was found but with wrong dimension. + #[error("Generator {id} has dimension {expected}, but piece has dimension {actual}")] + DimensionMismatch { + id: usize, + expected: usize, + actual: usize, + }, + + /// Source/target mismatch for a generator. + #[error("Generator {id} source/target does not match diagram boundary")] + BoundaryMismatch { id: usize }, + + /// The diagram is not globular. + #[error("Diagram is not globular")] + NotGlobular, + + /// Internal error during normalisation. + #[error("Normalisation failed: {message}")] + NormalisationError { message: String }, +} + +/// A piece of singular content from a diagram. +#[derive(Debug, Clone)] +pub struct SingularPiece { + /// The piece as a sub-diagram + pub diagram: Diagram, + /// Path to this piece in the original diagram (sequence of singular indices) + pub path: Vec, +} + +/// Type check a diagram against a signature. +/// +/// # Arguments +/// * `diagram` - The diagram to check +/// * `signature` - The signature to check against +/// +/// # Returns +/// * `Ok(())` if the diagram type-checks +/// * `Err(TypeError)` describing the first error found +pub fn type_check(diagram: &Diagram, signature: &Signature) -> Result<(), TypeError> { + // Check globularity first + if !diagram.is_globular() { + return Err(TypeError::NotGlobular); + } + + // Extract pieces + let pieces = extract_pieces(diagram); + + // Check each piece + for (index, piece) in pieces.iter().enumerate() { + check_piece(piece, signature, index)?; + } + + Ok(()) +} + +/// Extract the singular content of a diagram. +/// +/// For an n-diagram D: +/// - If n = 0: singular content is a 1-element set (the generator itself) +/// - If n > 0: singular content is the disjoint union of singular content +/// of all singular slices +pub fn extract_singular_content(diagram: &Diagram) -> Vec { + let mut pieces = Vec::new(); + extract_singular_content_recursive(diagram, &mut vec![], &mut pieces); + pieces +} + +fn extract_singular_content_recursive( + diagram: &Diagram, + path: &mut Vec, + pieces: &mut Vec, +) { + match diagram { + Diagram::Diagram0(_) => { + // Base case: this is a piece of singular content + pieces.push(SingularPiece { + diagram: diagram.clone(), + path: path.clone(), + }); + } + Diagram::DiagramN(d) => { + // Recurse into each singular slice + for i in 0..d.length() { + path.push(i); + if let Some(slice) = d.singular_slice(i) { + extract_singular_content_recursive(&slice, path, pieces); + } + path.pop(); + } + + // If no singular slices (identity diagram), the content is the source + if d.length() == 0 { + extract_singular_content_recursive(&d.source, path, pieces); + } + } + } +} + +/// Extract pieces from a diagram. +/// +/// Each piece corresponds to one element of singular content, +/// extracted as a sub-diagram by taking preimages. +pub fn extract_pieces(diagram: &Diagram) -> Vec { + // For now, this is the same as singular content extraction + // A full implementation would construct the actual sub-diagrams + extract_singular_content(diagram) +} + +/// Check a single piece against the signature. +fn check_piece(piece: &SingularPiece, signature: &Signature, index: usize) -> Result<(), TypeError> { + // Normalise the piece + let normalised = piece.diagram.normalise(); + + // The normalised piece should match a generator + match &normalised.normal_form { + Diagram::Diagram0(g) => { + // Check that this generator is in the signature + if !signature.contains(g.id) { + return Err(TypeError::UnknownGenerator { index }); + } + + // Check dimension matches + if let Some(sig_gen) = signature.generator(g.id) { + if sig_gen.dimension != g.dimension { + return Err(TypeError::DimensionMismatch { + id: g.id, + expected: sig_gen.dimension, + actual: g.dimension, + }); + } + } + } + Diagram::DiagramN(d) => { + // For n > 0, check if this matches an n-cell in the signature + let dim = normalised.normal_form.dimension(); + + // Find matching generators of this dimension + let matching: Vec<&Generator> = signature + .generators_of_dimension(dim) + .map(|gd| &gd.generator) + .collect(); + + if matching.is_empty() { + return Err(TypeError::UnknownGenerator { index }); + } + + // TODO: Check source/target boundaries match + // For now, accept if any generator of matching dimension exists + let _ = d; // suppress unused warning + } + } + + Ok(()) +} + +impl Diagram { + /// Type check this diagram against a signature. + pub fn type_check(&self, signature: &Signature) -> Result<(), TypeError> { + type_check(self, signature) + } + + /// Extract the singular content of this diagram. + pub fn singular_content(&self) -> Vec { + extract_singular_content(self) + } + + /// Break this diagram into pieces. + pub fn pieces(&self) -> Vec { + extract_pieces(self) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::signature::GeneratorData; + + #[test] + fn test_type_check_zero_cell() { + let mut sig = Signature::new(); + let id = sig.add(GeneratorData::zero_cell(0, Some("A".to_string()))); + + let g = Generator::point(id); + let d = Diagram::Diagram0(g); + + assert!(d.type_check(&sig).is_ok()); + } + + #[test] + fn test_type_check_unknown_generator() { + let sig = Signature::new(); // Empty signature + + let g = Generator::point(42); + let d = Diagram::Diagram0(g); + + let result = d.type_check(&sig); + assert!(matches!(result, Err(TypeError::UnknownGenerator { .. }))); + } + + #[test] + fn test_singular_content_zero() { + let g = Generator::point(0); + let d = Diagram::Diagram0(g); + + let content = d.singular_content(); + assert_eq!(content.len(), 1); + } +} diff --git a/src/zigzag.rs b/src/zigzag.rs new file mode 100644 index 0000000..a2543cd --- /dev/null +++ b/src/zigzag.rs @@ -0,0 +1,291 @@ +//! Zigzags and zigzag maps +//! +//! A zigzag of length n is a diagram: +//! ```text +//! X(r₀) → X(s₀) ← X(r₁) → X(s₁) ← ... → X(sₙ₋₁) ← X(rₙ) +//! ``` +//! +//! - Regular objects X(rⱼ) for j ∈ {0,...,n} +//! - Singular objects X(sᵢ) for i ∈ {0,...,n-1} +//! +//! A zigzag map f: X → Y consists of: +//! - A singular map fˢ: n → m in Δ₊ +//! - A derived regular map fʳ = (Rfˢ)ᵒᵖ: m+1 → n+1 +//! - Slice maps at each height + +use crate::monotone::MonotoneMap; + +/// A zigzag in a category C, parameterized by the object type T. +/// +/// A zigzag of length n has: +/// - n+1 regular objects +/// - n singular objects +/// - n cospans connecting them +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Zigzag { + /// Regular objects X(r₀), X(r₁), ..., X(rₙ) — length n+1 + pub regular: Vec, + /// Singular objects X(s₀), X(s₁), ..., X(sₙ₋₁) — length n + pub singular: Vec, + // Note: The cospan structure maps (forward/backward arrows) are implicit + // in the diagram representation; they're encoded in the Rewrite/Cospan types. +} + +impl Zigzag { + /// Create a new zigzag with the given regular and singular objects. + /// + /// # Panics + /// Panics if regular.len() != singular.len() + 1 + pub fn new(regular: Vec, singular: Vec) -> Self { + assert_eq!( + regular.len(), + singular.len() + 1, + "Zigzag requires regular.len() = singular.len() + 1, got {} and {}", + regular.len(), + singular.len() + ); + Self { regular, singular } + } + + /// Create a zigzag of length 0 (single regular object, no singular objects). + pub fn point(obj: T) -> Self { + Self { + regular: vec![obj], + singular: vec![], + } + } + + /// The length of this zigzag (number of singular objects / cospans). + pub fn length(&self) -> usize { + self.singular.len() + } + + /// Number of regular heights (length + 1). + pub fn regular_count(&self) -> usize { + self.regular.len() + } + + /// Number of singular heights (same as length). + pub fn singular_count(&self) -> usize { + self.singular.len() + } + + /// Get regular object at height h. + pub fn regular_at(&self, h: usize) -> Option<&T> { + self.regular.get(h) + } + + /// Get singular object at height h. + pub fn singular_at(&self, h: usize) -> Option<&T> { + self.singular.get(h) + } +} + +impl Zigzag { + /// Map a function over all objects in the zigzag. + pub fn map U>(&self, f: F) -> Zigzag { + Zigzag { + regular: self.regular.iter().map(&f).collect(), + singular: self.singular.iter().map(&f).collect(), + } + } +} + +/// A map between zigzags. +/// +/// Given zigzags X (length n) and Y (length m), a zigzag map f: X → Y consists of: +/// - A singular map fˢ: n → m in Δ₊ +/// - A derived regular map fʳ = (Rfˢ)ᵒᵖ: m+1 → n+1 in Δ₌ +/// - Slice data at each height (stored separately in the category-specific implementation) +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ZigzagMap { + /// The singular map fˢ: source_length → target_length + pub singular_map: MonotoneMap, + /// Slice data at regular heights (one per target regular height) + pub regular_slices: Vec, + /// Slice data at singular heights (one per source singular height) + pub singular_slices: Vec, +} + +impl ZigzagMap { + /// Create a new zigzag map. + /// + /// # Arguments + /// - `singular_map`: The singular map fˢ: n → m + /// - `regular_slices`: Slice data at each target regular height (length m+1) + /// - `singular_slices`: Slice data at each source singular height (length n) + /// + /// # Panics + /// Panics if slice counts don't match the singular map dimensions. + pub fn new( + singular_map: MonotoneMap, + regular_slices: Vec, + singular_slices: Vec, + ) -> Self { + let n = singular_map.source_size(); + let m = singular_map.target_size(); + + assert_eq!( + regular_slices.len(), + m + 1, + "Expected {} regular slices, got {}", + m + 1, + regular_slices.len() + ); + assert_eq!( + singular_slices.len(), + n, + "Expected {} singular slices, got {}", + n, + singular_slices.len() + ); + + Self { + singular_map, + regular_slices, + singular_slices, + } + } + + /// The source zigzag length (n). + pub fn source_length(&self) -> usize { + self.singular_map.source_size() + } + + /// The target zigzag length (m). + pub fn target_length(&self) -> usize { + self.singular_map.target_size() + } + + /// The regular map fʳ = (Rfˢ)ᵒᵖ: m+1 → n+1. + /// + /// This is derived from the singular map via Wraith's R equivalence. + pub fn regular_map(&self) -> MonotoneMap { + self.singular_map.wraith_r() + } + + /// Get the regular slice at target height j. + pub fn regular_slice(&self, j: usize) -> Option<&S> { + self.regular_slices.get(j) + } + + /// Get the singular slice at source height i. + pub fn singular_slice(&self, i: usize) -> Option<&S> { + self.singular_slices.get(i) + } +} + +impl ZigzagMap { + /// Compose two zigzag maps: (g ∘ f) where f: X → Y and g: Y → W. + /// + /// Composition rules: + /// - (g ∘ f)ˢ = gˢ ∘ fˢ + /// - (g ∘ f)(sⱼ) = g(s_{fˢ(j)}) ∘ f(sⱼ) + /// - (g ∘ f)(rᵢ) = g(rᵢ) ∘ f(r_{gʳ(i)}) + /// + /// Note: This requires a way to compose slice data. The `compose_slices` function + /// is provided to combine slice morphisms. + pub fn compose(&self, other: &ZigzagMap, compose_slices: F) -> ZigzagMap + where + F: Fn(&S, &S) -> S, + { + // Compose singular maps + let composed_singular = self.singular_map.compose(&other.singular_map); + + // Get other's regular map for indexing + let other_regular = other.regular_map(); + + // Compose regular slices: (g ∘ f)(rᵢ) = g(rᵢ) ∘ f(r_{gʳ(i)}) + let composed_regular: Vec = (0..other.target_length() + 1) + .map(|i| { + let g_r_i = other_regular.apply(i); + let f_slice = &self.regular_slices[g_r_i]; + let g_slice = &other.regular_slices[i]; + compose_slices(f_slice, g_slice) + }) + .collect(); + + // Compose singular slices: (g ∘ f)(sⱼ) = g(s_{fˢ(j)}) ∘ f(sⱼ) + let composed_singular_slices: Vec = (0..self.source_length()) + .map(|j| { + let f_s_j = self.singular_map.apply(j); + let f_slice = &self.singular_slices[j]; + let g_slice = &other.singular_slices[f_s_j]; + compose_slices(f_slice, g_slice) + }) + .collect(); + + ZigzagMap { + singular_map: composed_singular, + regular_slices: composed_regular, + singular_slices: composed_singular_slices, + } + } +} + +/// The π functor: Z(C) → Δ₊, sending zigzags to their lengths. +pub fn pi_length(zigzag: &Zigzag) -> usize { + zigzag.length() +} + +/// The π functor on maps: sends a zigzag map to its singular map. +pub fn pi_map(map: &ZigzagMap) -> &MonotoneMap { + &map.singular_map +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_zigzag_point() { + let z: Zigzag = Zigzag::point(42); + assert_eq!(z.length(), 0); + assert_eq!(z.regular_count(), 1); + assert_eq!(z.singular_count(), 0); + } + + #[test] + fn test_zigzag_construction() { + let z: Zigzag = Zigzag::new( + vec!['a', 'b', 'c'], + vec!['x', 'y'], + ); + assert_eq!(z.length(), 2); + assert_eq!(z.regular_at(0), Some(&'a')); + assert_eq!(z.singular_at(1), Some(&'y')); + } + + #[test] + fn test_zigzag_map_identity() { + // Identity map on a length-2 zigzag + let id_singular = MonotoneMap::identity(2); + let map: ZigzagMap<()> = ZigzagMap::new( + id_singular, + vec![(), (), ()], // 3 regular slices + vec![(), ()], // 2 singular slices + ); + + assert_eq!(map.source_length(), 2); + assert_eq!(map.target_length(), 2); + + let reg_map = map.regular_map(); + assert!(reg_map.is_identity()); + } + + #[test] + fn test_zigzag_map_regular_derived() { + // Singular map: 1 → 2 given by [0] (maps 0 to 0) + let singular = MonotoneMap::new(vec![0], 2); + let map: ZigzagMap<()> = ZigzagMap::new( + singular.clone(), + vec![(), (), ()], // 3 regular slices (target has length 2) + vec![()], // 1 singular slice (source has length 1) + ); + + // Regular map should be R([0]): 3 → 2 + let reg = map.regular_map(); + assert_eq!(reg.source_size(), 3); + assert_eq!(reg.target_size(), 2); + } +}