diff --git a/.github/workflows/cargo.yml b/.github/workflows/cargo.yml index 7f9306f..85593e7 100644 --- a/.github/workflows/cargo.yml +++ b/.github/workflows/cargo.yml @@ -17,7 +17,7 @@ jobs: components: clippy - uses: Swatinem/rust-cache@v2 - uses: taiki-e/install-action@cargo-nextest - - run: cargo nextest run --no-tests=warn + - run: cargo nextest run # Warn-only: this toolchain is rolling (not pinned), which may cause # unrelated PRs to start failing due to nightly containing new lints. # Nix clippy checks in ci.yml, which pins the toolchain via a flake.lock, diff --git a/Cargo.lock b/Cargo.lock index 0de2366..edc39b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,10 +2,630 @@ # 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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[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.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c61cd05405eb1d0f3a4660f802bad76ece84b6e722426342ba5dd511f724e97" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "concurrent-psbt" version = "0.1.0" +dependencies = [ + "proptest", +] + +[[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.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.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.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[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.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +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 = "scrubber" version = "0.1.0" + +[[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.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +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 = "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.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[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 0.51.0", +] + +[[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" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[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.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Justfile b/Justfile index 61b20be..1590cba 100644 --- a/Justfile +++ b/Justfile @@ -20,7 +20,7 @@ fmt: # Run tests via cargo-nextest test: - cargo nextest run --no-tests=warn + cargo nextest run # Run clippy lints clippy: diff --git a/crates/concurrent-psbt/Cargo.toml b/crates/concurrent-psbt/Cargo.toml index e6855c5..e29fc4a 100644 --- a/crates/concurrent-psbt/Cargo.toml +++ b/crates/concurrent-psbt/Cargo.toml @@ -7,3 +7,9 @@ edition = "2024" default = ["unit-tests", "prop-tests"] unit-tests = [] prop-tests = [] + +[dev-dependencies] +proptest = "1" + +[lints] +workspace = true diff --git a/crates/concurrent-psbt/src/lattice/join.rs b/crates/concurrent-psbt/src/lattice/join.rs new file mode 100644 index 0000000..773d288 --- /dev/null +++ b/crates/concurrent-psbt/src/lattice/join.rs @@ -0,0 +1,104 @@ +/// Trait for infallible join operation. +pub trait Join: Sized { + /// Merge two values of a given type into a new value of the same type + /// incorporating the information of both inputs. + /// + /// This operation should be associative, commutative and idempotent. + fn join(self, other: Self) -> Self; +} + +/// In-place variant of [`Join`]. +/// +/// Implementing `JoinMut` provides `Join` automatically via blanket impl. +pub trait JoinMut: Sized { + /// Merge `other` into `self` in place. + fn join_mut(&mut self, other: Self); +} + +impl Join for T { + fn join(mut self, other: Self) -> Self { + self.join_mut(other); + self + } +} + +/// Proptest macros for verifying lattice laws. Available when `prop-tests` +/// feature is enabled. +/// +/// # `assert_join_laws!($strategy)` +/// +/// Generates idempotent, commutative, and associative property tests for +/// any type implementing [`Join`] + `Clone` + `PartialEq` + `Debug`. +#[cfg(feature = "prop-tests")] +#[allow(unused_macros)] // used by downstream commits (partial.rs, collection modules) +macro_rules! assert_join_laws { + ($strategy:expr) => { + proptest! { + #[test] + fn idempotent(a in $strategy) { + prop_assert_eq!(a.clone().join(a.clone()), a); + } + + #[test] + fn commutative(a in $strategy, b in $strategy) { + prop_assert_eq!(a.clone().join(b.clone()), b.join(a)); + } + + #[test] + fn associative(a in $strategy, b in $strategy, c in $strategy) { + prop_assert_eq!( + a.clone().join(b.clone()).join(c.clone()), + a.join(b.join(c)), + ); + } + } + }; +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + + impl JoinMut for u32 { + fn join_mut(&mut self, other: Self) { + *self = (*self).max(other); + } + } + + #[cfg(feature = "unit-tests")] + mod unit { + use super::*; + + impl Join for () { + fn join(self, _other: Self) -> Self { + self + } + } + + #[test] + fn unit_type_join() { + assert_eq!(Join::join((), ()), ()); + } + + #[test] + fn join_mut_in_place() { + let mut a = 1u32; + a.join_mut(2); + assert_eq!(a, 2); + } + + #[test] + fn blanket_join_from_join_mut() { + assert_eq!(Join::join(1u32, 2), 2); + } + } + + #[cfg(feature = "prop-tests")] + mod prop { + use super::*; + use proptest::prelude::*; + + assert_join_laws!(any::()); + } +} diff --git a/crates/concurrent-psbt/src/lattice/mod.rs b/crates/concurrent-psbt/src/lattice/mod.rs new file mode 100644 index 0000000..fb450d6 --- /dev/null +++ b/crates/concurrent-psbt/src/lattice/mod.rs @@ -0,0 +1,4 @@ +#[macro_use] +pub mod join; +#[macro_use] +pub mod partial; diff --git a/crates/concurrent-psbt/src/lattice/partial.rs b/crates/concurrent-psbt/src/lattice/partial.rs new file mode 100644 index 0000000..baeb6c0 --- /dev/null +++ b/crates/concurrent-psbt/src/lattice/partial.rs @@ -0,0 +1,382 @@ +//! Some types have a natural merge operation but that isn't defined for all pairs of values. +//! [`PartialJoin::try_join`] captures this. +//! +//! It returns [`JoinResult`], an alias for `Result>`, where [`Conflict`], +//! collects all the distinct values that couldn't be merged. +//! +//! [`JoinResult`] implements [`Join`], which is an infallible join operation (the free +//! semilattice over `V`, more or less), in terms of [`Conflict`]'s [`Join`] implementation. + +use crate::lattice::join::{Join, JoinMut}; + +/// Fallible join for scalar value types where merge may produce a conflict. +/// +/// Containers should not implement this, instead they should implement [`Join`] by wrapping +/// contained values in [`JoinResult`]. +pub trait PartialJoin: Sized + PartialEq { + /// Attempt to join two values. + /// + /// Returns `Ok(lub)` where `lub` is the least upper bound if one exists, or `Err(Conflict)` + /// containing both values. + /// + /// Although `Conflict` has set semantics (equality treats it as an unordered collection), + /// insertion order is preserved during iteration: when two `Ok` values are joined and produce + /// a binary conflict, the first value is the left operand and the second is the right. + /// + /// Provenance is only reliable when both operands were `Ok` before the join. When an existing + /// `Conflict` is joined with another value, new entries are appended, so a multi-way fold does + /// not preserve per-operand attribution. + fn try_join(self, other: Self) -> JoinResult; + + /// Lift a value into the result domain as `Ok(self)`. + fn wrap(self) -> JoinResult { + Ok(self) + } +} + +/// Result of a fallible join: `Ok(v)` where `v` is the least upper bound if one exists, +/// `Err(Conflict)` otherwise. +/// +/// `JoinResult` implements [`Join`]. +pub type JoinResult = Result>; + +impl Join for JoinResult +where + V: PartialJoin, +{ + fn join(self, other: Self) -> Self { + match (self, other) { + (Ok(a), Ok(b)) => a.try_join(b), + (Err(a), Err(b)) => Err(a.join(b)), + (Err(a), Ok(b)) => Err(a.join(Conflict::singleton(b))), + (Ok(a), Err(b)) => Err(Conflict::singleton(a).join(b)), + } + } +} + +/// A set of conflicting values, produced when a join for those values does not exist. +/// +/// When two participants set the same field to different values, neither can be chosen without +/// losing information. `Conflict` preserves the set of distinct conflicting values allowing the +/// caller to inspect or resolve the disagreement. +/// +/// The inner values are [`PartialJoin`] with equality based on [`PartialEq`]. Duplicate values +/// are omitted. +#[derive(Debug, Clone)] +pub struct Conflict(Vec); + +impl Conflict { + /// Wrap a single value into a conflict. + fn singleton(v: V) -> Self { + Self(vec![v]) + } + + /// Build from an iterator, deduplicating values. + #[allow(dead_code)] // production caller: tspwkqxz (values.rs IdempotentValue::try_join) + pub(crate) fn from_values(iter: impl IntoIterator) -> Self { + let mut vals = Vec::new(); + for v in iter { + if !vals.contains(&v) { + vals.push(v); + } + } + Self(vals) + } + + /// Iterates over references to the conflicted values. + fn iter(&self) -> std::slice::Iter<'_, V> { + self.0.iter() + } + + /// Number of distinct conflicting values. + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl JoinMut for Conflict { + fn join_mut(&mut self, other: Self) { + for value in other.0 { + if !self.0.contains(&value) { + self.0.push(value) + } + } + } +} + +/// Set equality: same elements, regardless of order. +/// +/// $O(n^2)$ but $n$ is tiny in practice (typically 2). +impl PartialEq for Conflict { + fn eq(&self, other: &Self) -> bool { + self.0.len() == other.0.len() && self.iter().all(|v| other.0.contains(v)) + } +} + +impl Eq for Conflict {} + +impl IntoIterator for Conflict { + type Item = V; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +impl<'a, V: PartialJoin> IntoIterator for &'a Conflict { + type Item = &'a V; + type IntoIter = std::slice::Iter<'a, V>; + + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + +/// Proptest macros for verifying `PartialJoin` laws and the `JoinResult` completion. Available +/// when `prop-tests` feature is enabled. +/// +/// # `assert_partial_join_laws!($arb_clean, $arb_result)` +/// +/// Generates: +/// - `try_join` laws on clean values: idempotent, commutative, associative (via wrap/join) +/// - `JoinResult` laws on pre-built Ok/Err values: idempotent, commutative, associative +/// - `wrap_roundtrip`: wrapping a clean value always produces `Ok` +/// +/// The `$arb_clean` strategy produces clean (pre-join) values. +/// The `$arb_result` strategy produces `JoinResult` values (both Ok and Err). +#[cfg(feature = "prop-tests")] +#[allow(unused_macros)] // used by downstream commits (collection modules, domain types) +macro_rules! assert_partial_join_laws { + ($arb_clean:expr, $arb_result:expr) => { + proptest! { + #[test] + fn try_join_idempotent(a in $arb_clean) { + prop_assert_eq!(a.clone().try_join(a.clone()), Ok(a)); + } + + #[test] + fn try_join_commutative(a in $arb_clean, b in $arb_clean) { + prop_assert_eq!(a.clone().try_join(b.clone()), b.try_join(a)); + } + + #[test] + fn try_join_associative(a in $arb_clean, b in $arb_clean, c in $arb_clean) { + prop_assert_eq!( + a.clone().try_join(b.clone()).join(c.clone().wrap()), + a.wrap().join(b.try_join(c)), + ); + } + + #[test] + fn wrap_roundtrip(a in $arb_clean) { + let wrapped = a.clone().wrap(); + prop_assert!(wrapped.is_ok()); + prop_assert_eq!(wrapped.expect("should be Ok"), a); + } + } + + mod join_result { + use super::*; + assert_join_laws!($arb_result); + } + }; +} + +#[cfg(all(test, any(feature = "unit-tests", feature = "prop-tests")))] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + + #[derive(Debug, Clone, Copy, PartialEq)] + struct Foo(u8); + + impl PartialJoin for Foo { + fn try_join(self, other: Self) -> JoinResult { + if self == other { + Ok(self) + } else { + Err(Conflict::from_values([self, other])) + } + } + } + + #[cfg(feature = "unit-tests")] + mod unit { + use super::*; + + #[test] + fn result_ok_ok_different_produces_conflict() { + assert_eq!( + Foo(0).wrap().join(Foo(1).wrap()), + Err(Conflict(vec![Foo(0), Foo(1)])), + ); + } + + #[test] + fn result_err_ok_absorbs() { + let err: JoinResult = Err(Conflict(vec![Foo(0), Foo(1)])); + assert_eq!( + err.join(Ok(Foo(2))), + Err(Conflict(vec![Foo(0), Foo(1), Foo(2)])) + ); + } + + #[test] + fn result_ok_err_absorbs() { + let err: JoinResult = Err(Conflict(vec![Foo(0), Foo(1)])); + assert_eq!( + Ok(Foo(2)).join(err), + Err(Conflict(vec![Foo(2), Foo(0), Foo(1)])) + ); + } + + #[test] + fn result_err_err_merges_conflicts() { + let a: JoinResult = Err(Conflict(vec![Foo(0), Foo(1)])); + let b: JoinResult = Err(Conflict(vec![Foo(1), Foo(2)])); + assert_eq!(a.join(b), Err(Conflict(vec![Foo(0), Foo(1), Foo(2)]))); + } + + #[test] + fn different_conflicts_are_not_equal() { + let a = Conflict::from_values([Foo(0), Foo(1)]); + let b = Conflict::from_values([Foo(0), Foo(2)]); + assert_ne!(a, b); + } + + #[test] + fn different_length_conflicts_are_not_equal() { + let a = Conflict::from_values([Foo(0), Foo(1)]); + let b = Conflict::from_values([Foo(0), Foo(1), Foo(2)]); + assert_ne!(a, b); + } + + #[test] + fn conflict_from_equal_pair_deduplicates() { + let c = Conflict::from_values([Foo(0), Foo(0)]); + assert_eq!(c.len(), 1); + } + + #[test] + fn len_distinct_values() { + let c = Conflict::from_values([Foo(0), Foo(1)]); + assert_eq!(c.len(), 2); + } + + #[test] + fn is_empty() { + assert!(Conflict::::from_values([]).is_empty()); + assert!(!Conflict::from_values([Foo(0)]).is_empty()); + } + + #[test] + fn into_iter_yields_elements() { + let c = Conflict::from_values([Foo(0), Foo(1)]); + let v: Vec<_> = c.into_iter().collect(); + assert_eq!(v, vec![Foo(0), Foo(1)]); + } + + #[test] + fn borrowed_iteration() { + let c = Conflict::from_values([Foo(0), Foo(1)]); + let v: Vec<_> = (&c).into_iter().collect(); + assert_eq!(v, vec![&Foo(0), &Foo(1)]); + } + + #[test] + fn conflict_preserves_order() { + let x = Conflict::from_values([Foo(0)]); + let y = Conflict::from_values([Foo(1)]); + + let xy = x.clone().join(y.clone()); + let yx = y.clone().join(x.clone()); + + assert_eq!(xy, yx); + assert_ne!(xy.iter().collect::>(), yx.iter().collect::>()); + assert_ne!( + xy.into_iter().collect::>(), + yx.into_iter().collect::>() + ); + } + } + + // Small domain (4 values): high collision rate exercises the + // equal-value (Ok) path while still covering the conflict (Err) path. + #[cfg(feature = "prop-tests")] + mod prop { + use super::*; + use proptest::prelude::*; + + pub fn arb_foo() -> impl Strategy { + (0u8..4).prop_map(Foo) + } + + /// Generate a JoinResult which is either Ok(v) or Err(Conflict) with 1–5 values. + /// Exercises all arms of JoinResult::join. + pub fn arb_join_result() -> impl Strategy> { + prop_oneof![ + arb_foo().prop_map(Ok), + proptest::collection::vec(arb_foo(), 1..=5) + .prop_map(|v| Err(Conflict::from_values(v))), + ] + } + + assert_partial_join_laws!(arb_foo(), arb_join_result()); + + proptest! { + #[test] + fn conflict_into_iter_roundtrips(a in arb_foo(), b in arb_foo()) { + let c = Conflict::from_values([a, b]); + let vals = c.clone().into_iter(); + let rebuilt = Conflict::from_values(vals); + prop_assert_eq!(c, rebuilt); + } + + #[test] + fn borrowed_iter_matches_owned(a in arb_foo(), b in arb_foo()) { + let c = Conflict::from_values([a, b]); + let owned: Vec<_> = c.clone().into_iter().collect(); + let borrowed: Vec<_> = (&c).into_iter().copied().collect(); + prop_assert_eq!(owned, borrowed); + } + + #[test] + fn len_matches_iter_count(a in arb_foo(), b in arb_foo(), c in arb_foo()) { + let conflict = Conflict::from_values([a, b, c]); + prop_assert_eq!(conflict.len(), conflict.into_iter().count()); + } + + #[test] + fn non_empty_after_construction(a in arb_foo()) { + let c = Conflict::from_values([a]); + prop_assert!(!c.is_empty()); + } + + #[test] + fn empty_from_empty(a in arb_foo()) { + let _ = a; // use the parameter to satisfy proptest + let c = Conflict::::from_values([]); + prop_assert!(c.is_empty()); + prop_assert_eq!(c.len(), 0); + } + + #[test] + fn conflict_different_lengths_not_equal( + a in arb_foo(), + b in arb_foo(), + c in arb_foo(), + ) { + let short = Conflict::from_values([a, b]); + let long = Conflict::from_values([a, b, c]); + if short.len() != long.len() { + prop_assert_ne!(short, long); + } + } + } + } +} diff --git a/crates/concurrent-psbt/src/lib.rs b/crates/concurrent-psbt/src/lib.rs index 45278f2..c4cc31d 100644 --- a/crates/concurrent-psbt/src/lib.rs +++ b/crates/concurrent-psbt/src/lib.rs @@ -1 +1,9 @@ #![forbid(unsafe_code)] +#![allow(unused_features)] +#![cfg_attr(coverage_nightly, feature(coverage_attribute))] + +#[macro_use] +mod lattice; + +pub use lattice::join::{Join, JoinMut}; +pub use lattice::partial::{Conflict, JoinResult, PartialJoin}; diff --git a/nix/checks.nix b/nix/checks.nix index fd340f9..ff8213c 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -34,7 +34,6 @@ // { cargoArtifacts = deps; CARGO_PROFILE = profile; - cargoNextestExtraArgs = "--no-tests=warn"; } ); @@ -54,17 +53,13 @@ // { cargoArtifacts = cargoArtifactsDev; pnameSuffix = "-coverage"; - nativeBuildInputs = [ pkgs.cargo-llvm-cov ]; + nativeBuildInputs = with pkgs; [ + cargo-llvm-cov + cargo-nextest + ]; buildPhaseCargoCommand = '' mkdir -p $out - cargo llvm-cov --all-features --lcov --output-path $out/coverage.lcov || { - # no coverage data when there are no tests yet - if [ ! -s $out/coverage.lcov ]; then - echo "no coverage data (no tests), skipping assertion" - exit 0 - fi - exit 1 - } + cargo llvm-cov nextest --all-features --lcov --output-path $out/coverage.lcov cargo llvm-cov report --fail-under-regions 100 ''; installPhase = "true";