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<T>, ZigzagMap<S> 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 <noreply@anthropic.com>
This commit is contained in:
Maximus Gorog 2026-04-07 02:42:06 -06:00
commit cd4b951f78
13 changed files with 3365 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

642
Cargo.lock generated Normal file
View file

@ -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"

12
Cargo.toml Normal file
View file

@ -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"

185
src/degeneracy.rs Normal file
View file

@ -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<DegeneracyFactorisation> {
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<DegeneracyPullback> {
// 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());
}
}

375
src/diagram.rs Normal file
View file

@ -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<Diagram>,
/// The cospans forming the zigzag structure
pub cospans: Vec<Cospan>,
}
impl DiagramN {
/// Create a new n-diagram.
pub fn new(source: Diagram, cospans: Vec<Cospan>) -> 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<Diagram> {
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<Diagram> {
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<Cone>,
}
impl RewriteN {
/// Create a new n-dimensional rewrite.
pub fn new(dimension: usize, cones: Vec<Cone>) -> 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<Cospan>,
/// Target cospan (what the source contracts to)
pub target: Cospan,
/// Slice rewrites for each interior boundary
pub slices: Vec<Rewrite>,
}
impl Cone {
/// Create a new cone.
pub fn new(index: usize, source: Vec<Cospan>, target: Cospan, slices: Vec<Rewrite>) -> 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<Diagram> {
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<Generator> for Diagram {
fn from(g: Generator) -> Self {
Diagram::Diagram0(g)
}
}
impl From<DiagramN> 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());
}
}

309
src/explosion.rs Normal file
View file

@ -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<HeightLabel>);
impl Point {
/// Create a new point from height labels.
pub fn new(labels: Vec<HeightLabel>) -> 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<T> {
/// Elements of the poset
elements: Vec<T>,
/// Covering relations: (i, j) means elements[i] < elements[j] and is a cover
covers: Vec<(usize, usize)>,
}
impl<T: Clone + Eq> Poset<T> {
/// 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<usize> {
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<Point> {
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<Point> {
k_points(diagram, diagram.dimension())
}
impl Diagram {
/// Compute k-points of this diagram.
pub fn points(&self, k: usize) -> Poset<Point> {
k_points(self, k)
}
/// Compute full explosion (n-points where n = dimension).
pub fn full_points(&self) -> Poset<Point> {
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<Ordering> {
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<Ordering> {
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)
);
}
}

320
src/layout.rs Normal file
View file

@ -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<Point, Vec<f64>>,
/// Computed width of the diagram in each dimension
pub dimensions: Vec<f64>,
}
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<f64>> {
self.positions.get(point)
}
/// Set the position of a point.
pub fn set_position(&mut self, point: Point, position: Vec<f64>) {
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<OrderingConstraint>,
/// Soft fairness constraints (centering, symmetry)
pub fairness: Vec<FairnessConstraint>,
/// Soft spring constraints (from learned model)
pub springs: Vec<SpringConstraint>,
}
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<Point>,
/// 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<Point>, 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<f64>,
/// Spring constant (stiffness)
pub stiffness: f64,
}
impl SpringConstraint {
/// Create a new spring constraint.
pub fn new(point: Point, target_position: Vec<f64>, 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<f64> {
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<Layout, LayoutError>;
}
/// 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);
}
}

48
src/lib.rs Normal file
View file

@ -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};

325
src/monotone.rs Normal file
View file

@ -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<usize>,
/// 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<usize>, 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<usize> = (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<usize> {
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<usize> {
// 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<usize> = (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());
}
}

428
src/normalise.rs Normal file
View file

@ -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<DiagramMap>,
}
/// 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<DiagramMap>,
}
impl<'a> Sink<'a> {
/// Create a new sink.
pub fn new(target: &'a Diagram, maps: Vec<DiagramMap>) -> 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,
&regular_normalisations,
);
// Step 3: Assemble into zigzag P with parallel degeneracy dP
let (p, d_parallel, assembled_factorisations) = assemble(
target,
&regular_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<DiagramMap>,
}
/// Normalise at each regular height of the diagram.
fn normalise_regular_heights(
target: &DiagramN,
sink_maps: &[DiagramMap],
) -> Vec<RegularNormalisation> {
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<DiagramMap> = 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<DiagramMap>,
}
/// 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<SingularNormalisation> {
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<DiagramMap> = 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<DiagramMap>) {
// Build cospans from the normalisation results
let cospans: Vec<crate::diagram::Cospan> = 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<DiagramMap>) {
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::<usize>::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);
}
}

200
src/signature.rs Normal file
View file

@ -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<crate::diagram::Diagram>,
/// Target diagram (None for dimension 0)
pub target: Option<crate::diagram::Diagram>,
/// Optional human-readable label
pub label: Option<String>,
}
impl GeneratorData {
/// Create generator data for a 0-cell.
pub fn zero_cell(id: usize, label: Option<String>) -> 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<String>,
) -> 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<usize, GeneratorData>,
/// 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<String>) -> 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<Item = &GeneratorData> {
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<Item = &GeneratorData> {
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);
}
}

229
src/typecheck.rs Normal file
View file

@ -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<usize>,
}
/// 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<SingularPiece> {
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<usize>,
pieces: &mut Vec<SingularPiece>,
) {
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<SingularPiece> {
// 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<SingularPiece> {
extract_singular_content(self)
}
/// Break this diagram into pieces.
pub fn pieces(&self) -> Vec<SingularPiece> {
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);
}
}

291
src/zigzag.rs Normal file
View file

@ -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<T> {
/// Regular objects X(r₀), X(r₁), ..., X(rₙ) — length n+1
pub regular: Vec<T>,
/// Singular objects X(s₀), X(s₁), ..., X(sₙ₋₁) — length n
pub singular: Vec<T>,
// Note: The cospan structure maps (forward/backward arrows) are implicit
// in the diagram representation; they're encoded in the Rewrite/Cospan types.
}
impl<T> Zigzag<T> {
/// Create a new zigzag with the given regular and singular objects.
///
/// # Panics
/// Panics if regular.len() != singular.len() + 1
pub fn new(regular: Vec<T>, singular: Vec<T>) -> 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<T: Clone> Zigzag<T> {
/// Map a function over all objects in the zigzag.
pub fn map<U, F: Fn(&T) -> U>(&self, f: F) -> Zigzag<U> {
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<S> {
/// 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<S>,
/// Slice data at singular heights (one per source singular height)
pub singular_slices: Vec<S>,
}
impl<S> ZigzagMap<S> {
/// 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<S>,
singular_slices: Vec<S>,
) -> 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<S: Clone> ZigzagMap<S> {
/// 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<F>(&self, other: &ZigzagMap<S>, compose_slices: F) -> ZigzagMap<S>
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<S> = (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<S> = (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<T>(zigzag: &Zigzag<T>) -> usize {
zigzag.length()
}
/// The π functor on maps: sends a zigzag map to its singular map.
pub fn pi_map<S>(map: &ZigzagMap<S>) -> &MonotoneMap {
&map.singular_map
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_zigzag_point() {
let z: Zigzag<i32> = 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<char> = 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);
}
}