diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97ffe89..2258116 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,8 +78,8 @@ jobs: strategy: matrix: os: [ubuntu-latest] - # 1.85 is the MSRV - rust-version: ["1.85"] + # 1.86 is the MSRV + rust-version: ["1.86"] partition: ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"] fail-fast: false env: @@ -111,8 +111,8 @@ jobs: strategy: matrix: os: [ubuntu-latest] - # 1.85 is the MSRV - rust-version: ["1.85", "stable"] + # 1.86 is the MSRV + rust-version: ["1.86", "stable"] fail-fast: false env: RUSTFLAGS: -D warnings diff --git a/.gitignore b/.gitignore index 3370c26..7cef912 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ /mutants.out* # Soteria symbolic-execution frontend output (see `just soteria`). *.llbc.json +# Hegel property-test failure database, created on failing local runs. +/.hegel/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7129745..e928f08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## Unreleased - ReleaseDate +### Changed + +- MSRV updated to Rust 1.86. + ## [0.4.5] - 2026-06-17 ### Added diff --git a/Cargo.lock b/Cargo.lock index 69ad17f..6df2168 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.3" @@ -23,12 +29,56 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "atomicwrites" version = "0.4.4" @@ -73,12 +123,42 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "cbindgen" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20" +dependencies = [ + "clap", + "heck", + "indexmap 2.14.0", + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn", + "tempfile", + "toml 0.9.12+spec-1.1.0", +] + [[package]] name = "cfg-if" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core 0.10.1", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -121,8 +201,10 @@ version = "4.5.43" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c35b5830294e1fa0462034af85cc95225a4cb07092c088c55bda3147cfcd8f65" dependencies = [ + "anstream", "anstyle", "clap_lex", + "strsim", ] [[package]] @@ -131,6 +213,12 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "console" version = "0.15.11" @@ -144,6 +232,24 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "criterion" version = "0.7.0" @@ -217,6 +323,25 @@ dependencies = [ "paste", ] +[[package]] +name = "dashu-base" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993b95dc1b248e3f5747dcb017a41d6e75853a2e5ee4504f7d537c5b8dffdae4" + +[[package]] +name = "dashu-int" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49c05a0d5cb0b39fcc87c46432fdac24b90dce239857c7f6b798be4ffc3c42c6" +dependencies = [ + "cfg-if", + "dashu-base", + "num-modular", + "rustversion", + "static_assertions", +] + [[package]] name = "derive-ex" version = "0.1.8" @@ -301,10 +426,22 @@ checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.2.0", "wasi", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + [[package]] name = "glob" version = "0.3.3" @@ -359,6 +496,55 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hegeltest" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e8bcee4716fe693734b22fd0faa3d9062871460a685fa14367f8ce597faeef5" +dependencies = [ + "ciborium", + "crc32fast", + "dashu-int", + "hegeltest-c", + "hegeltest-macros", + "miniz_oxide", + "parking_lot", + "paste", + "rand 0.10.1", + "rustc-hash", + "serde", + "tempfile", +] + +[[package]] +name = "hegeltest-c" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "499774b9885ffea34db69353dc9c5b6afbb010fdad77c4ea86ca7a002fc381f0" +dependencies = [ + "cbindgen", + "ciborium", + "crc32fast", + "dashu-int", + "miniz_oxide", + "parking_lot", + "rand 0.10.1", + "rustc-hash", + "serde", + "tempfile", +] + +[[package]] +name = "hegeltest-macros" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84041698f9fe77b269fdce7c2edb8b1d319daee3ef211c1566a891819dc56ba3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "hugealloc" version = "0.1.1" @@ -380,6 +566,7 @@ dependencies = [ "expectorate", "foldhash 0.2.0", "hashbrown 0.16.0", + "hegeltest", "iddqd-test-utils", "proptest", "ref-cast", @@ -454,6 +641,12 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.13.0" @@ -503,6 +696,15 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.27" @@ -515,6 +717,15 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + [[package]] name = "newline-converter" version = "0.3.0" @@ -524,6 +735,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "num-modular" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc41a1374056e9672221567958a66c16be12d0e2c1b408761e14d901c237d5e0" + [[package]] name = "num-traits" version = "0.2.19" @@ -539,12 +756,41 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "oorandom" version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "paste" version = "1.0.15" @@ -606,7 +852,7 @@ dependencies = [ "bitflags", "lazy_static", "num-traits", - "rand", + "rand 0.9.4", "rand_chacha", "rand_xorshift", "regex-syntax", @@ -628,6 +874,12 @@ version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +[[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" @@ -635,7 +887,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.3", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -645,7 +908,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.3", ] [[package]] @@ -654,16 +917,22 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ - "getrandom", + "getrandom 0.3.3", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_xorshift" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "rand_core", + "rand_core 0.9.3", ] [[package]] @@ -686,6 +955,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "ref-cast" version = "1.0.24" @@ -745,6 +1023,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustix" version = "0.38.44" @@ -817,6 +1101,12 @@ dependencies = [ "syn", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.26" @@ -907,12 +1197,30 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + [[package]] name = "sptr" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "structmeta" version = "0.3.0" @@ -960,7 +1268,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.3.3", "once_cell", "rustix 1.0.7", "windows-sys 0.59.0", @@ -1018,6 +1326,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -1027,10 +1350,19 @@ dependencies = [ "indexmap 2.14.0", "serde_core", "serde_spanned", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", ] [[package]] @@ -1048,7 +1380,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.3", ] [[package]] @@ -1069,7 +1401,7 @@ dependencies = [ "serde_json", "target-triple", "termcolor", - "toml", + "toml 1.1.2+spec-1.1.0", ] [[package]] @@ -1143,6 +1475,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "walkdir" version = "2.5.0" @@ -1239,6 +1577,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[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.52.0" @@ -1257,6 +1601,15 @@ dependencies = [ "windows-targets", ] +[[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 = "windows-targets" version = "0.52.6" @@ -1321,6 +1674,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + [[package]] name = "winnow" version = "1.0.3" diff --git a/Cargo.toml b/Cargo.toml index 2264aa1..e6ac87a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ members = ["crates/*"] [workspace.package] edition = "2024" license = "MIT OR Apache-2.0" -rust-version = "1.85" +rust-version = "1.86" [workspace.lints.clippy] undocumented_unsafe_blocks = { level = "warn" } @@ -29,6 +29,7 @@ foldhash = "0.2.0" # allocator-api2 is turned on, then for e.g. IdHashMap, otherwise # IdHashMap.) hashbrown = { version = "0.16.0", default-features = false, features = ["allocator-api2", "inline-more"] } +hegeltest = "0.23.1" hugealloc = "0.1.1" iddqd = { path = "crates/iddqd", default-features = false } iddqd-test-utils = { path = "crates/iddqd-test-utils" } diff --git a/crates/iddqd-test-utils/src/naive_map.rs b/crates/iddqd-test-utils/src/naive_map.rs index 4d69587..5f3795e 100644 --- a/crates/iddqd-test-utils/src/naive_map.rs +++ b/crates/iddqd-test-utils/src/naive_map.rs @@ -190,6 +190,10 @@ impl NaiveMap { Some(self.items.remove(index)) } + pub fn items(&self) -> &[TestItem] { + &self.items + } + pub fn iter(&self) -> impl Iterator { self.items.iter() } diff --git a/crates/iddqd-test-utils/src/panic_safety.rs b/crates/iddqd-test-utils/src/panic_safety.rs index 690a484..41e4a03 100644 --- a/crates/iddqd-test-utils/src/panic_safety.rs +++ b/crates/iddqd-test-utils/src/panic_safety.rs @@ -3,8 +3,8 @@ //! Provides several types for use in panic safety tests: //! //! * [`PanickyKey`] is a key whose `Hash`/`Eq`/`Ord`/`Drop` impls share a -//! thread-local panic countdown. The map-specific test items also call into -//! the same countdown from `Drop`. +//! thread-local panic countdown. The map-specific test items also tick the +//! same countdown from `Drop` and from their `key()` accessors. //! * [`PanickyAlloc`] wraps an allocator and //! taps into the same countdown from `allocate`, so allocator-panic windows //! (notably the shrink path's `Vec::shrink_to_fit` and @@ -13,14 +13,6 @@ //! The panic countdowns are set up so that after it reaches zero, the next call //! panics. (The countdown values are both set by example-based tests and //! generated by PBTs.) -//! -//! The panic safety PBTs create random sequences of [`PanickyOp`]s, and after -//! each step assert: -//! -//! * `validate()` -//! * a `contains_key` round-trip on every surviving item -//! * (for atomic ops that panicked) that the post-op state equals the pre-op -//! snapshot. use crate::unwind::catch_panic; #[cfg(all(feature = "default-hasher", feature = "allocator-api2"))] @@ -35,30 +27,11 @@ use core::{ hash::{Hash, Hasher}, }; use equivalent::{Comparable, Equivalent}; -use proptest::prelude::*; use std::{ io::Write, sync::{Mutex, OnceLock}, }; -/// Default `proptest` case count for the per-map `proptest_panic_ops` -/// tests. -/// -/// 16 seems like a pretty small number for PBTs. What's going on here? For one, -/// we generate many operations per case (see [`PANIC_PROPTEST_MAX_OPS`]). But -/// also, CI runs the tests with the powerset of features, so these tests are -/// run multiple times with different feature combinations. Overall, we get -/// pretty good coverage and fast per-feature-set test runs. -pub const PANIC_PROPTEST_CASES: u32 = 16; - -/// Exclusive upper bound on the number of [`PanickyOp`]s generated per -/// `proptest_panic_ops` case (so each case runs `0..PANIC_PROPTEST_MAX_OPS` -/// operations against a fresh map). -/// -/// Empirically chosen to give the armed-countdown distribution enough room to -/// hit its long-tail buckets at least a few times per case. -pub const PANIC_PROPTEST_MAX_OPS: usize = 512; - thread_local! { static PANIC_COUNTDOWN: Cell> = const { Cell::new(None) }; static OP_COUNT: Cell = const { Cell::new(0) }; @@ -179,51 +152,6 @@ pub fn disarm_panic() { PANIC_COUNTDOWN.with(|c| c.set(None)); } -#[derive(Debug)] -pub struct PanickyOp { - pub action: A, - pub armed: Option, -} - -impl Arbitrary for PanickyOp -where - A: Arbitrary + fmt::Debug + 'static, -{ - type Parameters = A::Parameters; - type Strategy = BoxedStrategy; - - fn arbitrary_with(args: A::Parameters) -> Self::Strategy { - let armed = if observe_output_path().is_some() { - // In observe mode, force an effectively-infinite countdown so - // `OP_COUNT` ticks for every user call but the panic never fires. - // The PBTs then record the per-op call count for distribution - // analysis. See [`observe_output_path`] and [`record_observation`]. - Just(Some(u32::MAX)).boxed() - } else { - // Layered distribution chosen from observed per-op user-call - // counts (see [`observe_output_path`]). - prop_oneof![ - // Mostly `None` so that the map fills up. - 7 => Just(None), - // Retains dense coverage of early panics. - 2 => (0..16_u32).prop_map(Some), - // This bucket covers single-key atomic ops (`IdOrdMap` - // insert/overwrite/remove go up to ~114 calls) and - // `*HashMap::InsertOverwrite` (~63). - 1 => (16..128_u32).prop_map(Some), - // This bucket covers bulk ops, with `Extend`, `RetainModulo`, - // and `Clear` observed running to ~503 calls - // (`IdOrdMap::Extend`). - 1 => (128..640_u32).prop_map(Some), - ] - .boxed() - }; - (any_with::(args), armed) - .prop_map(|(action, armed)| PanickyOp { action, armed }) - .boxed() - } -} - /// Run `f` with the panic countdown set, then unconditionally disarm /// so a leftover countdown can't trip later code. Returns /// `(panicked, ops)` where `ops` is the count of observed user calls made @@ -242,12 +170,17 @@ pub fn run_armed(armed: Option, f: impl FnOnce()) -> (bool, u32) { /// Asserts that the panic-countdown infrastructure fired (or didn't) /// exactly as the arming would predict. /// -/// "User call" here means any of `Hash`/`Eq`/`Ord`/`Drop` on `PanickyKey`, -/// or `Drop` on a map item. With `armed = Some(n)`, the panic should fire -/// on the `(n+1)`-th user call, so `panicked` implies `ops == n + 1`, -/// and `!panicked` implies -/// the action made at most `n` user calls. With `armed = None`, no -/// panic should escape. +/// "User call" means anything that decrements the shared countdown: +/// +/// * Key accesses. +/// * `Hash`/`Eq`/`Ord`/`Drop` on `PanickyKey`. +/// * `Hash`/`Equivalent`/`Comparable` impls on `PanickySearchKey`. +/// * `Drop` on a map item. +/// * `allocate` on `PanickyAlloc`. +/// +/// With `armed = Some(n)`, the panic should fire on the `(n+1)`-th user call, +/// so `panicked` implies `ops == n + 1`, and `!panicked` implies the action +/// made at most `n` user calls. With `armed = None`, no panic should escape. pub fn assert_panic_fired_as_expected( op_label: &dyn fmt::Display, armed: Option, @@ -356,7 +289,7 @@ pub fn observe_output_path() -> Option<&'static Utf8Path> { /// Records a per-op observation when observe mode is on. (This is a no-op when /// observe mode is off.) /// -/// The output is a TSV with columns: map label, action variant name, observed +/// The output is a TSV with columns: map label, action label, observed /// user-call count. pub fn record_observation( map_label: &'static str, @@ -384,19 +317,11 @@ pub fn record_observation( Mutex::new(file) }); - // Strip arguments from `action_label`. This assumes the action enum uses - // its default-derived `Debug`, which produces `Variant`, `Variant(..)`, or - // `Variant { .. }`. - let variant = action_label - .split(['(', ' ', '{']) - .next() - .expect("str::split always yields at least one element"); - // Small writes with `O_APPEND` are fine on Linux, so concurrent nextest // worker processes won't interleave records on a local filesystem. // (`write_all` may loop on a short write or `EINTR`, but neither is // realistic for a ~50-byte regular-file write.) - let line = format!("{map_label}\t{variant}\t{ops}\n"); + let line = format!("{map_label}\t{action_label}\t{ops}\n"); let mut file = file.lock().expect("acquired observation file lock"); file.write_all(line.as_bytes()).expect("wrote observation record"); } diff --git a/crates/iddqd-test-utils/src/test_item.rs b/crates/iddqd-test-utils/src/test_item.rs index 2f9df90..8fc202d 100644 --- a/crates/iddqd-test-utils/src/test_item.rs +++ b/crates/iddqd-test-utils/src/test_item.rs @@ -8,7 +8,7 @@ use iddqd::{ }; #[cfg(feature = "std")] use iddqd::{IdOrdItem, IdOrdMap, id_ord_map}; -use proptest::{prelude::*, sample::SizeRange}; +use proptest::prelude::*; use std::{cell::Cell, fmt}; use test_strategy::Arbitrary; @@ -905,38 +905,3 @@ pub fn assert_iter_eq>(mut map: M, items: Vec<&TestItem>) { into_iter.sort_by_key(|e| e.key1); assert_eq!(into_iter, items, ".into_iter() items match naive ones"); } - -// Returns a pair of permutations of a set of unique items (unique to a given -// map). -pub fn test_item_permutation_strategy>( - size: impl Into, -) -> impl Strategy, Vec)> { - prop::collection::vec(any::(), size.into()).prop_perturb( - |v, mut rng| { - // It is possible (likely even) that the input vector has - // duplicates. How can we remove them? The easiest way is to use - // the logic that already exists to check for duplicates. Insert - // all the items one by one, then get the list. - let mut map = M::make_new(); - for item in v { - // The error case here is expected -- we're actively de-duping - // items right now. - _ = map.insert_unique(item); - } - let set: Vec<_> = map.into_iter().collect(); - - // Now shuffle the items. This is a simple Fisher-Yates shuffle - // (Durstenfeld variant, low to high). - let mut set2 = set.clone(); - if set.len() < 2 { - return (set, set2); - } - for i in 0..set2.len() - 2 { - let j = rng.random_range(i..set2.len()); - set2.swap(i, j); - } - - (set, set2) - }, - ) -} diff --git a/crates/iddqd/Cargo.toml b/crates/iddqd/Cargo.toml index 319c3d2..382dcb7 100644 --- a/crates/iddqd/Cargo.toml +++ b/crates/iddqd/Cargo.toml @@ -37,6 +37,7 @@ proptest = { workspace = true, optional = true } [dev-dependencies] expectorate.workspace = true foldhash.workspace = true +hegeltest.workspace = true iddqd-test-utils.workspace = true proptest.workspace = true serde.workspace = true diff --git a/crates/iddqd/README.md b/crates/iddqd/README.md index bcff503..fb908aa 100644 --- a/crates/iddqd/README.md +++ b/crates/iddqd/README.md @@ -5,7 +5,7 @@ ![License: MIT OR Apache-2.0](https://img.shields.io/crates/l/iddqd.svg?) [![crates.io](https://img.shields.io/crates/v/iddqd.svg?logo=rust)](https://crates.io/crates/iddqd) [![docs.rs](https://img.shields.io/docsrs/iddqd.svg?logo=docs.rs)](https://docs.rs/iddqd) -[![Rust: ^1.85.0](https://img.shields.io/badge/rust-^1.85.0-93450a.svg?logo=rust)](https://doc.rust-lang.org/cargo/reference/manifest.html#the-rust-version-field) +[![Rust: ^1.86.0](https://img.shields.io/badge/rust-^1.86.0-93450a.svg?logo=rust)](https://doc.rust-lang.org/cargo/reference/manifest.html#the-rust-version-field) Maps where keys are borrowed from values. @@ -326,7 +326,7 @@ platform-specific notion of thread locals, would suffice to make ## Minimum supported Rust version (MSRV) -This crate’s MSRV is **Rust 1.85**. In general we aim for 6 months of Rust +This crate’s MSRV is **Rust 1.86**. In general we aim for 6 months of Rust compatibility. ## What does iddqd mean? diff --git a/crates/iddqd/src/lib.rs b/crates/iddqd/src/lib.rs index 31f3667..5e7b8a5 100644 --- a/crates/iddqd/src/lib.rs +++ b/crates/iddqd/src/lib.rs @@ -354,7 +354,7 @@ //! //! # Minimum supported Rust version (MSRV) //! -//! This crate's MSRV is **Rust 1.85**. In general we aim for 6 months of Rust +//! This crate's MSRV is **Rust 1.86**. In general we aim for 6 months of Rust //! compatibility. //! //! # What does iddqd mean? diff --git a/crates/iddqd/tests/integration/bi_hash_map.rs b/crates/iddqd/tests/integration/bi_hash_map.rs index 6daefa1..0e844d3 100644 --- a/crates/iddqd/tests/integration/bi_hash_map.rs +++ b/crates/iddqd/tests/integration/bi_hash_map.rs @@ -1,3 +1,8 @@ +use crate::hegel_support::{ + draw_fill_batch, draw_lookup_key1, draw_lookup_key2, draw_lookup_keys12, + draw_shuffle, test_item, +}; +use hegel::{TestCase, generators as gs}; use iddqd::{ BiHashItem, BiHashMap, bi_hash_map, bi_upcast, internal::ValidateCompact, }; @@ -7,15 +12,13 @@ use iddqd_test_utils::{ naive_map::NaiveMap, test_item::{ Alloc, HashBuilder, ItemMap, TestItem, TestKey1, TestKey2, - assert_iter_eq, test_item_permutation_strategy, + assert_iter_eq, }, }; -use proptest::prelude::*; use std::{ borrow::Cow, path::{Path, PathBuf}, }; -use test_strategy::{Arbitrary, proptest}; #[derive(Clone, Debug)] struct SimpleItem { @@ -245,134 +248,30 @@ impl CompactnessChange { } } -/// A keys-pair sourced from a mix of "an existing item in the map" and random -/// fallback values. -/// -/// Each component independently either copies a key from an item at -/// `key{1,2}_from % naive_map.len()` (when the map is non-empty), or falls back -/// to the random `rand_key{1,2}` value. This mix-and-match makes "right key1, -/// wrong key2" (and vice versa) triples common in the proptest stream, which is -/// what the `_unique` methods need to be exercised on. -#[derive(Clone, Debug, Arbitrary)] -struct UniqueKeysOp { - key1_from: Option, - key2_from: Option, - rand_key1: u8, - rand_key2: char, +struct BiHashMapMachine { + map: BiHashMap, + naive: NaiveMap, + compactness: ValidateCompact, } -impl UniqueKeysOp { - /// Resolves the pair against the current oracle state. - fn resolve(&self, naive_map: &NaiveMap) -> (u8, char) { - let items: Vec<&TestItem> = naive_map.iter().collect(); - let pick_from = |from: Option| -> Option<&TestItem> { - let len = items.len(); - from.and_then(|i| { - if len == 0 { None } else { Some(items[i as usize % len]) } - }) - }; - let key1 = pick_from(self.key1_from) - .map(|item| item.key1) - .unwrap_or(self.rand_key1); - let key2 = pick_from(self.key2_from) - .map(|item| item.key2) - .unwrap_or(self.rand_key2); - (key1, key2) +impl BiHashMapMachine { + fn check_valid(&mut self, change: CompactnessChange) { + self.compactness = change.apply(self.compactness); + self.map.validate(self.compactness).expect("map should be valid"); } } -#[derive(Debug, Arbitrary)] -enum Operation { - // Make inserts a bit more common to try and fill up the map. - #[weight(4)] - InsertUnique(TestItem), - #[weight(3)] - InsertOverwrite(TestItem), - #[weight(2)] - EntryInsertOverwrite(TestItem), - #[weight(2)] - EntryRemove(UniqueKeysOp), - #[weight(2)] - Get1(u8), - #[weight(2)] - Get2(char), - #[weight(2)] - GetUnique(UniqueKeysOp), - #[weight(2)] - GetMutUnique(UniqueKeysOp), - #[weight(2)] - Remove1(u8), - #[weight(2)] - Remove2(char), - #[weight(2)] - RemoveUnique(UniqueKeysOp), - #[weight(2)] - RetainValueContains(char, bool), - #[weight(2)] - RetainModulo(#[strategy(0..3_u8)] u8, #[strategy(1..4_u8)] u8, bool), - #[weight(2)] - Extend( - #[strategy(prop::collection::vec(any::(), 0..16))] - Vec, - ), - Clear, - // `additional` is kept modest so that reservations frequently - // exceed the current `growth_left` and so trigger hashbrown's - // rehash path. - Reserve(#[strategy(0..256_usize)] usize), - TryReserve(#[strategy(0..256_usize)] usize), - ShrinkToFit, - ShrinkTo(#[strategy(0..256_usize)] usize), -} - -impl Operation { - fn compactness_change(&self) -> CompactnessChange { - match self { - Operation::InsertUnique(_) - | Operation::Get1(_) - | Operation::Get2(_) - | Operation::GetUnique(_) - | Operation::GetMutUnique(_) - | Operation::Reserve(_) - | Operation::TryReserve(_) => CompactnessChange::NoChange, - // The act of removing items, including calls to insert_overwrite, - // can make the map non-compact. - Operation::InsertOverwrite(_) - | Operation::EntryInsertOverwrite(_) - | Operation::EntryRemove(_) - | Operation::Remove1(_) - | Operation::Remove2(_) - | Operation::RemoveUnique(_) - | Operation::RetainValueContains(_, _) - | Operation::RetainModulo(_, _, _) - | Operation::Extend(_) => CompactnessChange::NoLongerCompact, - // Clear always makes the map compact (empty). Shrink - // fully compacts the backing store, restoring the - // `Compact` invariant. - Operation::Clear - | Operation::ShrinkToFit - | Operation::ShrinkTo(_) => CompactnessChange::BecomesCompact, - } - } -} - -#[proptest(cases = 16)] -fn proptest_ops( - #[strategy(prop::collection::vec(any::(), 0..1024))] ops: Vec< - Operation, - >, -) { - let mut map = BiHashMap::::make_new(); - let mut naive_map = NaiveMap::new_key12(); - - let mut compactness = ValidateCompact::Compact; - - // Now perform the operations on both maps. - for op in ops.into_iter() { - compactness = op.compactness_change().apply(compactness); - - match op { - Operation::InsertUnique(item) => { +mod indent0 { + mod indent1 { + use super::super::*; + + #[hegel::state_machine] + impl BiHashMapMachine { + #[rule] + fn insert_unique(&mut self, tc: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; + let item = tc.draw(test_item()); let map_res = map.insert_unique(item.clone()); let naive_res = naive_map.insert_unique(item.clone()); @@ -393,9 +292,14 @@ fn proptest_ops( assert_eq!(map_err_dups, naive_err_dups); } - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::NoChange); } - Operation::InsertOverwrite(item) => { + + #[rule] + fn insert_overwrite(&mut self, tc: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; + let item = tc.draw(test_item()); let mut map_dups = map.insert_overwrite(item.clone()); map_dups.sort(); let mut naive_dups = naive_map.insert_overwrite(item.clone()); @@ -405,9 +309,14 @@ fn proptest_ops( map_dups, naive_dups, "map and naive map should agree on insert_overwrite dups" ); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::EntryInsertOverwrite(item) => { + + #[rule] + fn entry_insert_overwrite(&mut self, tc: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; + let item = tc.draw(test_item()); let map_res = match map.entry(item.key1(), item.key2()) { bi_hash_map::Entry::Occupied(mut entry) => { let mut dups = entry.insert(item.clone()); @@ -429,10 +338,14 @@ fn proptest_ops( map_res, naive_res, "map and naive map should agree on Entry::insert dups" ); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::EntryRemove(keys) => { - let (key1, key2) = keys.resolve(&naive_map); + + #[rule] + fn entry_remove(&mut self, tc: TestCase) { + let (key1, key2) = draw_lookup_keys12(&tc, &self.naive); + let map = &mut self.map; + let naive_map = &mut self.naive; let map_res = match map .entry(TestKey1::new(&key1), TestKey2::new(key2)) @@ -452,62 +365,100 @@ fn proptest_ops( map_res, naive_res, "map and naive map should agree on Entry::remove items" ); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::Get1(key1) => { + + #[rule] + fn get1(&mut self, tc: TestCase) { + let key1 = draw_lookup_key1(&tc, &self.naive); + let map = &mut self.map; + let naive_map = &mut self.naive; let map_res = map.get1(&TestKey1::new(&key1)); let naive_res = naive_map.get1(key1); assert_eq!(map_res, naive_res); } - Operation::Get2(key2) => { + + #[rule] + fn get2(&mut self, tc: TestCase) { + let key2 = draw_lookup_key2(&tc, &self.naive); + let map = &mut self.map; + let naive_map = &mut self.naive; let map_res = map.get2(&TestKey2::new(key2)); let naive_res = naive_map.get2(key2); assert_eq!(map_res, naive_res); } - Operation::GetUnique(keys) => { - let (key1, key2) = keys.resolve(&naive_map); + + #[rule] + fn get_unique(&mut self, tc: TestCase) { + let (key1, key2) = draw_lookup_keys12(&tc, &self.naive); + let map = &mut self.map; + let naive_map = &mut self.naive; let map_res = map.get_unique(&TestKey1::new(&key1), &TestKey2::new(key2)); let naive_res = naive_map.get_unique12(key1, key2); assert_eq!(map_res, naive_res); } - Operation::GetMutUnique(keys) => { - let (key1, key2) = keys.resolve(&naive_map); + + #[rule] + fn get_mut_unique(&mut self, tc: TestCase) { + let (key1, key2) = draw_lookup_keys12(&tc, &self.naive); + let map = &mut self.map; + let naive_map = &mut self.naive; let map_res = map .get_mut_unique(&TestKey1::new(&key1), &TestKey2::new(key2)) .map(|r| (*r).clone()); let naive_res = naive_map.get_mut_unique12(key1, key2).cloned(); assert_eq!(map_res, naive_res); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::NoChange); } - Operation::Remove1(key1) => { + + #[rule] + fn remove1(&mut self, tc: TestCase) { + let key1 = draw_lookup_key1(&tc, &self.naive); + let map = &mut self.map; + let naive_map = &mut self.naive; let map_res = map.remove1(&TestKey1::new(&key1)); let naive_res = naive_map.remove1(key1); assert_eq!(map_res, naive_res); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::Remove2(key2) => { + + #[rule] + fn remove2(&mut self, tc: TestCase) { + let key2 = draw_lookup_key2(&tc, &self.naive); + let map = &mut self.map; + let naive_map = &mut self.naive; let map_res = map.remove2(&TestKey2::new(key2)); let naive_res = naive_map.remove2(key2); assert_eq!(map_res, naive_res); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::RemoveUnique(keys) => { - let (key1, key2) = keys.resolve(&naive_map); + + #[rule] + fn remove_unique(&mut self, tc: TestCase) { + let (key1, key2) = draw_lookup_keys12(&tc, &self.naive); + let map = &mut self.map; + let naive_map = &mut self.naive; let map_res = map .remove_unique(&TestKey1::new(&key1), &TestKey2::new(key2)); let naive_res = naive_map.remove_unique12(key1, key2); assert_eq!(map_res, naive_res); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::RetainValueContains(ch, equals) => { + + #[rule] + fn retain_value_contains(&mut self, tc: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; + let ch = tc.draw(gs::characters()); + let equals = tc.draw(gs::booleans()); map.retain(|item| { let contains = item.value.contains(ch); if equals { contains } else { !contains } @@ -516,9 +467,16 @@ fn proptest_ops( let contains = item.value.contains(ch); if equals { contains } else { !contains } }); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::RetainModulo(a, b, equals) => { + + #[rule] + fn retain_modulo(&mut self, tc: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; + let a = tc.draw(gs::integers::().max_value(2)); + let b = tc.draw(gs::integers::().min_value(1).max_value(3)); + let equals = tc.draw(gs::booleans()); let modulo = a + b; let remainder = a; map.retain(|item| { @@ -529,70 +487,117 @@ fn proptest_ops( let matches = item.key1 % modulo == remainder; if equals { matches } else { !matches } }); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); + } + + #[rule] + fn extend(&mut self, tc: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; + let items = tc.draw(gs::vecs(test_item()).max_size(15)); + map.extend(items.clone()); + naive_map.extend(items); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::Extend(items) => { + + // Fill up the map to ensure later operations use a larger map. + #[rule] + fn fill(&mut self, tc: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; + let items = draw_fill_batch(&tc); map.extend(items.clone()); naive_map.extend(items); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::Reserve(additional) => { + + #[rule] + fn reserve(&mut self, tc: TestCase) { + let map = &mut self.map; + let additional = + tc.draw(gs::integers::().max_value(255)); map.reserve(additional); - // `reserve` has no observable effect beyond capacity; the - // naive map has no equivalent. `validate` is the real - // check — it iterates items and asks `find_index` for - // each, which catches a hash-table left mis-bucketed by - // a regrowth rehash. - map.validate(compactness).expect("map should be valid"); + // `reserve` has no observable effect beyond capacity -- the + // naive map has no equivalent. `check_valid` will iterate items + // and ask `find_index` for each, which catches a hash-table + // left mis-bucketed by a regrowth rehash. + self.check_valid(CompactnessChange::NoChange); } - Operation::TryReserve(additional) => { - // Mirror `Reserve`; we don't assert `Ok` because the - // allocator could (legitimately) refuse a large request, - // and bailing on that would mask the actual regression - // we care about (silent hash-table corruption). + + #[rule] + fn try_reserve(&mut self, tc: TestCase) { + let map = &mut self.map; + let additional = + tc.draw(gs::integers::().max_value(255)); let _ = map.try_reserve(additional); - map.validate(compactness).expect("map should be valid"); + // See the comment on `reserve` above for why this is only + // `check_valid`. + self.check_valid(CompactnessChange::NoChange); } - Operation::ShrinkToFit => { + + #[rule] + fn shrink_to_fit(&mut self, _: TestCase) { + let map = &mut self.map; map.shrink_to_fit(); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::BecomesCompact); } - Operation::ShrinkTo(min_capacity) => { + + #[rule] + fn shrink_to(&mut self, tc: TestCase) { + let map = &mut self.map; + let min_capacity = + tc.draw(gs::integers::().max_value(255)); map.shrink_to(min_capacity); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::BecomesCompact); } - Operation::Clear => { + + #[rule] + fn clear(&mut self, _: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; map.clear(); naive_map.clear(); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::BecomesCompact); } - } - // Check that the iterators work correctly. - let mut naive_items = naive_map.iter().collect::>(); - naive_items.sort_by(|a, b| a.key1().cmp(&b.key1())); - - assert_iter_eq(map.clone(), naive_items); + #[invariant] + fn iter_matches(&mut self, _: TestCase) { + let map = &self.map; + let naive_map = &self.naive; + let mut naive_items = naive_map.iter().collect::>(); + naive_items.sort_by(|a, b| a.key1().cmp(&b.key1())); + assert_iter_eq(map.clone(), naive_items); + } + } } } -#[proptest(cases = 64)] -fn proptest_permutation_eq( - #[strategy(test_item_permutation_strategy::>(0..256))] - items: (Vec, Vec), -) { - let (items1, items2) = items; +#[hegel::test(test_cases = 512)] +fn proptest_ops(tc: TestCase) { + let machine = BiHashMapMachine { + map: BiHashMap::::make_new(), + naive: NaiveMap::new_key12(), + compactness: ValidateCompact::Compact, + }; + hegel::stateful::run(machine, tc); +} + +#[hegel::test(test_cases = 64)] +fn proptest_permutation_eq(tc: TestCase) { + // draw_fill_batch generates unique keys so there's no need to deduplicate. + let set = draw_fill_batch(&tc); + let set2 = draw_shuffle(&tc, &set); + let mut map1 = BiHashMap::::make_new(); let mut map2 = BiHashMap::::make_new(); - - for item in items1 { - map1.insert_unique(item.clone()).unwrap(); + for item in set { + map1.insert_unique(item).expect("set is deduplicated"); } - for item in items2 { - map2.insert_unique(item.clone()).unwrap(); + for item in set2 { + map2.insert_unique(item).expect("set is deduplicated"); } - assert_eq_props(map1, map2); + assert_eq_props(&map1, &map2); } // Test various conditions for non-equality. @@ -1234,21 +1239,26 @@ mod macro_tests { #[cfg(feature = "serde")] mod serde_tests { + use crate::hegel_support::draw_random_batch; + use hegel::TestCase; use iddqd::BiHashMap; use iddqd_test_utils::{ serde_utils::assert_serialize_roundtrip, test_item::{Alloc, HashBuilder, TestItem}, }; - use test_strategy::proptest; - #[proptest] - fn proptest_serialize_roundtrip(values: Vec) { + #[hegel::test(test_cases = 256)] + fn proptest_serialize_roundtrip(tc: TestCase) { + let values = draw_random_batch(&tc); assert_serialize_roundtrip::>( values, ); } } +#[cfg(feature = "proptest")] +use test_strategy::proptest; + #[cfg(feature = "proptest")] #[proptest(cases = 16)] fn proptest_arbitrary_map(map: BiHashMap) { @@ -1305,209 +1315,303 @@ impl Drop for PanickyHashItem { #[cfg(all(feature = "default-hasher", feature = "allocator-api2"))] mod proptest_panic_safety { use super::*; + use crate::hegel_support::{MAX_PANIC_KEY, draw_armed}; use allocator_api2::alloc::Global; use iddqd_test_utils::panic_safety::{ - PANIC_PROPTEST_CASES, PANIC_PROPTEST_MAX_OPS, PanicSafety, - PanickyAlloc, PanickyKey, PanickyOp, PanickySearchKey, + PanicSafety, PanickyAlloc, PanickyKey, PanickySearchKey, assert_panic_fired_as_expected, assert_post_op_invariants, drop_unarmed, record_observation, run_armed, sorted_keys, }; - /// Map type used by these tests. type PanickyMap = BiHashMap< PanickyHashItem, iddqd::DefaultHashBuilder, PanickyAlloc, >; - // Keys are kept in a small range so hits and misses both happen - // frequently against a 16-ish-element map. - #[derive(Debug, Arbitrary)] - enum PanickyAction { - #[weight(4)] - InsertUnique(#[strategy(0..32_u32)] u32, #[strategy(0..32_u32)] u32), - #[weight(3)] - InsertOverwrite(#[strategy(0..32_u32)] u32, #[strategy(0..32_u32)] u32), - #[weight(3)] - EntryInsertOverwrite( - #[strategy(0..32_u32)] u32, - #[strategy(0..32_u32)] u32, - ), - #[weight(2)] - EntryRemove(#[strategy(0..32_u32)] u32, #[strategy(0..32_u32)] u32), - #[weight(2)] - Remove1(#[strategy(0..32_u32)] u32), - #[weight(2)] - Remove2(#[strategy(0..32_u32)] u32), - #[weight(1)] - Get1(#[strategy(0..32_u32)] u32), - #[weight(1)] - Get2(#[strategy(0..32_u32)] u32), - #[weight(1)] - ContainsKey1(#[strategy(0..32_u32)] u32), - #[weight(1)] - ContainsKey2(#[strategy(0..32_u32)] u32), - #[weight(2)] - RetainModulo( - #[strategy(0..3_u32)] u32, - #[strategy(1..4_u32)] u32, - bool, - ), - #[weight(2)] - Extend( - #[strategy(prop::collection::vec( - (0..32_u32, 0..32_u32), 0..8, - ))] - Vec<(u32, u32)>, - ), - Clear, - ShrinkToFit, - ShrinkTo(#[strategy(0..32_usize)] usize), + struct PanicMachine { + map: PanickyMap, + step: usize, + pending: Option, } - impl PanickyAction { - /// Classify panic safety for this action. - /// - /// * `RetainModulo` and `Clear` loop over per-step atomic item - /// destruction. - /// * `Extend` calls `HashTable::reserve` up front, which on a - /// tombstone-heavy map drops into hashbrown's - /// `rehash_in_place` — documented as not panic-safe under a - /// user `Hash` panic, so the proptest skips arming for it. - fn panic_safety(&self) -> PanicSafety { - match self { - PanickyAction::InsertUnique(_, _) - | PanickyAction::InsertOverwrite(_, _) - | PanickyAction::EntryInsertOverwrite(_, _) - | PanickyAction::EntryRemove(_, _) - | PanickyAction::Remove1(_) - | PanickyAction::Remove2(_) - | PanickyAction::Get1(_) - | PanickyAction::Get2(_) - | PanickyAction::ContainsKey1(_) - | PanickyAction::ContainsKey2(_) - | PanickyAction::ShrinkToFit - | PanickyAction::ShrinkTo(_) => PanicSafety::Atomic, - PanickyAction::RetainModulo(_, _, _) - | PanickyAction::Extend(_) - | PanickyAction::Clear => PanicSafety::StepAtomic, - } + struct Pending { + label: &'static str, + panic_safety: PanicSafety, + armed: Option, + panicked: bool, + pre_state: Vec<(u32, u32)>, + } + + impl PanicMachine { + fn armed_op( + &mut self, + tc: &TestCase, + label: &'static str, + panic_safety: PanicSafety, + op: impl FnOnce(&mut PanickyMap), + ) { + // hegel runs the `#[invariant]` (which consumes `pending`) after + // every successful rule, so `pending` must be `None` here -- if + // not, a prior op's post-op checks were silently skipped. + assert!( + self.pending.is_none(), + "previous op's post-op invariant did not run before this op", + ); + let armed = draw_armed(tc); + let pre_state = + sorted_keys(&self.map, |item| (item.key1, item.key2)); + let (panicked, ops) = run_armed(armed, || op(&mut self.map)); + record_observation("bi_hash_map", label, ops); + assert_panic_fired_as_expected(&label, armed, panicked, ops); + + // `self.pending` is set at the end of this function, after all + // fallible draws. + self.pending = Some(Pending { + label, + panic_safety, + armed, + panicked, + pre_state, + }); } + } - fn run(self, map: &mut PanickyMap) { - match self { - PanickyAction::InsertUnique(key1, key2) => { - drop_unarmed( - map.insert_unique(PanickyHashItem { key1, key2 }), - ); - } - PanickyAction::InsertOverwrite(key1, key2) => { + #[hegel::state_machine] + impl PanicMachine { + #[rule] + fn insert_unique(&mut self, tc: TestCase) { + let key1 = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + let key2 = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + self.armed_op(&tc, "insert_unique", PanicSafety::Atomic, |map| { + drop_unarmed(map.insert_unique(PanickyHashItem { key1, key2 })); + }); + } + + #[rule] + fn insert_overwrite(&mut self, tc: TestCase) { + let key1 = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + let key2 = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + self.armed_op( + &tc, + "insert_overwrite", + PanicSafety::Atomic, + |map| { drop_unarmed( map.insert_overwrite(PanickyHashItem { key1, key2 }), ); - } - PanickyAction::EntryInsertOverwrite(key1, key2) => { - let entry = map.entry(PanickyKey(key1), PanickyKey(key2)); + }, + ); + } + #[rule] + fn entry_insert_overwrite(&mut self, tc: TestCase) { + let key1 = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + let key2 = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + self.armed_op( + &tc, + "entry_insert_overwrite", + PanicSafety::Atomic, + |map| { + let entry = map.entry(PanickyKey(key1), PanickyKey(key2)); if let bi_hash_map::Entry::Occupied(mut entry) = entry { drop_unarmed( entry.insert(PanickyHashItem { key1, key2 }), ); } - } - PanickyAction::EntryRemove(key1, key2) => { - let entry = map.entry(PanickyKey(key1), PanickyKey(key2)); + }, + ); + } - if let bi_hash_map::Entry::Occupied(entry) = entry { - drop_unarmed(entry.remove()); - } - } - PanickyAction::Remove1(key1) => { - drop_unarmed(map.remove1(&PanickySearchKey(key1))); - } - PanickyAction::Remove2(key2) => { - drop_unarmed(map.remove2(&PanickySearchKey(key2))); - } - PanickyAction::Get1(key1) => { - let _ = map.get1(&PanickySearchKey(key1)); - } - PanickyAction::Get2(key2) => { - let _ = map.get2(&PanickySearchKey(key2)); - } - PanickyAction::ContainsKey1(key1) => { - let _ = map.contains_key1(&PanickySearchKey(key1)); - } - PanickyAction::ContainsKey2(key2) => { - let _ = map.contains_key2(&PanickySearchKey(key2)); + #[rule] + fn entry_remove(&mut self, tc: TestCase) { + let key1 = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + let key2 = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + self.armed_op(&tc, "entry_remove", PanicSafety::Atomic, |map| { + let entry = map.entry(PanickyKey(key1), PanickyKey(key2)); + if let bi_hash_map::Entry::Occupied(entry) = entry { + drop_unarmed(entry.remove()); } - PanickyAction::RetainModulo(rem, modulo, keep) => { + }); + } + + #[rule] + fn remove1(&mut self, tc: TestCase) { + let key1 = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + self.armed_op(&tc, "remove1", PanicSafety::Atomic, |map| { + drop_unarmed(map.remove1(&PanickySearchKey(key1))); + }); + } + + #[rule] + fn remove2(&mut self, tc: TestCase) { + let key2 = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + self.armed_op(&tc, "remove2", PanicSafety::Atomic, |map| { + drop_unarmed(map.remove2(&PanickySearchKey(key2))); + }); + } + + #[rule] + fn get1(&mut self, tc: TestCase) { + let key1 = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + self.armed_op(&tc, "get1", PanicSafety::Atomic, |map| { + let _ = map.get1(&PanickySearchKey(key1)); + }); + } + + #[rule] + fn get2(&mut self, tc: TestCase) { + let key2 = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + self.armed_op(&tc, "get2", PanicSafety::Atomic, |map| { + let _ = map.get2(&PanickySearchKey(key2)); + }); + } + + #[rule] + fn contains_key1(&mut self, tc: TestCase) { + let key1 = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + self.armed_op(&tc, "contains_key1", PanicSafety::Atomic, |map| { + let _ = map.contains_key1(&PanickySearchKey(key1)); + }); + } + + #[rule] + fn contains_key2(&mut self, tc: TestCase) { + let key2 = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + self.armed_op(&tc, "contains_key2", PanicSafety::Atomic, |map| { + let _ = map.contains_key2(&PanickySearchKey(key2)); + }); + } + + #[rule] + fn retain_modulo(&mut self, tc: TestCase) { + let rem = tc.draw(gs::integers::().max_value(2)); + let modulo = + tc.draw(gs::integers::().min_value(1).max_value(3)); + let keep = tc.draw(gs::booleans()); + self.armed_op( + &tc, + "retain_modulo", + // `retain_modulo` loops over per-step atomic operations. + PanicSafety::StepAtomic, + |map| { map.retain(|item| { let matches = item.key1 % modulo == rem; if keep { matches } else { !matches } }); - } - PanickyAction::Extend(pairs) => { - map.extend( - pairs - .into_iter() - .map(|(key1, key2)| PanickyHashItem { key1, key2 }), - ); - } - PanickyAction::Clear => map.clear(), - PanickyAction::ShrinkToFit => map.shrink_to_fit(), - PanickyAction::ShrinkTo(min_capacity) => { - map.shrink_to(min_capacity); - } + }, + ); + } + + #[rule] + fn extend(&mut self, tc: TestCase) { + let pairs = tc.draw( + gs::vecs(gs::tuples!( + gs::integers::().max_value(MAX_PANIC_KEY), + gs::integers::().max_value(MAX_PANIC_KEY), + )) + .max_size(7), + ); + // `extend` does per-step atomic operations. + self.armed_op(&tc, "extend", PanicSafety::StepAtomic, |map| { + map.extend( + pairs + .into_iter() + .map(|(key1, key2)| PanickyHashItem { key1, key2 }), + ); + }); + } + + #[rule] + fn fill(&mut self, tc: TestCase) { + let pairs = tc.draw( + gs::vecs(gs::tuples!( + gs::integers::().max_value(MAX_PANIC_KEY), + gs::integers::().max_value(MAX_PANIC_KEY), + )) + .max_size(64), + ); + for (key1, key2) in pairs { + let _ = self.map.insert_unique(PanickyHashItem { key1, key2 }); } } - } - #[proptest(cases = PANIC_PROPTEST_CASES)] - fn proptest_panic_ops( - #[strategy(prop::collection::vec( - any::>(), 0..PANIC_PROPTEST_MAX_OPS, - ))] - ops: Vec>, - ) { - let mut map: PanickyMap = BiHashMap::with_hasher_in( - iddqd::DefaultHashBuilder::default(), - PanickyAlloc::default(), - ); + #[rule] + fn clear(&mut self, tc: TestCase) { + self.armed_op( + &tc, + "clear", + // `clear` does per-table atomic operations. + PanicSafety::StepAtomic, + |map| { + map.clear(); + }, + ); + } - for (i, op) in ops.into_iter().enumerate() { - let action = op.action; - let action_label = format!("{action:?}"); - let panic_safety = action.panic_safety(); - let armed = op.armed; + #[rule] + fn shrink_to_fit(&mut self, tc: TestCase) { + self.armed_op(&tc, "shrink_to_fit", PanicSafety::Atomic, |map| { + map.shrink_to_fit(); + }); + } - let pre_state = sorted_keys(&map, |item| (item.key1, item.key2)); - let (panicked, ops) = run_armed(armed, || action.run(&mut map)); - record_observation("bi_hash_map", &action_label, ops); - assert_panic_fired_as_expected(&action_label, armed, panicked, ops); + #[rule] + fn shrink_to(&mut self, tc: TestCase) { + let min_capacity = tc.draw( + gs::integers::().max_value(MAX_PANIC_KEY as usize), + ); + self.armed_op(&tc, "shrink_to", PanicSafety::Atomic, |map| { + map.shrink_to(min_capacity); + }); + } + + #[invariant] + fn check_post_op(&mut self, _: TestCase) { + let Some(p) = self.pending.take() else { + self.map + .validate(ValidateCompact::NonCompact) + .expect("map should be valid"); + return; + }; + let step = self.step; // `NonCompact` since step-atomic panics can leave compactness in an // indeterminate state. - map.validate(ValidateCompact::NonCompact).unwrap_or_else(|err| { - panic!( - "map invalid after op {i} ({action_label}, \ - armed: {armed:?}, panicked: {panicked}): {err}" - ) - }); - - let post_state = sorted_keys(&map, |item| (item.key1, item.key2)); + self.map.validate(ValidateCompact::NonCompact).unwrap_or_else( + |err| { + panic!( + "map invalid after op {step} ({}, armed: {:?}, \ + panicked: {}): {err}", + p.label, p.armed, p.panicked + ) + }, + ); + let post_state = + sorted_keys(&self.map, |item| (item.key1, item.key2)); assert_post_op_invariants( - i, - &action_label, - armed, - panicked, - panic_safety, - &pre_state, + step, + &p.label, + p.armed, + p.panicked, + p.panic_safety, + &p.pre_state, &post_state, |&(k1, k2)| { - map.contains_key1(&PanickySearchKey(k1)) - && map.contains_key2(&PanickySearchKey(k2)) + self.map.contains_key1(&PanickySearchKey(k1)) + && self.map.contains_key2(&PanickySearchKey(k2)) }, ); + self.step += 1; } } + + #[hegel::test(test_cases = 512)] + fn proptest_panic_ops(tc: TestCase) { + let map: PanickyMap = BiHashMap::with_hasher_in( + iddqd::DefaultHashBuilder::default(), + PanickyAlloc::default(), + ); + hegel::stateful::run(PanicMachine { map, step: 0, pending: None }, tc); + } } diff --git a/crates/iddqd/tests/integration/hegel_support.rs b/crates/iddqd/tests/integration/hegel_support.rs new file mode 100644 index 0000000..57c2720 --- /dev/null +++ b/crates/iddqd/tests/integration/hegel_support.rs @@ -0,0 +1,191 @@ +use hegel::{TestCase, generators as gs}; +use iddqd_test_utils::{naive_map::NaiveMap, test_item::TestItem}; + +/// The maximum code point for key2 characters. +/// +/// We set a fairly low value here to ensure that more collisions happen in the +/// key2 space. +const MAX_KEY2_CODEPOINT: u8 = 0x7f; + +/// Returns a [`TestItem`] with random keys and values. +#[hegel::composite] +pub(crate) fn test_item(tc: TestCase) -> TestItem { + let key1 = draw_random_key1(&tc); + let key2 = draw_random_key2(&tc); + let key3 = draw_random_key3(&tc); + let value = tc.draw(gs::text()); + TestItem::new(key1, key2, key3, value) +} + +/// Draws a batch of non-repeating [`TestItem`]s to fill the map. +pub(crate) fn draw_fill_batch(tc: &TestCase) -> Vec { + // This is written in this style to try and minimize collisions with as few + // random draws as possible. + // + // Generate a random base for the first key. + let start = tc.draw(gs::integers::()); + // Generate a random count of items to fill the map. Bounding the count by + // MAX_KEY2_CODEPOINT keeps key2 inside the 0..=MAX_KEY2_CODEPOINT codepoint + // space that random key2 draws also use. + let count_minus_one = + tc.draw(gs::integers::().max_value(MAX_KEY2_CODEPOINT)); + (0..=count_minus_one) + .map(|i| { + // key1 stays collision-free because `wrapping_add` of the constant + // `start` is injective over this i range (there are at most 128 + // values, while u8 can have 256 different values). + let key1 = start.wrapping_add(i); + // Likewise, key2 is collision-free. + let key2 = char::from(i); + TestItem::new(key1, key2, format!("fill-{i}"), format!("fill-{i}")) + }) + .collect() +} + +/// Draws a batch of fully random [`TestItem`]s. +/// +/// Unlike [`draw_fill_batch`], the keys are drawn at random rather than laid +/// out to avoid collisions. The key1 and key2 spaces are small, so a batch of +/// any appreciable size is likely to contain duplicate keys. This exercises +/// duplicate-key handling, such as serde's rejection of a sequence with +/// repeated keys. +#[cfg(feature = "serde")] +pub(crate) fn draw_random_batch(tc: &TestCase) -> Vec { + tc.draw(gs::vecs(test_item()).max_size(32)) +} + +/// Returns a shuffled copy of the given slice. +pub(crate) fn draw_shuffle(tc: &TestCase, items: &[T]) -> Vec { + let mut out = items.to_vec(); + if out.len() < 2 { + return out; + } + // This is a simple Fisher-Yates shuffle (Durstenfeld variant, low to high). + for i in 0..out.len() - 1 { + let j = tc.draw( + gs::integers::().min_value(i).max_value(out.len() - 1), + ); + out.swap(i, j); + } + out +} + +/// Draws a random key1 value. +/// +/// This is likely to collide with existing key1 values in the map, since the u8 +/// space is small. +pub(crate) fn draw_random_key1(tc: &TestCase) -> u8 { + tc.draw(gs::integers::()) +} + +/// Draws a random key2 value. +/// +/// This is likely to collide with existing key2 values in the map, since +/// `MAX_KEY2_CODEPOINT` is small. +pub(crate) fn draw_random_key2(tc: &TestCase) -> char { + tc.draw(gs::characters().max_codepoint(u32::from(MAX_KEY2_CODEPOINT))) +} + +/// Draws a random key3 string. +/// +/// This is unlikely to match any of the existing key3 strings in the map. +pub(crate) fn draw_random_key3(tc: &TestCase) -> String { + tc.draw(gs::text()) +} + +fn draw_lookup_key1_in(tc: &TestCase, items: &[TestItem]) -> u8 { + if !items.is_empty() && tc.draw(gs::booleans()) { + let idx = tc.draw(gs::integers::().max_value(items.len() - 1)); + items[idx].key1 + } else { + draw_random_key1(tc) + } +} + +fn draw_lookup_key2_in(tc: &TestCase, items: &[TestItem]) -> char { + if !items.is_empty() && tc.draw(gs::booleans()) { + let idx = tc.draw(gs::integers::().max_value(items.len() - 1)); + items[idx].key2 + } else { + draw_random_key2(tc) + } +} + +fn draw_lookup_key3_in(tc: &TestCase, items: &[TestItem]) -> String { + if !items.is_empty() && tc.draw(gs::booleans()) { + let idx = tc.draw(gs::integers::().max_value(items.len() - 1)); + items[idx].key3.clone() + } else { + draw_random_key3(tc) + } +} + +pub(crate) fn draw_lookup_key1(tc: &TestCase, naive: &NaiveMap) -> u8 { + draw_lookup_key1_in(tc, naive.items()) +} + +pub(crate) fn draw_lookup_key2(tc: &TestCase, naive: &NaiveMap) -> char { + draw_lookup_key2_in(tc, naive.items()) +} + +pub(crate) fn draw_lookup_key3(tc: &TestCase, naive: &NaiveMap) -> String { + draw_lookup_key3_in(tc, naive.items()) +} + +pub(crate) fn draw_lookup_keys12( + tc: &TestCase, + naive: &NaiveMap, +) -> (u8, char) { + ( + draw_lookup_key1_in(tc, naive.items()), + draw_lookup_key2_in(tc, naive.items()), + ) +} + +pub(crate) fn draw_lookup_keys123( + tc: &TestCase, + naive: &NaiveMap, +) -> (u8, char, String) { + ( + draw_lookup_key1_in(tc, naive.items()), + draw_lookup_key2_in(tc, naive.items()), + draw_lookup_key3_in(tc, naive.items()), + ) +} + +#[cfg(any( + feature = "std", + all(feature = "default-hasher", feature = "allocator-api2") +))] +pub(crate) const MAX_PANIC_KEY: u32 = 63; + +#[cfg(any( + feature = "std", + all(feature = "default-hasher", feature = "allocator-api2") +))] +pub(crate) fn draw_armed(tc: &TestCase) -> Option { + use iddqd_test_utils::panic_safety::observe_output_path; + + if observe_output_path().is_some() { + // In observation mode, set the countdown to `u32::MAX` to avoid + // panicking. + return Some(u32::MAX); + } + + // hegel has no native way to do weighted choices, so reconstruct a biased + // proptest distribution with a bucket selector: + // + // * Mostly disarmed so the map gets filled. + // * When armed, dense coverage of early panics and sparser coverage of + // the long tail. The longest observed per-op user-call count is just + // over 500 (bulk ops like `retain`/`extend` on a filled map). The 639 + // ceiling leaves some headroom above that. + match tc.draw(gs::integers::().max_value(10)) { + 0..=6 => None, + 7 | 8 => Some(tc.draw(gs::integers::().max_value(15))), + 9 => Some(tc.draw(gs::integers::().min_value(16).max_value(127))), + 10.. => { + Some(tc.draw(gs::integers::().min_value(128).max_value(639))) + } + } +} diff --git a/crates/iddqd/tests/integration/id_hash_map.rs b/crates/iddqd/tests/integration/id_hash_map.rs index 8867154..ab86682 100644 --- a/crates/iddqd/tests/integration/id_hash_map.rs +++ b/crates/iddqd/tests/integration/id_hash_map.rs @@ -1,3 +1,7 @@ +use crate::hegel_support::{ + draw_fill_batch, draw_lookup_key1, draw_shuffle, test_item, +}; +use hegel::{TestCase, generators as gs}; use iddqd::{ IdHashItem, IdHashMap, id_hash_map, id_upcast, internal::ValidateCompact, }; @@ -7,15 +11,12 @@ use iddqd_test_utils::{ naive_map::NaiveMap, test_item::{ Alloc, HashBuilder, ItemMap, TestItem, TestKey1, assert_iter_eq, - test_item_permutation_strategy, }, }; -use proptest::prelude::*; use std::{ borrow::Cow, path::{Path, PathBuf}, }; -use test_strategy::{Arbitrary, proptest}; #[derive(Clone, Debug)] struct SimpleItem { @@ -201,83 +202,30 @@ impl CompactnessChange { } } -#[derive(Debug, Arbitrary)] -enum Operation { - // Make inserts a bit more common to try and fill up the map. - #[weight(4)] - InsertUnique(TestItem), - #[weight(3)] - InsertOverwrite(TestItem), - #[weight(2)] - EntryInsertOverwrite(TestItem), - #[weight(2)] - EntryRemove(u8), - #[weight(2)] - Get(u8), - #[weight(2)] - Remove(u8), - #[weight(2)] - RetainValueContains(char, bool), - #[weight(2)] - RetainModulo(#[strategy(0..3_u8)] u8, #[strategy(1..4_u8)] u8, bool), - #[weight(2)] - Extend( - #[strategy(prop::collection::vec(any::(), 0..16))] - Vec, - ), - Clear, - // `additional` is kept modest so that reservations frequently - // exceed the current `growth_left` and so trigger hashbrown's - // rehash path. - Reserve(#[strategy(0..256_usize)] usize), - TryReserve(#[strategy(0..256_usize)] usize), - ShrinkToFit, - ShrinkTo(#[strategy(0..256_usize)] usize), +struct IdHashMapMachine { + map: IdHashMap, + naive: NaiveMap, + compactness: ValidateCompact, } -impl Operation { - fn compactness_change(&self) -> CompactnessChange { - match self { - Operation::InsertUnique(_) - | Operation::Get(_) - | Operation::Reserve(_) - | Operation::TryReserve(_) => CompactnessChange::NoChange, - // The act of removing items, including calls to insert_overwrite, - // can make the map non-compact. - Operation::InsertOverwrite(_) - | Operation::EntryInsertOverwrite(_) - | Operation::EntryRemove(_) - | Operation::Remove(_) - | Operation::RetainValueContains(_, _) - | Operation::RetainModulo(_, _, _) - | Operation::Extend(_) => CompactnessChange::NoLongerCompact, - // Clear empties the map, so it is de-facto compact. Shrink - // operations fully compact the backing store, restoring the - // `Compact` invariant. - Operation::Clear - | Operation::ShrinkToFit - | Operation::ShrinkTo(_) => CompactnessChange::BecomesCompact, - } +impl IdHashMapMachine { + fn check_valid(&mut self, change: CompactnessChange) { + self.compactness = change.apply(self.compactness); + self.map.validate(self.compactness).expect("map should be valid"); } } -#[proptest(cases = 16)] -fn proptest_ops( - #[strategy(prop::collection::vec(any::(), 0..1024))] ops: Vec< - Operation, - >, -) { - let mut map = IdHashMap::::make_new(); - let mut naive_map = NaiveMap::new_key1(); - - let mut compactness = ValidateCompact::Compact; - - // Now perform the operations on both maps. - for op in ops { - compactness = op.compactness_change().apply(compactness); - - match op { - Operation::InsertUnique(item) => { +mod indent0 { + mod indent1 { + use super::super::*; + + #[hegel::state_machine] + impl IdHashMapMachine { + #[rule] + fn insert_unique(&mut self, tc: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; + let item = tc.draw(test_item()); let map_res = map.insert_unique(item.clone()); let naive_res = naive_map.insert_unique(item.clone()); @@ -288,9 +236,14 @@ fn proptest_ops( assert_eq!(map_err.duplicates(), naive_err.duplicates()); } - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::NoChange); } - Operation::InsertOverwrite(item) => { + + #[rule] + fn insert_overwrite(&mut self, tc: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; + let item = tc.draw(test_item()); let map_dups = map.insert_overwrite(item.clone()); let mut naive_dups = naive_map.insert_overwrite(item.clone()); assert!(naive_dups.len() <= 1, "max one conflict"); @@ -300,9 +253,14 @@ fn proptest_ops( map_dups, naive_dup, "map and naive map should agree on insert_overwrite dup" ); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::EntryInsertOverwrite(item) => { + + #[rule] + fn entry_insert_overwrite(&mut self, tc: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; + let item = tc.draw(test_item()); let map_res = match map.entry(item.key()) { id_hash_map::Entry::Occupied(mut entry) => { Some(entry.insert(item.clone())) @@ -321,9 +279,14 @@ fn proptest_ops( map_res, naive_res, "map and naive map should agree on Entry::insert" ); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::EntryRemove(key) => { + + #[rule] + fn entry_remove(&mut self, tc: TestCase) { + let key = draw_lookup_key1(&tc, &self.naive); + let map = &mut self.map; + let naive_map = &mut self.naive; let map_res = match map.entry(TestKey1::new(&key)) { id_hash_map::Entry::Occupied(entry) => Some(entry.remove()), id_hash_map::Entry::Vacant(_) => None, @@ -335,23 +298,38 @@ fn proptest_ops( map_res, naive_res, "map and naive map should agree on Entry::remove" ); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::Get(key) => { + #[rule] + fn get(&mut self, tc: TestCase) { + let key = draw_lookup_key1(&tc, &self.naive); + let map = &mut self.map; + let naive_map = &mut self.naive; let map_res = map.get(&TestKey1::new(&key)); let naive_res = naive_map.get1(key); assert_eq!(map_res, naive_res); } - Operation::Remove(key) => { + + #[rule] + fn remove(&mut self, tc: TestCase) { + let key = draw_lookup_key1(&tc, &self.naive); + let map = &mut self.map; + let naive_map = &mut self.naive; let map_res = map.remove(&TestKey1::new(&key)); let naive_res = naive_map.remove1(key); assert_eq!(map_res, naive_res); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::RetainValueContains(ch, equals) => { + + #[rule] + fn retain_value_contains(&mut self, tc: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; + let ch = tc.draw(gs::characters()); + let equals = tc.draw(gs::booleans()); map.retain(|item| { let contains = item.value.contains(ch); if equals { contains } else { !contains } @@ -360,9 +338,16 @@ fn proptest_ops( let contains = item.value.contains(ch); if equals { contains } else { !contains } }); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::RetainModulo(a, b, equals) => { + + #[rule] + fn retain_modulo(&mut self, tc: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; + let a = tc.draw(gs::integers::().max_value(2)); + let b = tc.draw(gs::integers::().min_value(1).max_value(3)); + let equals = tc.draw(gs::booleans()); let modulo = a + b; let remainder = a; map.retain(|item| { @@ -373,69 +358,114 @@ fn proptest_ops( let matches = item.key1 % modulo == remainder; if equals { matches } else { !matches } }); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::Extend(items) => { + + #[rule] + fn extend(&mut self, tc: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; + let items = tc.draw(gs::vecs(test_item()).max_size(15)); map.extend(items.clone()); naive_map.extend(items); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::Clear => { + + // Fill up the map to ensure later operations use a larger map. + #[rule] + fn fill(&mut self, tc: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; + let items = draw_fill_batch(&tc); + map.extend(items.clone()); + naive_map.extend(items); + self.check_valid(CompactnessChange::NoLongerCompact); + } + + #[rule] + fn clear(&mut self, _: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; map.clear(); naive_map.clear(); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::BecomesCompact); } - Operation::Reserve(additional) => { + + #[rule] + fn reserve(&mut self, tc: TestCase) { + let map = &mut self.map; + let additional = + tc.draw(gs::integers::().max_value(255)); map.reserve(additional); - // `reserve` has no observable effect beyond capacity; the - // naive map has no equivalent. `validate` is the real - // check — it iterates items and asks `find_index` for - // each, which catches a hash-table left mis-bucketed by - // a regrowth rehash. - map.validate(compactness).expect("map should be valid"); + // `reserve` has no observable effect beyond capacity -- the + // naive map has no equivalent. `check_valid` will iterate items + // and ask `find_index` for each, which catches a hash-table + // left mis-bucketed by a regrowth rehash. + self.check_valid(CompactnessChange::NoChange); } - Operation::TryReserve(additional) => { - // Mirror `Reserve`; we don't assert `Ok` because the - // allocator could (legitimately) refuse a large request, - // and bailing on that would mask the actual regression - // we care about (silent hash-table corruption). + + #[rule] + fn try_reserve(&mut self, tc: TestCase) { + let map = &mut self.map; + let additional = + tc.draw(gs::integers::().max_value(255)); let _ = map.try_reserve(additional); - map.validate(compactness).expect("map should be valid"); + // See the comment on `reserve` above for why this is only + // `check_valid`. + self.check_valid(CompactnessChange::NoChange); } - Operation::ShrinkToFit => { + + #[rule] + fn shrink_to_fit(&mut self, _: TestCase) { + let map = &mut self.map; map.shrink_to_fit(); - // The naive map has no shrink operation. - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::BecomesCompact); } - Operation::ShrinkTo(min_capacity) => { + + #[rule] + fn shrink_to(&mut self, tc: TestCase) { + let map = &mut self.map; + let min_capacity = + tc.draw(gs::integers::().max_value(255)); map.shrink_to(min_capacity); - // The naive map has no shrink operation. - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::BecomesCompact); } - } - // Check that the iterators work correctly. - let mut naive_items = naive_map.iter().collect::>(); - naive_items.sort_by(|a, b| a.key().cmp(&b.key())); - - assert_iter_eq(map.clone(), naive_items); + #[invariant] + fn iter_matches(&mut self, _: TestCase) { + let map = &self.map; + let naive_map = &self.naive; + let mut naive_items = naive_map.iter().collect::>(); + naive_items.sort_by(|a, b| a.key().cmp(&b.key())); + assert_iter_eq(map.clone(), naive_items); + } + } } } -#[proptest(cases = 64)] -fn proptest_permutation_eq( - #[strategy(test_item_permutation_strategy::>(0..256))] - items: (Vec, Vec), -) { - let (items1, items2) = items; +#[hegel::test(test_cases = 512)] +fn proptest_ops(tc: TestCase) { + let machine = IdHashMapMachine { + map: IdHashMap::::make_new(), + naive: NaiveMap::new_key1(), + compactness: ValidateCompact::Compact, + }; + hegel::stateful::run(machine, tc); +} + +#[hegel::test(test_cases = 64)] +fn proptest_permutation_eq(tc: TestCase) { + // draw_fill_batch generates unique keys so there's no need to deduplicate. + let set = draw_fill_batch(&tc); + let set2 = draw_shuffle(&tc, &set); + let mut map1 = IdHashMap::::make_new(); let mut map2 = IdHashMap::::make_new(); - - for item in items1.clone() { - map1.insert_unique(item.clone()).unwrap(); + for item in set { + map1.insert_unique(item).expect("set is deduplicated"); } - for item in items2.clone() { - map2.insert_unique(item.clone()).unwrap(); + for item in set2 { + map2.insert_unique(item).expect("set is deduplicated"); } assert_eq_props(&map1, &map2); @@ -896,6 +926,9 @@ mod macro_tests { } } +#[cfg(feature = "proptest")] +use test_strategy::proptest; + #[cfg(feature = "proptest")] #[proptest(cases = 16)] fn proptest_arbitrary_map(map: IdHashMap) { @@ -918,15 +951,17 @@ fn proptest_arbitrary_map(map: IdHashMap) { #[cfg(feature = "serde")] mod serde_tests { + use crate::hegel_support::draw_random_batch; + use hegel::TestCase; use iddqd::IdHashMap; use iddqd_test_utils::{ serde_utils::assert_serialize_roundtrip, test_item::{Alloc, HashBuilder, TestItem}, }; - use test_strategy::proptest; - #[proptest] - fn proptest_serialize_roundtrip(values: Vec) { + #[hegel::test(test_cases = 256)] + fn proptest_serialize_roundtrip(tc: TestCase) { + let values = draw_random_batch(&tc); assert_serialize_roundtrip::>( values, ); @@ -961,170 +996,256 @@ impl Drop for PanickyHashItem { #[cfg(all(feature = "default-hasher", feature = "allocator-api2"))] mod proptest_panic_safety { use super::*; + use crate::hegel_support::{MAX_PANIC_KEY, draw_armed}; use allocator_api2::alloc::Global; use iddqd_test_utils::panic_safety::{ - PANIC_PROPTEST_CASES, PANIC_PROPTEST_MAX_OPS, PanicSafety, - PanickyAlloc, PanickyKey, PanickyOp, PanickySearchKey, + PanicSafety, PanickyAlloc, PanickyKey, PanickySearchKey, assert_panic_fired_as_expected, assert_post_op_invariants, drop_unarmed, record_observation, run_armed, sorted_keys, }; - /// Map type used by these tests. type PanickyMap = IdHashMap< PanickyHashItem, iddqd::DefaultHashBuilder, PanickyAlloc, >; - // Keys are kept in a small range so collisions, hits, and misses - // all happen frequently against a 16-ish-element map. - #[derive(Debug, Arbitrary)] - enum PanickyAction { - #[weight(4)] - InsertUnique(#[strategy(0..32_u32)] u32), - #[weight(3)] - InsertOverwrite(#[strategy(0..32_u32)] u32), - #[weight(3)] - EntryInsertOverwrite(#[strategy(0..32_u32)] u32), - #[weight(2)] - EntryRemove(#[strategy(0..32_u32)] u32), - #[weight(2)] - Remove(#[strategy(0..32_u32)] u32), - #[weight(2)] - Get(#[strategy(0..32_u32)] u32), - #[weight(1)] - ContainsKey(#[strategy(0..32_u32)] u32), - #[weight(2)] - RetainModulo( - #[strategy(0..3_u32)] u32, - #[strategy(1..4_u32)] u32, - bool, - ), - #[weight(2)] - Extend(#[strategy(prop::collection::vec(0..32_u32, 0..8))] Vec), - Clear, - ShrinkToFit, - ShrinkTo(#[strategy(0..32_usize)] usize), + struct PanicMachine { + map: PanickyMap, + step: usize, + pending: Option, } - impl PanickyAction { - /// Classify panic safety for this action. - /// - /// * `RetainModulo` and `Clear` loop over per-step atomic item - /// destruction. - /// * `Extend` is a sequence of per-step atomic `insert_overwrite` - /// calls; a mid-sequence panic leaves earlier inserts committed. - fn panic_safety(&self) -> PanicSafety { - match self { - PanickyAction::InsertUnique(_) - | PanickyAction::InsertOverwrite(_) - | PanickyAction::EntryInsertOverwrite(_) - | PanickyAction::EntryRemove(_) - | PanickyAction::Remove(_) - | PanickyAction::Get(_) - | PanickyAction::ContainsKey(_) - | PanickyAction::ShrinkToFit - | PanickyAction::ShrinkTo(_) => PanicSafety::Atomic, - PanickyAction::RetainModulo(_, _, _) - | PanickyAction::Extend(_) - | PanickyAction::Clear => PanicSafety::StepAtomic, - } + struct Pending { + label: &'static str, + panic_safety: PanicSafety, + armed: Option, + panicked: bool, + pre_state: Vec, + } + + impl PanicMachine { + fn armed_op( + &mut self, + tc: &TestCase, + label: &'static str, + panic_safety: PanicSafety, + op: impl FnOnce(&mut PanickyMap), + ) { + // hegel runs the `#[invariant]` (which consumes `pending`) after + // every successful rule, so `pending` must be `None` here -- if + // not, a prior op's post-op checks were silently skipped. + assert!( + self.pending.is_none(), + "previous op's post-op invariant did not run before this op", + ); + let armed = draw_armed(tc); + let pre_state = sorted_keys(&self.map, |item| item.key); + let (panicked, ops) = run_armed(armed, || op(&mut self.map)); + record_observation("id_hash_map", label, ops); + assert_panic_fired_as_expected(&label, armed, panicked, ops); + + // `self.pending` is set at the end of this function, after all + // fallible draws. + self.pending = Some(Pending { + label, + panic_safety, + armed, + panicked, + pre_state, + }); } + } - fn run(self, map: &mut PanickyMap) { - match self { - PanickyAction::InsertUnique(key) => { - drop_unarmed(map.insert_unique(PanickyHashItem { key })); - } - PanickyAction::InsertOverwrite(key) => { + #[hegel::state_machine] + impl PanicMachine { + #[rule] + fn insert_unique(&mut self, tc: TestCase) { + let key = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + self.armed_op(&tc, "insert_unique", PanicSafety::Atomic, |map| { + drop_unarmed(map.insert_unique(PanickyHashItem { key })); + }); + } + + #[rule] + fn insert_overwrite(&mut self, tc: TestCase) { + let key = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + self.armed_op( + &tc, + "insert_overwrite", + PanicSafety::Atomic, + |map| { drop_unarmed(map.insert_overwrite(PanickyHashItem { key })); - } - PanickyAction::EntryInsertOverwrite(key) => { - let entry = map.entry(PanickyKey(key)); + }, + ); + } + #[rule] + fn entry_insert_overwrite(&mut self, tc: TestCase) { + let key = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + self.armed_op( + &tc, + "entry_insert_overwrite", + PanicSafety::Atomic, + |map| { + let entry = map.entry(PanickyKey(key)); if let id_hash_map::Entry::Occupied(mut entry) = entry { drop_unarmed(entry.insert(PanickyHashItem { key })); } - } - PanickyAction::EntryRemove(key) => { - let entry = map.entry(PanickyKey(key)); + }, + ); + } - if let id_hash_map::Entry::Occupied(entry) = entry { - drop_unarmed(entry.remove()); - } - } - PanickyAction::Remove(key) => { - drop_unarmed(map.remove(&PanickySearchKey(key))); - } - PanickyAction::Get(key) => { - let _ = map.get(&PanickySearchKey(key)); + #[rule] + fn entry_remove(&mut self, tc: TestCase) { + let key = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + self.armed_op(&tc, "entry_remove", PanicSafety::Atomic, |map| { + let entry = map.entry(PanickyKey(key)); + if let id_hash_map::Entry::Occupied(entry) = entry { + drop_unarmed(entry.remove()); } - PanickyAction::ContainsKey(key) => { - let _ = map.contains_key(&PanickySearchKey(key)); - } - PanickyAction::RetainModulo(rem, modulo, keep) => { + }); + } + + #[rule] + fn remove(&mut self, tc: TestCase) { + let key = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + self.armed_op(&tc, "remove", PanicSafety::Atomic, |map| { + drop_unarmed(map.remove(&PanickySearchKey(key))); + }); + } + + #[rule] + fn get(&mut self, tc: TestCase) { + let key = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + self.armed_op(&tc, "get", PanicSafety::Atomic, |map| { + let _ = map.get(&PanickySearchKey(key)); + }); + } + + #[rule] + fn contains_key(&mut self, tc: TestCase) { + let key = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + self.armed_op(&tc, "contains_key", PanicSafety::Atomic, |map| { + let _ = map.contains_key(&PanickySearchKey(key)); + }); + } + + #[rule] + fn retain_modulo(&mut self, tc: TestCase) { + let rem = tc.draw(gs::integers::().max_value(2)); + let modulo = + tc.draw(gs::integers::().min_value(1).max_value(3)); + let keep = tc.draw(gs::booleans()); + self.armed_op( + &tc, + "retain_modulo", + // `retain_modulo` loops over per-step atomic operations. + PanicSafety::StepAtomic, + |map| { map.retain(|item| { let matches = item.key % modulo == rem; if keep { matches } else { !matches } }); - } - PanickyAction::Extend(keys) => { - map.extend( - keys.into_iter().map(|key| PanickyHashItem { key }), - ); - } - PanickyAction::Clear => map.clear(), - PanickyAction::ShrinkToFit => map.shrink_to_fit(), - PanickyAction::ShrinkTo(min_capacity) => { - map.shrink_to(min_capacity); - } + }, + ); + } + + #[rule] + fn extend(&mut self, tc: TestCase) { + let keys = tc.draw( + gs::vecs(gs::integers::().max_value(MAX_PANIC_KEY)) + .max_size(7), + ); + // `extend` does per-step atomic operations. + self.armed_op(&tc, "extend", PanicSafety::StepAtomic, |map| { + map.extend(keys.into_iter().map(|key| PanickyHashItem { key })); + }); + } + + #[rule] + fn fill(&mut self, tc: TestCase) { + let keys = tc.draw( + gs::vecs(gs::integers::().max_value(MAX_PANIC_KEY)) + .max_size(64), + ); + for key in keys { + let _ = self.map.insert_unique(PanickyHashItem { key }); } } - } - #[proptest(cases = PANIC_PROPTEST_CASES)] - fn proptest_panic_ops( - #[strategy(prop::collection::vec( - any::>(), 0..PANIC_PROPTEST_MAX_OPS, - ))] - ops: Vec>, - ) { - let mut map: PanickyMap = IdHashMap::with_hasher_in( - iddqd::DefaultHashBuilder::default(), - PanickyAlloc::default(), - ); + #[rule] + fn clear(&mut self, tc: TestCase) { + self.armed_op( + &tc, + "clear", + // `clear` does per-table atomic operations. + PanicSafety::StepAtomic, + |map| { + map.clear(); + }, + ); + } + + #[rule] + fn shrink_to_fit(&mut self, tc: TestCase) { + self.armed_op(&tc, "shrink_to_fit", PanicSafety::Atomic, |map| { + map.shrink_to_fit(); + }); + } - for (i, op) in ops.into_iter().enumerate() { - let action = op.action; - let action_label = format!("{action:?}"); - let panic_safety = action.panic_safety(); - let armed = op.armed; - - let pre_state = sorted_keys(&map, |item| item.key); - let (panicked, ops) = run_armed(armed, || action.run(&mut map)); - record_observation("id_hash_map", &action_label, ops); - assert_panic_fired_as_expected(&action_label, armed, panicked, ops); - - // `NonCompact` since step-atomic panics leave compactness - // in an indeterminate state. - map.validate(ValidateCompact::NonCompact).unwrap_or_else(|err| { - panic!( - "map invalid after op {i} ({action_label}, \ - armed: {armed:?}, panicked: {panicked}): {err}" - ) + #[rule] + fn shrink_to(&mut self, tc: TestCase) { + let min_capacity = tc.draw( + gs::integers::().max_value(MAX_PANIC_KEY as usize), + ); + self.armed_op(&tc, "shrink_to", PanicSafety::Atomic, |map| { + map.shrink_to(min_capacity); }); + } - let post_state = sorted_keys(&map, |item| item.key); + #[invariant] + fn check_post_op(&mut self, _: TestCase) { + let Some(p) = self.pending.take() else { + self.map + .validate(ValidateCompact::NonCompact) + .expect("map should be valid"); + return; + }; + let step = self.step; + + // `NonCompact` since step-atomic panics can leave compactness in an + // indeterminate state. + self.map.validate(ValidateCompact::NonCompact).unwrap_or_else( + |err| { + panic!( + "map invalid after op {step} ({}, armed: {:?}, \ + panicked: {}): {err}", + p.label, p.armed, p.panicked + ) + }, + ); + let post_state = sorted_keys(&self.map, |item| item.key); assert_post_op_invariants( - i, - &action_label, - armed, - panicked, - panic_safety, - &pre_state, + step, + &p.label, + p.armed, + p.panicked, + p.panic_safety, + &p.pre_state, &post_state, - |&k| map.contains_key(&PanickySearchKey(k)), + |&k| self.map.contains_key(&PanickySearchKey(k)), ); + self.step += 1; } } + + #[hegel::test(test_cases = 512)] + fn proptest_panic_ops(tc: TestCase) { + let map: PanickyMap = IdHashMap::with_hasher_in( + iddqd::DefaultHashBuilder::default(), + PanickyAlloc::default(), + ); + hegel::stateful::run(PanicMachine { map, step: 0, pending: None }, tc); + } } diff --git a/crates/iddqd/tests/integration/id_ord_map.rs b/crates/iddqd/tests/integration/id_ord_map.rs index e6a0b79..1ceafd1 100644 --- a/crates/iddqd/tests/integration/id_ord_map.rs +++ b/crates/iddqd/tests/integration/id_ord_map.rs @@ -1,3 +1,7 @@ +use crate::hegel_support::{ + draw_fill_batch, draw_lookup_key1, draw_shuffle, test_item, +}; +use hegel::{TestCase, generators as gs}; use iddqd::{ IdOrdItem, IdOrdMap, id_ord_map, id_upcast, internal::{ValidateChaos, ValidateCompact}, @@ -8,16 +12,14 @@ use iddqd_test_utils::{ naive_map::NaiveMap, test_item::{ ChaosEq, ChaosOrd, ItemMap, KeyChaos, TestItem, TestKey1, - assert_iter_eq, test_item_permutation_strategy, without_chaos, + assert_iter_eq, without_chaos, }, unwind::catch_panic, }; -use proptest::prelude::*; use std::{ borrow::Cow, path::{Path, PathBuf}, }; -use test_strategy::{Arbitrary, proptest}; #[test] fn with_capacity() { @@ -237,95 +239,32 @@ impl CompactnessChange { } } -#[derive(Debug, Arbitrary)] -enum Operation { - // Make inserts a bit more common to try and fill up the map. - #[weight(6)] - InsertUnique(TestItem), - #[weight(4)] - InsertOverwrite(TestItem), - #[weight(2)] - EntryInsertOverwrite(TestItem), - #[weight(2)] - EntryRemove(u8), - #[weight(2)] - Get(u8), - #[weight(2)] - Remove(u8), - #[weight(2)] - First, - #[weight(2)] - Last, - #[weight(2)] - PopFirst, - #[weight(2)] - PopLast, - #[weight(2)] - FirstEntryModify(String), - #[weight(2)] - LastEntryModify(String), - #[weight(2)] - RetainValueContains(char, bool), - #[weight(2)] - RetainModulo(#[strategy(0..3_u8)] u8, #[strategy(1..4_u8)] u8, bool), - #[weight(2)] - Extend( - #[strategy(prop::collection::vec(any::(), 0..16))] - Vec, - ), - // clear is set to a lower weight since it makes the map empty. - Clear, - ShrinkToFit, - ShrinkTo(#[strategy(0..256_usize)] usize), +struct IdOrdMapMachine { + map: IdOrdMap, + naive: NaiveMap, + compactness: ValidateCompact, } -impl Operation { - fn compactness_change(&self) -> CompactnessChange { - match self { - Operation::InsertUnique(_) - | Operation::Get(_) - | Operation::First - | Operation::Last - | Operation::FirstEntryModify(_) - | Operation::LastEntryModify(_) => CompactnessChange::NoChange, - // The act of removing items, including calls to insert_overwrite, - // can make the map non-compact. - Operation::InsertOverwrite(_) - | Operation::EntryInsertOverwrite(_) - | Operation::EntryRemove(_) - | Operation::Remove(_) - | Operation::PopFirst - | Operation::PopLast - | Operation::RetainValueContains(_, _) - | Operation::RetainModulo(_, _, _) - | Operation::Extend(_) => CompactnessChange::NoLongerCompact, - // Clear always makes the map compact (empty). Shrink - // fully compacts the backing store, restoring the - // `Compact` invariant. - Operation::Clear - | Operation::ShrinkToFit - | Operation::ShrinkTo(_) => CompactnessChange::BecomesCompact, - } +impl IdOrdMapMachine { + fn check_valid(&mut self, change: CompactnessChange) { + self.compactness = change.apply(self.compactness); + self.map + .validate(self.compactness, ValidateChaos::No) + .expect("map should be valid"); } } -#[proptest(cases = 16)] -fn proptest_ops( - #[strategy(prop::collection::vec(any::(), 0..1024))] ops: Vec< - Operation, - >, -) { - let mut map = IdOrdMap::::make_new(); - let mut naive_map = NaiveMap::new_key1(); - - let mut compactness = ValidateCompact::Compact; - - // Now perform the operations on both maps. - for op in ops { - compactness = op.compactness_change().apply(compactness); - - match op { - Operation::InsertUnique(item) => { +mod indent0 { + mod indent1 { + use super::super::*; + + #[hegel::state_machine] + impl IdOrdMapMachine { + #[rule] + fn insert_unique(&mut self, tc: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; + let item = tc.draw(test_item()); let map_res = map.insert_unique(item.clone()); let naive_res = naive_map.insert_unique(item.clone()); @@ -336,10 +275,14 @@ fn proptest_ops( assert_eq!(map_err.duplicates(), naive_err.duplicates()); } - map.validate(compactness, ValidateChaos::No) - .expect("map should be valid"); + self.check_valid(CompactnessChange::NoChange); } - Operation::InsertOverwrite(item) => { + + #[rule] + fn insert_overwrite(&mut self, tc: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; + let item = tc.draw(test_item()); let map_dups = map.insert_overwrite(item.clone()); let mut naive_dups = naive_map.insert_overwrite(item.clone()); assert!(naive_dups.len() <= 1, "max one conflict"); @@ -349,10 +292,14 @@ fn proptest_ops( map_dups, naive_dup, "map and naive map should agree on insert_overwrite dup" ); - map.validate(compactness, ValidateChaos::No) - .expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::EntryInsertOverwrite(item) => { + + #[rule] + fn entry_insert_overwrite(&mut self, tc: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; + let item = tc.draw(test_item()); let map_res = match map.entry(item.key()) { id_ord_map::Entry::Occupied(mut entry) => { Some(entry.insert(item.clone())) @@ -371,10 +318,14 @@ fn proptest_ops( map_res, naive_res, "map and naive map should agree on Entry::insert" ); - map.validate(compactness, ValidateChaos::No) - .expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::EntryRemove(key) => { + + #[rule] + fn entry_remove(&mut self, tc: TestCase) { + let key = draw_lookup_key1(&tc, &self.naive); + let map = &mut self.map; + let naive_map = &mut self.naive; let map_res = match map.entry(TestKey1::new(&key)) { id_ord_map::Entry::Occupied(entry) => Some(entry.remove()), id_ord_map::Entry::Vacant(_) => None, @@ -386,53 +337,79 @@ fn proptest_ops( map_res, naive_res, "map and naive map should agree on Entry::remove" ); - map.validate(compactness, ValidateChaos::No) - .expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::Get(key) => { + #[rule] + fn get(&mut self, tc: TestCase) { + let key = draw_lookup_key1(&tc, &self.naive); + let map = &mut self.map; + let naive_map = &mut self.naive; let map_res = map.get(&TestKey1::new(&key)); let naive_res = naive_map.get1(key); assert_eq!(map_res, naive_res); } - Operation::Remove(key) => { + + #[rule] + fn remove(&mut self, tc: TestCase) { + let key = draw_lookup_key1(&tc, &self.naive); + let map = &mut self.map; + let naive_map = &mut self.naive; let map_res = map.remove(&TestKey1::new(&key)); let naive_res = naive_map.remove1(key); assert_eq!(map_res, naive_res); - map.validate(compactness, ValidateChaos::No) - .expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::First => { + + #[rule] + fn first(&mut self, _: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; let map_res = map.first(); let naive_res = naive_map.first(); assert_eq!(map_res, naive_res); } - Operation::Last => { + + #[rule] + fn last(&mut self, _: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; let map_res = map.last(); let naive_res = naive_map.last(); assert_eq!(map_res, naive_res); } - Operation::PopFirst => { + + #[rule] + fn pop_first(&mut self, _: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; let map_res = map.pop_first(); let naive_res = naive_map.pop_first(); assert_eq!(map_res, naive_res); - map.validate(compactness, ValidateChaos::No) - .expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::PopLast => { + + #[rule] + fn pop_last(&mut self, _: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; let map_res = map.pop_last(); let naive_res = naive_map.pop_last(); assert_eq!(map_res, naive_res); - map.validate(compactness, ValidateChaos::No) - .expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::FirstEntryModify(new_value) => { + + #[rule] + fn first_entry_modify(&mut self, tc: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; + let new_value = tc.draw(gs::text()); match (map.first_entry(), naive_map.first_mut()) { (Some(mut entry), Some(item)) => { let key1 = entry.get().key1; @@ -452,10 +429,14 @@ fn proptest_ops( ); } } - map.validate(compactness, ValidateChaos::No) - .expect("map should be valid"); + self.check_valid(CompactnessChange::NoChange); } - Operation::LastEntryModify(new_value) => { + + #[rule] + fn last_entry_modify(&mut self, tc: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; + let new_value = tc.draw(gs::text()); match (map.last_entry(), naive_map.last_mut()) { (Some(mut entry), Some(item)) => { let key1 = entry.get().key1; @@ -475,10 +456,15 @@ fn proptest_ops( ); } } - map.validate(compactness, ValidateChaos::No) - .expect("map should be valid"); + self.check_valid(CompactnessChange::NoChange); } - Operation::RetainValueContains(ch, equals) => { + + #[rule] + fn retain_value_contains(&mut self, tc: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; + let ch = tc.draw(gs::characters()); + let equals = tc.draw(gs::booleans()); map.retain(|item| { let contains = item.value.contains(ch); if equals { contains } else { !contains } @@ -487,10 +473,16 @@ fn proptest_ops( let contains = item.value.contains(ch); if equals { contains } else { !contains } }); - map.validate(compactness, ValidateChaos::No) - .expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::RetainModulo(a, b, equals) => { + + #[rule] + fn retain_modulo(&mut self, tc: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; + let a = tc.draw(gs::integers::().max_value(2)); + let b = tc.draw(gs::integers::().min_value(1).max_value(3)); + let equals = tc.draw(gs::booleans()); let modulo = a + b; let remainder = a; map.retain(|item| { @@ -501,62 +493,96 @@ fn proptest_ops( let matches = item.key1 % modulo == remainder; if equals { matches } else { !matches } }); - map.validate(compactness, ValidateChaos::No) - .expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::Extend(items) => { + + #[rule] + fn extend(&mut self, tc: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; + let items = tc.draw(gs::vecs(test_item()).max_size(15)); map.extend(items.clone()); naive_map.extend(items); - map.validate(compactness, ValidateChaos::No) - .expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); + } + + // Fill up the map to ensure later operations use a larger map. + #[rule] + fn fill(&mut self, tc: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; + let items = draw_fill_batch(&tc); + map.extend(items.clone()); + naive_map.extend(items); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::Clear => { + + #[rule] + fn clear(&mut self, _: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; map.clear(); naive_map.clear(); - map.validate(compactness, ValidateChaos::No) - .expect("map should be valid"); + self.check_valid(CompactnessChange::BecomesCompact); } - Operation::ShrinkToFit => { + + #[rule] + fn shrink_to_fit(&mut self, _: TestCase) { + let map = &mut self.map; map.shrink_to_fit(); - map.validate(compactness, ValidateChaos::No) - .expect("map should be valid"); + self.check_valid(CompactnessChange::BecomesCompact); } - Operation::ShrinkTo(min_capacity) => { + + #[rule] + fn shrink_to(&mut self, tc: TestCase) { + let map = &mut self.map; + let min_capacity = + tc.draw(gs::integers::().max_value(255)); map.shrink_to(min_capacity); - map.validate(compactness, ValidateChaos::No) - .expect("map should be valid"); + self.check_valid(CompactnessChange::BecomesCompact); } - } - // Check that the iterators work correctly. - let mut naive_items = naive_map.iter().collect::>(); - naive_items.sort_by(|a, b| a.key().cmp(&b.key())); - - assert_iter_eq(map.clone(), naive_items); + #[invariant] + fn iter_matches(&mut self, _: TestCase) { + let map = &self.map; + let naive_map = &self.naive; + let mut naive_items = naive_map.iter().collect::>(); + naive_items.sort_by(|a, b| a.key().cmp(&b.key())); + assert_iter_eq(map.clone(), naive_items); + } + } } } -#[proptest(cases = 64)] -fn proptest_permutation_eq( - #[strategy(test_item_permutation_strategy::>(0..256))] - items: (Vec, Vec), -) { - let (items1, items2) = items; +#[hegel::test(test_cases = 512)] +fn proptest_ops(tc: TestCase) { + let machine = IdOrdMapMachine { + map: IdOrdMap::::make_new(), + naive: NaiveMap::new_key1(), + compactness: ValidateCompact::Compact, + }; + hegel::stateful::run(machine, tc); +} + +#[hegel::test(test_cases = 64)] +fn proptest_permutation_eq(tc: TestCase) { + // draw_fill_batch generates unique keys so there's no need to deduplicate. + let set = draw_fill_batch(&tc); + let set2 = draw_shuffle(&tc, &set); + let mut map1 = IdOrdMap::::make_new(); let mut map2 = IdOrdMap::::make_new(); - - for item in items1.clone() { - map1.insert_unique(item.clone()).unwrap(); + for item in set.clone() { + map1.insert_unique(item).expect("set is deduplicated"); } - for item in items2.clone() { - map2.insert_unique(item.clone()).unwrap(); + for item in set2.clone() { + map2.insert_unique(item).expect("set is deduplicated"); } assert_eq_props(&map1, &map2); - // Also test from_iter_unique. - let map3 = IdOrdMap::from_iter_unique(items1).unwrap(); - let map4 = IdOrdMap::from_iter_unique(items2).unwrap(); + let map3 = IdOrdMap::from_iter_unique(set).unwrap(); + let map4 = IdOrdMap::from_iter_unique(set2).unwrap(); assert_eq_props(&map1, &map3); assert_eq_props(&map3, &map4); } @@ -1101,18 +1127,23 @@ mod macro_tests { #[cfg(feature = "serde")] mod serde_tests { + use crate::hegel_support::draw_random_batch; + use hegel::TestCase; use iddqd::IdOrdMap; use iddqd_test_utils::{ serde_utils::assert_serialize_roundtrip, test_item::TestItem, }; - use test_strategy::proptest; - #[proptest] - fn proptest_serialize_roundtrip(values: Vec) { + #[hegel::test(test_cases = 256)] + fn proptest_serialize_roundtrip(tc: TestCase) { + let values = draw_random_batch(&tc); assert_serialize_roundtrip::>(values); } } +#[cfg(feature = "proptest")] +use test_strategy::proptest; + #[cfg(feature = "proptest")] #[proptest(cases = 16)] fn proptest_arbitrary_map(map: IdOrdMap) { @@ -1158,164 +1189,243 @@ impl Drop for PanickyOrdItem { mod proptest_panic_safety { use super::*; + use crate::hegel_support::{MAX_PANIC_KEY, draw_armed}; use iddqd_test_utils::panic_safety::{ - PANIC_PROPTEST_CASES, PANIC_PROPTEST_MAX_OPS, PanicSafety, PanickyKey, - PanickyOp, PanickySearchKey, assert_panic_fired_as_expected, - assert_post_op_invariants, drop_unarmed, record_observation, run_armed, - sorted_keys, + PanicSafety, PanickyKey, PanickySearchKey, + assert_panic_fired_as_expected, assert_post_op_invariants, + drop_unarmed, record_observation, run_armed, sorted_keys, }; - // Keys are kept in a small range so hits and misses both happen - // frequently against a 16-ish-element map. - #[derive(Debug, Arbitrary)] - enum PanickyAction { - #[weight(4)] - InsertUnique(#[strategy(0..32_u32)] u32), - #[weight(3)] - InsertOverwrite(#[strategy(0..32_u32)] u32), - #[weight(3)] - EntryInsertOverwrite(#[strategy(0..32_u32)] u32), - #[weight(2)] - EntryRemove(#[strategy(0..32_u32)] u32), - #[weight(2)] - Remove(#[strategy(0..32_u32)] u32), - #[weight(2)] - Get(#[strategy(0..32_u32)] u32), - #[weight(1)] - ContainsKey(#[strategy(0..32_u32)] u32), - #[weight(1)] - PopFirst, - #[weight(1)] - PopLast, - #[weight(2)] - RetainModulo( - #[strategy(0..3_u32)] u32, - #[strategy(1..4_u32)] u32, - bool, - ), - #[weight(2)] - Extend(#[strategy(prop::collection::vec(0..32_u32, 0..8))] Vec), - Clear, + struct PanicMachine { + map: IdOrdMap, + step: usize, + pending: Option, } - impl PanickyAction { - /// Classify panic safety for this action. - /// - /// * `RetainModulo` and `Clear` loop over per-step atomic item - /// destruction. - /// * `Extend` is a sequence of per-step atomic `insert_overwrite` - /// calls; a mid-sequence panic leaves earlier inserts committed. - fn panic_safety(&self) -> PanicSafety { - match self { - PanickyAction::InsertUnique(_) - | PanickyAction::InsertOverwrite(_) - | PanickyAction::EntryInsertOverwrite(_) - | PanickyAction::EntryRemove(_) - | PanickyAction::Remove(_) - | PanickyAction::Get(_) - | PanickyAction::ContainsKey(_) - | PanickyAction::PopFirst - | PanickyAction::PopLast => PanicSafety::Atomic, - PanickyAction::RetainModulo(_, _, _) - | PanickyAction::Extend(_) - | PanickyAction::Clear => PanicSafety::StepAtomic, - } + struct Pending { + label: &'static str, + panic_safety: PanicSafety, + armed: Option, + panicked: bool, + pre_state: Vec, + } + + impl PanicMachine { + fn armed_op( + &mut self, + tc: &TestCase, + label: &'static str, + panic_safety: PanicSafety, + op: impl FnOnce(&mut IdOrdMap), + ) { + // hegel runs the `#[invariant]` (which consumes `pending`) after + // every successful rule, so `pending` must be `None` here -- if + // not, a prior op's post-op checks were silently skipped. + assert!( + self.pending.is_none(), + "previous op's post-op invariant did not run before this op", + ); + let armed = draw_armed(tc); + let pre_state = sorted_keys(&self.map, |item| item.key); + let (panicked, ops) = run_armed(armed, || op(&mut self.map)); + record_observation("id_ord_map", label, ops); + assert_panic_fired_as_expected(&label, armed, panicked, ops); + + // `self.pending` is set at the end of this function, after all + // fallible draws. + self.pending = Some(Pending { + label, + panic_safety, + armed, + panicked, + pre_state, + }); } + } - fn run(self, map: &mut IdOrdMap) { - match self { - PanickyAction::InsertUnique(key) => { - drop_unarmed(map.insert_unique(PanickyOrdItem { key })); - } - PanickyAction::InsertOverwrite(key) => { + #[hegel::state_machine] + impl PanicMachine { + #[rule] + fn insert_unique(&mut self, tc: TestCase) { + let key = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + self.armed_op(&tc, "insert_unique", PanicSafety::Atomic, |map| { + drop_unarmed(map.insert_unique(PanickyOrdItem { key })); + }); + } + + #[rule] + fn insert_overwrite(&mut self, tc: TestCase) { + let key = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + self.armed_op( + &tc, + "insert_overwrite", + PanicSafety::Atomic, + |map| { drop_unarmed(map.insert_overwrite(PanickyOrdItem { key })); - } - PanickyAction::EntryInsertOverwrite(key) => { - let entry = map.entry(PanickyKey(key)); + }, + ); + } + #[rule] + fn entry_insert_overwrite(&mut self, tc: TestCase) { + let key = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + self.armed_op( + &tc, + "entry_insert_overwrite", + PanicSafety::Atomic, + |map| { + let entry = map.entry(PanickyKey(key)); if let id_ord_map::Entry::Occupied(mut entry) = entry { drop_unarmed(entry.insert(PanickyOrdItem { key })); } - } - PanickyAction::EntryRemove(key) => { - let entry = map.entry(PanickyKey(key)); + }, + ); + } - if let id_ord_map::Entry::Occupied(entry) = entry { - drop_unarmed(entry.remove()); - } - } - PanickyAction::Remove(key) => { - drop_unarmed(map.remove(&PanickySearchKey(key))); - } - PanickyAction::Get(key) => { - let _ = map.get(&PanickySearchKey(key)); + #[rule] + fn entry_remove(&mut self, tc: TestCase) { + let key = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + self.armed_op(&tc, "entry_remove", PanicSafety::Atomic, |map| { + let entry = map.entry(PanickyKey(key)); + if let id_ord_map::Entry::Occupied(entry) = entry { + drop_unarmed(entry.remove()); } - PanickyAction::ContainsKey(key) => { - let _ = map.contains_key(&PanickySearchKey(key)); - } - PanickyAction::PopFirst => { - drop_unarmed(map.pop_first()); - } - PanickyAction::PopLast => { - drop_unarmed(map.pop_last()); - } - PanickyAction::RetainModulo(rem, modulo, keep) => { + }); + } + + #[rule] + fn remove(&mut self, tc: TestCase) { + let key = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + self.armed_op(&tc, "remove", PanicSafety::Atomic, |map| { + drop_unarmed(map.remove(&PanickySearchKey(key))); + }); + } + + #[rule] + fn get(&mut self, tc: TestCase) { + let key = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + self.armed_op(&tc, "get", PanicSafety::Atomic, |map| { + let _ = map.get(&PanickySearchKey(key)); + }); + } + + #[rule] + fn contains_key(&mut self, tc: TestCase) { + let key = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + self.armed_op(&tc, "contains_key", PanicSafety::Atomic, |map| { + let _ = map.contains_key(&PanickySearchKey(key)); + }); + } + + #[rule] + fn pop_first(&mut self, tc: TestCase) { + self.armed_op(&tc, "pop_first", PanicSafety::Atomic, |map| { + drop_unarmed(map.pop_first()); + }); + } + + #[rule] + fn pop_last(&mut self, tc: TestCase) { + self.armed_op(&tc, "pop_last", PanicSafety::Atomic, |map| { + drop_unarmed(map.pop_last()); + }); + } + + #[rule] + fn retain_modulo(&mut self, tc: TestCase) { + let rem = tc.draw(gs::integers::().max_value(2)); + let modulo = + tc.draw(gs::integers::().min_value(1).max_value(3)); + let keep = tc.draw(gs::booleans()); + self.armed_op( + &tc, + "retain_modulo", + // `retain_modulo` loops over per-step atomic operations. + PanicSafety::StepAtomic, + |map| { map.retain(|item| { let matches = item.key % modulo == rem; if keep { matches } else { !matches } }); - } - PanickyAction::Extend(keys) => { - map.extend( - keys.into_iter().map(|key| PanickyOrdItem { key }), - ); - } - PanickyAction::Clear => map.clear(), + }, + ); + } + + #[rule] + fn extend(&mut self, tc: TestCase) { + let keys = tc.draw( + gs::vecs(gs::integers::().max_value(MAX_PANIC_KEY)) + .max_size(7), + ); + // `extend` does per-step atomic operations. + self.armed_op(&tc, "extend", PanicSafety::StepAtomic, |map| { + map.extend(keys.into_iter().map(|key| PanickyOrdItem { key })); + }); + } + + #[rule] + fn fill(&mut self, tc: TestCase) { + let keys = tc.draw( + gs::vecs(gs::integers::().max_value(MAX_PANIC_KEY)) + .max_size(64), + ); + for key in keys { + let _ = self.map.insert_unique(PanickyOrdItem { key }); } } - } - #[proptest(cases = PANIC_PROPTEST_CASES)] - fn proptest_panic_ops( - #[strategy(prop::collection::vec( - any::>(), 0..PANIC_PROPTEST_MAX_OPS, - ))] - ops: Vec>, - ) { - let mut map = IdOrdMap::::new(); - - for (i, op) in ops.into_iter().enumerate() { - let action = op.action; - let action_label = format!("{action:?}"); - let panic_safety = action.panic_safety(); - let armed = op.armed; - - let pre_state = sorted_keys(&map, |item| item.key); - let (panicked, ops) = run_armed(armed, || action.run(&mut map)); - record_observation("id_ord_map", &action_label, ops); - assert_panic_fired_as_expected(&action_label, armed, panicked, ops); - - // `NonCompact` since step-atomic panics leave compactness - // in an indeterminate state. - map.validate(ValidateCompact::NonCompact, ValidateChaos::No) + #[rule] + fn clear(&mut self, tc: TestCase) { + self.armed_op( + &tc, + "clear", + // `clear` does per-table atomic operations. + PanicSafety::StepAtomic, + |map| { + map.clear(); + }, + ); + } + + #[invariant] + fn check_post_op(&mut self, _: TestCase) { + let Some(p) = self.pending.take() else { + self.map + .validate(ValidateCompact::NonCompact, ValidateChaos::No) + .expect("map should be valid"); + return; + }; + let step = self.step; + + // `NonCompact` since step-atomic panics can leave compactness in an + // indeterminate state. + self.map + .validate(ValidateCompact::NonCompact, ValidateChaos::No) .unwrap_or_else(|err| { panic!( - "map invalid after op {i} ({action_label}, \ - armed: {armed:?}, panicked: {panicked}): {err}" + "map invalid after op {step} ({}, armed: {:?}, \ + panicked: {}): {err}", + p.label, p.armed, p.panicked ) }); - - let post_state = sorted_keys(&map, |item| item.key); + let post_state = sorted_keys(&self.map, |item| item.key); assert_post_op_invariants( - i, - &action_label, - armed, - panicked, - panic_safety, - &pre_state, + step, + &p.label, + p.armed, + p.panicked, + p.panic_safety, + &p.pre_state, &post_state, - |&k| map.contains_key(&PanickySearchKey(k)), + |&k| self.map.contains_key(&PanickySearchKey(k)), ); + self.step += 1; } } + + #[hegel::test(test_cases = 512)] + fn proptest_panic_ops(tc: TestCase) { + let map = IdOrdMap::::new(); + hegel::stateful::run(PanicMachine { map, step: 0, pending: None }, tc); + } } diff --git a/crates/iddqd/tests/integration/main.rs b/crates/iddqd/tests/integration/main.rs index 4f0890c..83f2cf2 100644 --- a/crates/iddqd/tests/integration/main.rs +++ b/crates/iddqd/tests/integration/main.rs @@ -1,4 +1,5 @@ mod bi_hash_map; +mod hegel_support; mod id_hash_map; #[cfg(feature = "std")] mod id_ord_map; diff --git a/crates/iddqd/tests/integration/tri_hash_map.rs b/crates/iddqd/tests/integration/tri_hash_map.rs index bc19a59..bc11010 100644 --- a/crates/iddqd/tests/integration/tri_hash_map.rs +++ b/crates/iddqd/tests/integration/tri_hash_map.rs @@ -1,3 +1,8 @@ +use crate::hegel_support::{ + draw_fill_batch, draw_lookup_key1, draw_lookup_key2, draw_lookup_key3, + draw_lookup_keys123, draw_shuffle, test_item, +}; +use hegel::{TestCase, generators as gs}; use iddqd::{ TriHashItem, TriHashMap, internal::ValidateCompact, tri_hash_map, tri_upcast, @@ -8,15 +13,13 @@ use iddqd_test_utils::{ naive_map::NaiveMap, test_item::{ Alloc, HashBuilder, ItemMap, TestItem, TestKey1, TestKey2, TestKey3, - assert_iter_eq, test_item_permutation_strategy, + assert_iter_eq, }, }; -use proptest::prelude::*; use std::{ borrow::Cow, path::{Path, PathBuf}, }; -use test_strategy::{Arbitrary, proptest}; #[derive(Clone, Debug)] struct SimpleItem { @@ -271,140 +274,30 @@ impl CompactnessChange { } } -/// A keys-triple sourced from a mix of "an existing item in the map" and -/// random fallback values. -/// -/// Each component independently either copies a key from an item at -/// `key{1,2,3}_from % naive_map.len()` (when the map is non-empty), or falls -/// back to the random `rand_key{1,2,3}` value. This mix-and-match makes "right -/// key1, right key2, wrong key3"-style triples (and permutations thereof) -/// common in the proptest stream, which is what the `_unique` methods need to -/// be exercised on. -#[derive(Clone, Debug, Arbitrary)] -struct UniqueKeysOp { - key1_from: Option, - key2_from: Option, - key3_from: Option, - rand_key1: u8, - rand_key2: char, - rand_key3: String, +struct TriHashMapMachine { + map: TriHashMap, + naive: NaiveMap, + compactness: ValidateCompact, } -impl UniqueKeysOp { - /// Resolves the triple against the current oracle state. - fn resolve(&self, naive_map: &NaiveMap) -> (u8, char, String) { - let items: Vec<&TestItem> = naive_map.iter().collect(); - let pick_from = |from: Option| -> Option<&TestItem> { - let len = items.len(); - from.and_then(|i| { - if len == 0 { None } else { Some(items[i as usize % len]) } - }) - }; - let key1 = pick_from(self.key1_from) - .map(|item| item.key1) - .unwrap_or(self.rand_key1); - let key2 = pick_from(self.key2_from) - .map(|item| item.key2) - .unwrap_or(self.rand_key2); - let key3 = pick_from(self.key3_from) - .map(|item| item.key3.clone()) - .unwrap_or_else(|| self.rand_key3.clone()); - (key1, key2, key3) +impl TriHashMapMachine { + fn check_valid(&mut self, change: CompactnessChange) { + self.compactness = change.apply(self.compactness); + self.map.validate(self.compactness).expect("map should be valid"); } } -#[derive(Debug, Arbitrary)] -enum Operation { - // Make inserts a bit more common to try and fill up the map. - #[weight(4)] - InsertUnique(TestItem), - #[weight(3)] - InsertOverwrite(TestItem), - #[weight(2)] - Get1(u8), - #[weight(2)] - Get2(char), - #[weight(2)] - Get3(String), - #[weight(2)] - GetUnique(UniqueKeysOp), - #[weight(2)] - GetMutUnique(UniqueKeysOp), - #[weight(2)] - Remove1(u8), - #[weight(2)] - Remove2(char), - #[weight(2)] - Remove3(String), - #[weight(2)] - RemoveUnique(UniqueKeysOp), - #[weight(2)] - RetainValueContains(char, bool), - #[weight(2)] - RetainModulo(#[strategy(0..3_u8)] u8, #[strategy(1..4_u8)] u8, bool), - #[weight(2)] - Extend( - #[strategy(prop::collection::vec(any::(), 0..16))] - Vec, - ), - Clear, - // `additional` is kept modest so that reservations frequently - // exceed the current `growth_left` and so trigger hashbrown's - // rehash path. - Reserve(#[strategy(0..256_usize)] usize), - TryReserve(#[strategy(0..256_usize)] usize), - ShrinkToFit, - ShrinkTo(#[strategy(0..256_usize)] usize), -} - -impl Operation { - fn compactness_change(&self) -> CompactnessChange { - match self { - Operation::InsertUnique(_) - | Operation::Get1(_) - | Operation::Get2(_) - | Operation::Get3(_) - | Operation::GetUnique(_) - | Operation::GetMutUnique(_) - | Operation::Reserve(_) - | Operation::TryReserve(_) => CompactnessChange::NoChange, - // The act of removing items, including calls to insert_overwrite, - // can make the map non-compact. - Operation::InsertOverwrite(_) - | Operation::Remove1(_) - | Operation::Remove2(_) - | Operation::Remove3(_) - | Operation::RemoveUnique(_) - | Operation::RetainValueContains(_, _) - | Operation::RetainModulo(_, _, _) - | Operation::Extend(_) => CompactnessChange::NoLongerCompact, - // Clear always makes the map compact (empty). Shrink - // fully compacts the backing store, restoring the - // `Compact` invariant. - Operation::Clear - | Operation::ShrinkToFit - | Operation::ShrinkTo(_) => CompactnessChange::BecomesCompact, - } - } -} - -#[proptest(cases = 16)] -fn proptest_ops( - #[strategy(prop::collection::vec(any::(), 0..1024))] ops: Vec< - Operation, - >, -) { - let mut map = TriHashMap::::make_new(); - let mut naive_map = NaiveMap::new_key123(); - - let mut compactness = ValidateCompact::Compact; - - // Now perform the operations on both maps. - for op in ops.into_iter() { - compactness = op.compactness_change().apply(compactness); - - match op { - Operation::InsertUnique(item) => { +mod indent0 { + mod indent1 { + use super::super::*; + + #[hegel::state_machine] + impl TriHashMapMachine { + #[rule] + fn insert_unique(&mut self, tc: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; + let item = tc.draw(test_item()); let map_res = map.insert_unique(item.clone()); let naive_res = naive_map.insert_unique(item.clone()); @@ -425,9 +318,14 @@ fn proptest_ops( assert_eq!(map_err_dups, naive_err_dups); } - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::NoChange); } - Operation::InsertOverwrite(item) => { + + #[rule] + fn insert_overwrite(&mut self, tc: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; + let item = tc.draw(test_item()); let mut map_dups = map.insert_overwrite(item.clone()); map_dups.sort(); let mut naive_dups = naive_map.insert_overwrite(item.clone()); @@ -437,28 +335,47 @@ fn proptest_ops( map_dups, naive_dups, "map and naive map should agree on insert_overwrite dups" ); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::Get1(key1) => { + + #[rule] + fn get1(&mut self, tc: TestCase) { + let key1 = draw_lookup_key1(&tc, &self.naive); + let map = &mut self.map; + let naive_map = &mut self.naive; let map_res = map.get1(&TestKey1::new(&key1)); let naive_res = naive_map.get1(key1); assert_eq!(map_res, naive_res); } - Operation::Get2(key2) => { + + #[rule] + fn get2(&mut self, tc: TestCase) { + let key2 = draw_lookup_key2(&tc, &self.naive); + let map = &mut self.map; + let naive_map = &mut self.naive; let map_res = map.get2(&TestKey2::new(key2)); let naive_res = naive_map.get2(key2); assert_eq!(map_res, naive_res); } - Operation::Get3(key3) => { + + #[rule] + fn get3(&mut self, tc: TestCase) { + let key3 = draw_lookup_key3(&tc, &self.naive); + let map = &mut self.map; + let naive_map = &mut self.naive; let map_res = map.get3(&TestKey3::new(&key3)); let naive_res = naive_map.get3(&key3); assert_eq!(map_res, naive_res); } - Operation::GetUnique(keys) => { - let (key1, key2, key3) = keys.resolve(&naive_map); + + #[rule] + fn get_unique(&mut self, tc: TestCase) { + let (key1, key2, key3) = draw_lookup_keys123(&tc, &self.naive); + let map = &mut self.map; + let naive_map = &mut self.naive; let map_res = map.get_unique( &TestKey1::new(&key1), &TestKey2::new(key2), @@ -468,8 +385,12 @@ fn proptest_ops( assert_eq!(map_res, naive_res); } - Operation::GetMutUnique(keys) => { - let (key1, key2, key3) = keys.resolve(&naive_map); + + #[rule] + fn get_mut_unique(&mut self, tc: TestCase) { + let (key1, key2, key3) = draw_lookup_keys123(&tc, &self.naive); + let map = &mut self.map; + let naive_map = &mut self.naive; let map_res = map .get_mut_unique( &TestKey1::new(&key1), @@ -481,31 +402,50 @@ fn proptest_ops( naive_map.get_mut_unique123(key1, key2, &key3).cloned(); assert_eq!(map_res, naive_res); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::NoChange); } - Operation::Remove1(key1) => { + + #[rule] + fn remove1(&mut self, tc: TestCase) { + let key1 = draw_lookup_key1(&tc, &self.naive); + let map = &mut self.map; + let naive_map = &mut self.naive; let map_res = map.remove1(&TestKey1::new(&key1)); let naive_res = naive_map.remove1(key1); assert_eq!(map_res, naive_res); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::Remove2(key2) => { + + #[rule] + fn remove2(&mut self, tc: TestCase) { + let key2 = draw_lookup_key2(&tc, &self.naive); + let map = &mut self.map; + let naive_map = &mut self.naive; let map_res = map.remove2(&TestKey2::new(key2)); let naive_res = naive_map.remove2(key2); assert_eq!(map_res, naive_res); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::Remove3(key3) => { + + #[rule] + fn remove3(&mut self, tc: TestCase) { + let key3 = draw_lookup_key3(&tc, &self.naive); + let map = &mut self.map; + let naive_map = &mut self.naive; let map_res = map.remove3(&TestKey3::new(&key3)); let naive_res = naive_map.remove3(&key3); assert_eq!(map_res, naive_res); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::RemoveUnique(keys) => { - let (key1, key2, key3) = keys.resolve(&naive_map); + + #[rule] + fn remove_unique(&mut self, tc: TestCase) { + let (key1, key2, key3) = draw_lookup_keys123(&tc, &self.naive); + let map = &mut self.map; + let naive_map = &mut self.naive; let map_res = map.remove_unique( &TestKey1::new(&key1), &TestKey2::new(key2), @@ -514,9 +454,15 @@ fn proptest_ops( let naive_res = naive_map.remove_unique123(key1, key2, &key3); assert_eq!(map_res, naive_res); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::RetainValueContains(ch, equals) => { + + #[rule] + fn retain_value_contains(&mut self, tc: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; + let ch = tc.draw(gs::characters()); + let equals = tc.draw(gs::booleans()); map.retain(|item| { let contains = item.value.contains(ch); if equals { contains } else { !contains } @@ -525,9 +471,16 @@ fn proptest_ops( let contains = item.value.contains(ch); if equals { contains } else { !contains } }); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::RetainModulo(a, b, equals) => { + + #[rule] + fn retain_modulo(&mut self, tc: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; + let a = tc.draw(gs::integers::().max_value(2)); + let b = tc.draw(gs::integers::().min_value(1).max_value(3)); + let equals = tc.draw(gs::booleans()); let modulo = a + b; let remainder = a; map.retain(|item| { @@ -538,70 +491,117 @@ fn proptest_ops( let matches = item.key1 % modulo == remainder; if equals { matches } else { !matches } }); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::Extend(items) => { + + #[rule] + fn extend(&mut self, tc: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; + let items = tc.draw(gs::vecs(test_item()).max_size(15)); map.extend(items.clone()); naive_map.extend(items); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::NoLongerCompact); } - Operation::Clear => { + + // Fill up the map to ensure later operations use a larger map. + #[rule] + fn fill(&mut self, tc: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; + let items = draw_fill_batch(&tc); + map.extend(items.clone()); + naive_map.extend(items); + self.check_valid(CompactnessChange::NoLongerCompact); + } + + #[rule] + fn clear(&mut self, _: TestCase) { + let map = &mut self.map; + let naive_map = &mut self.naive; map.clear(); naive_map.clear(); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::BecomesCompact); } - Operation::Reserve(additional) => { + + #[rule] + fn reserve(&mut self, tc: TestCase) { + let map = &mut self.map; + let additional = + tc.draw(gs::integers::().max_value(255)); map.reserve(additional); - // `reserve` has no observable effect beyond capacity; the - // naive map has no equivalent. `validate` is the real - // check — it iterates items and asks `find_index` for - // each, which catches a hash-table left mis-bucketed by - // a regrowth rehash. - map.validate(compactness).expect("map should be valid"); + // `reserve` has no observable effect beyond capacity -- the + // naive map has no equivalent. `check_valid` will iterate items + // and ask `find_index` for each, which catches a hash-table + // left mis-bucketed by a regrowth rehash. + self.check_valid(CompactnessChange::NoChange); } - Operation::TryReserve(additional) => { - // Mirror `Reserve`; we don't assert `Ok` because the - // allocator could (legitimately) refuse a large request, - // and bailing on that would mask the actual regression - // we care about (silent hash-table corruption). + + #[rule] + fn try_reserve(&mut self, tc: TestCase) { + let map = &mut self.map; + let additional = + tc.draw(gs::integers::().max_value(255)); let _ = map.try_reserve(additional); - map.validate(compactness).expect("map should be valid"); + // See the comment on `reserve` above for why this is only + // `check_valid`. + self.check_valid(CompactnessChange::NoChange); } - Operation::ShrinkToFit => { + + #[rule] + fn shrink_to_fit(&mut self, _: TestCase) { + let map = &mut self.map; map.shrink_to_fit(); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::BecomesCompact); } - Operation::ShrinkTo(min_capacity) => { + + #[rule] + fn shrink_to(&mut self, tc: TestCase) { + let map = &mut self.map; + let min_capacity = + tc.draw(gs::integers::().max_value(255)); map.shrink_to(min_capacity); - map.validate(compactness).expect("map should be valid"); + self.check_valid(CompactnessChange::BecomesCompact); } - } - - // Check that the iterators work correctly. - let mut naive_items = naive_map.iter().collect::>(); - naive_items.sort_by(|a, b| a.key1().cmp(&b.key1())); - assert_iter_eq(map.clone(), naive_items); + #[invariant] + fn iter_matches(&mut self, _: TestCase) { + let map = &self.map; + let naive_map = &self.naive; + let mut naive_items = naive_map.iter().collect::>(); + naive_items.sort_by(|a, b| a.key1().cmp(&b.key1())); + assert_iter_eq(map.clone(), naive_items); + } + } } } -#[proptest(cases = 64)] -fn proptest_permutation_eq( - #[strategy(test_item_permutation_strategy::>(0..256))] - items: (Vec, Vec), -) { - let (items1, items2) = items; +#[hegel::test(test_cases = 512)] +fn proptest_ops(tc: TestCase) { + let machine = TriHashMapMachine { + map: TriHashMap::::make_new(), + naive: NaiveMap::new_key123(), + compactness: ValidateCompact::Compact, + }; + hegel::stateful::run(machine, tc); +} + +#[hegel::test(test_cases = 64)] +fn proptest_permutation_eq(tc: TestCase) { + // draw_fill_batch generates unique keys so there's no need to deduplicate. + let set = draw_fill_batch(&tc); + let set2 = draw_shuffle(&tc, &set); + let mut map1 = TriHashMap::::make_new(); let mut map2 = TriHashMap::::make_new(); - - for item in items1 { - map1.insert_unique(item.clone()).unwrap(); + for item in set { + map1.insert_unique(item).expect("set is deduplicated"); } - for item in items2 { - map2.insert_unique(item.clone()).unwrap(); + for item in set2 { + map2.insert_unique(item).expect("set is deduplicated"); } - assert_eq_props(map1, map2); + assert_eq_props(&map1, &map2); } // Test various conditions for non-equality. @@ -1057,21 +1057,26 @@ mod macro_tests { #[cfg(feature = "serde")] mod serde_tests { + use crate::hegel_support::draw_random_batch; + use hegel::TestCase; use iddqd::TriHashMap; use iddqd_test_utils::{ serde_utils::assert_serialize_roundtrip, test_item::{Alloc, HashBuilder, TestItem}, }; - use test_strategy::proptest; - #[proptest] - fn proptest_serialize_roundtrip(values: Vec) { + #[hegel::test(test_cases = 256)] + fn proptest_serialize_roundtrip(tc: TestCase) { + let values = draw_random_batch(&tc); assert_serialize_roundtrip::>( values, ); } } +#[cfg(feature = "proptest")] +use test_strategy::proptest; + #[cfg(feature = "proptest")] #[proptest(cases = 16)] fn proptest_arbitrary_map(map: TriHashMap) { @@ -1136,210 +1141,284 @@ impl Drop for PanickyHashItem { #[cfg(all(feature = "default-hasher", feature = "allocator-api2"))] mod proptest_panic_safety { use super::*; + use crate::hegel_support::{MAX_PANIC_KEY, draw_armed}; use allocator_api2::alloc::Global; use iddqd_test_utils::panic_safety::{ - PANIC_PROPTEST_CASES, PANIC_PROPTEST_MAX_OPS, PanicSafety, - PanickyAlloc, PanickyOp, PanickySearchKey, + PanicSafety, PanickyAlloc, PanickySearchKey, assert_panic_fired_as_expected, assert_post_op_invariants, drop_unarmed, record_observation, run_armed, sorted_keys, }; - /// Map type used by these tests. - /// - /// Wraps the allocator in [`PanickyAlloc`] so the shared panic - /// countdown can fire from inside `allocate`. That makes the shrink - /// actions below (which would otherwise make zero observable user - /// calls — `cached_hasher` keeps hashbrown from invoking user `Hash`) - /// participate in the panic-injection schedule. type PanickyMap = TriHashMap< PanickyHashItem, iddqd::DefaultHashBuilder, PanickyAlloc, >; - // Keys are kept in a small range so hits and misses both happen - // frequently against a 16-ish-element map. - #[derive(Debug, Arbitrary)] - enum PanickyAction { - #[weight(4)] - InsertUnique( - #[strategy(0..32_u32)] u32, - #[strategy(0..32_u32)] u32, - #[strategy(0..32_u32)] u32, - ), - #[weight(3)] - InsertOverwrite( - #[strategy(0..32_u32)] u32, - #[strategy(0..32_u32)] u32, - #[strategy(0..32_u32)] u32, - ), - #[weight(2)] - Remove1(#[strategy(0..32_u32)] u32), - #[weight(2)] - Remove2(#[strategy(0..32_u32)] u32), - #[weight(2)] - Remove3(#[strategy(0..32_u32)] u32), - #[weight(1)] - Get1(#[strategy(0..32_u32)] u32), - #[weight(1)] - Get2(#[strategy(0..32_u32)] u32), - #[weight(1)] - Get3(#[strategy(0..32_u32)] u32), - #[weight(2)] - RetainModulo( - #[strategy(0..3_u32)] u32, - #[strategy(1..4_u32)] u32, - bool, - ), - #[weight(2)] - Extend( - #[strategy(prop::collection::vec( - (0..32_u32, 0..32_u32, 0..32_u32), 0..8, - ))] - Vec<(u32, u32, u32)>, - ), - Clear, - ShrinkToFit, - ShrinkTo(#[strategy(0..32_usize)] usize), + struct PanicMachine { + map: PanickyMap, + step: usize, + pending: Option, } - impl PanickyAction { - /// Classify panic safety for this action. - /// - /// * `RetainModulo` and `Clear` loop over per-step atomic item - /// destruction. - /// * `Extend` calls `HashTable::reserve` up front, which on a - /// tombstone-heavy map drops into hashbrown's - /// `rehash_in_place` — documented as not panic-safe under a - /// user `Hash` panic, so the proptest skips arming for it. - /// * `ShrinkToFit` / `ShrinkTo` reorganize indexes and capacities - /// but never add, remove, or drop items, so the observable - /// set of keys is invariant — atomic in this test's sense. - fn panic_safety(&self) -> PanicSafety { - match self { - PanickyAction::InsertUnique(_, _, _) - | PanickyAction::InsertOverwrite(_, _, _) - | PanickyAction::Remove1(_) - | PanickyAction::Remove2(_) - | PanickyAction::Remove3(_) - | PanickyAction::Get1(_) - | PanickyAction::Get2(_) - | PanickyAction::Get3(_) - | PanickyAction::ShrinkToFit - | PanickyAction::ShrinkTo(_) => PanicSafety::Atomic, - PanickyAction::RetainModulo(_, _, _) - | PanickyAction::Extend(_) - | PanickyAction::Clear => PanicSafety::StepAtomic, - } + struct Pending { + label: &'static str, + panic_safety: PanicSafety, + armed: Option, + panicked: bool, + pre_state: Vec<(u32, u32, u32)>, + } + + impl PanicMachine { + fn armed_op( + &mut self, + tc: &TestCase, + label: &'static str, + panic_safety: PanicSafety, + op: impl FnOnce(&mut PanickyMap), + ) { + // hegel runs the `#[invariant]` (which consumes `pending`) after + // every successful rule, so `pending` must be `None` here -- if + // not, a prior op's post-op checks were silently skipped. + assert!( + self.pending.is_none(), + "previous op's post-op invariant did not run before this op", + ); + let armed = draw_armed(tc); + let pre_state = sorted_keys(&self.map, |item| { + (item.key1, item.key2, item.key3) + }); + let (panicked, ops) = run_armed(armed, || op(&mut self.map)); + record_observation("tri_hash_map", label, ops); + assert_panic_fired_as_expected(&label, armed, panicked, ops); + + // `self.pending` is set at the end of this function, after all + // fallible draws. + self.pending = Some(Pending { + label, + panic_safety, + armed, + panicked, + pre_state, + }); } + } - fn run(self, map: &mut PanickyMap) { - match self { - PanickyAction::InsertUnique(key1, key2, key3) => { - drop_unarmed(map.insert_unique(PanickyHashItem { - key1, - key2, - key3, - })); - } - PanickyAction::InsertOverwrite(key1, key2, key3) => { + #[hegel::state_machine] + impl PanicMachine { + #[rule] + fn insert_unique(&mut self, tc: TestCase) { + let key1 = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + let key2 = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + let key3 = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + self.armed_op(&tc, "insert_unique", PanicSafety::Atomic, |map| { + drop_unarmed(map.insert_unique(PanickyHashItem { + key1, + key2, + key3, + })); + }); + } + + #[rule] + fn insert_overwrite(&mut self, tc: TestCase) { + let key1 = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + let key2 = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + let key3 = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + self.armed_op( + &tc, + "insert_overwrite", + PanicSafety::Atomic, + |map| { drop_unarmed(map.insert_overwrite(PanickyHashItem { key1, key2, key3, })); - } - PanickyAction::Remove1(key1) => { - drop_unarmed(map.remove1(&PanickySearchKey(key1))); - } - PanickyAction::Remove2(key2) => { - drop_unarmed(map.remove2(&PanickySearchKey(key2))); - } - PanickyAction::Remove3(key3) => { - drop_unarmed(map.remove3(&PanickySearchKey(key3))); - } - PanickyAction::Get1(key1) => { - let _ = map.get1(&PanickySearchKey(key1)); - } - PanickyAction::Get2(key2) => { - let _ = map.get2(&PanickySearchKey(key2)); - } - PanickyAction::Get3(key3) => { - let _ = map.get3(&PanickySearchKey(key3)); - } - PanickyAction::RetainModulo(rem, modulo, keep) => { + }, + ); + } + + #[rule] + fn remove1(&mut self, tc: TestCase) { + let key1 = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + self.armed_op(&tc, "remove1", PanicSafety::Atomic, |map| { + drop_unarmed(map.remove1(&PanickySearchKey(key1))); + }); + } + + #[rule] + fn remove2(&mut self, tc: TestCase) { + let key2 = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + self.armed_op(&tc, "remove2", PanicSafety::Atomic, |map| { + drop_unarmed(map.remove2(&PanickySearchKey(key2))); + }); + } + + #[rule] + fn remove3(&mut self, tc: TestCase) { + let key3 = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + self.armed_op(&tc, "remove3", PanicSafety::Atomic, |map| { + drop_unarmed(map.remove3(&PanickySearchKey(key3))); + }); + } + + #[rule] + fn get1(&mut self, tc: TestCase) { + let key1 = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + self.armed_op(&tc, "get1", PanicSafety::Atomic, |map| { + let _ = map.get1(&PanickySearchKey(key1)); + }); + } + + #[rule] + fn get2(&mut self, tc: TestCase) { + let key2 = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + self.armed_op(&tc, "get2", PanicSafety::Atomic, |map| { + let _ = map.get2(&PanickySearchKey(key2)); + }); + } + + #[rule] + fn get3(&mut self, tc: TestCase) { + let key3 = tc.draw(gs::integers::().max_value(MAX_PANIC_KEY)); + self.armed_op(&tc, "get3", PanicSafety::Atomic, |map| { + let _ = map.get3(&PanickySearchKey(key3)); + }); + } + + #[rule] + fn retain_modulo(&mut self, tc: TestCase) { + let rem = tc.draw(gs::integers::().max_value(2)); + let modulo = + tc.draw(gs::integers::().min_value(1).max_value(3)); + let keep = tc.draw(gs::booleans()); + self.armed_op( + &tc, + "retain_modulo", + // `retain_modulo` loops over per-step atomic operations. + PanicSafety::StepAtomic, + |map| { map.retain(|item| { let matches = item.key1 % modulo == rem; if keep { matches } else { !matches } }); - } - PanickyAction::Extend(triples) => { - map.extend(triples.into_iter().map( - |(key1, key2, key3)| PanickyHashItem { - key1, - key2, - key3, - }, - )); - } - PanickyAction::Clear => map.clear(), - PanickyAction::ShrinkToFit => map.shrink_to_fit(), - PanickyAction::ShrinkTo(min_capacity) => { - map.shrink_to(min_capacity); - } + }, + ); + } + + #[rule] + fn extend(&mut self, tc: TestCase) { + let triples = tc.draw( + gs::vecs(gs::tuples!( + gs::integers::().max_value(MAX_PANIC_KEY), + gs::integers::().max_value(MAX_PANIC_KEY), + gs::integers::().max_value(MAX_PANIC_KEY), + )) + .max_size(7), + ); + // `extend` does per-step atomic operations. + self.armed_op(&tc, "extend", PanicSafety::StepAtomic, |map| { + map.extend(triples.into_iter().map(|(key1, key2, key3)| { + PanickyHashItem { key1, key2, key3 } + })); + }); + } + + #[rule] + fn fill(&mut self, tc: TestCase) { + let triples = tc.draw( + gs::vecs(gs::tuples!( + gs::integers::().max_value(MAX_PANIC_KEY), + gs::integers::().max_value(MAX_PANIC_KEY), + gs::integers::().max_value(MAX_PANIC_KEY), + )) + .max_size(64), + ); + for (key1, key2, key3) in triples { + let item = PanickyHashItem { key1, key2, key3 }; + let _ = self.map.insert_unique(item); } } - } - #[proptest(cases = PANIC_PROPTEST_CASES)] - fn proptest_panic_ops( - #[strategy(prop::collection::vec( - any::>(), 0..PANIC_PROPTEST_MAX_OPS, - ))] - ops: Vec>, - ) { - let mut map = PanickyMap::with_hasher_in( - iddqd::DefaultHashBuilder::default(), - PanickyAlloc::default(), - ); + #[rule] + fn clear(&mut self, tc: TestCase) { + self.armed_op( + &tc, + "clear", + // `clear` does per-table atomic operations. + PanicSafety::StepAtomic, + |map| { + map.clear(); + }, + ); + } - for (i, op) in ops.into_iter().enumerate() { - let action = op.action; - let action_label = format!("{action:?}"); - let panic_safety = action.panic_safety(); - let armed = op.armed; - - let pre_state = - sorted_keys(&map, |item| (item.key1, item.key2, item.key3)); - let (panicked, ops) = run_armed(armed, || action.run(&mut map)); - record_observation("tri_hash_map", &action_label, ops); - assert_panic_fired_as_expected(&action_label, armed, panicked, ops); - - // `NonCompact` since step-atomic panics leave compactness - // in an indeterminate state. - map.validate(ValidateCompact::NonCompact).unwrap_or_else(|err| { - panic!( - "map invalid after op {i} ({action_label}, \ - armed: {armed:?}, panicked: {panicked}): {err}" - ) + #[rule] + fn shrink_to_fit(&mut self, tc: TestCase) { + self.armed_op(&tc, "shrink_to_fit", PanicSafety::Atomic, |map| { + map.shrink_to_fit(); }); + } - let post_state = - sorted_keys(&map, |item| (item.key1, item.key2, item.key3)); + #[rule] + fn shrink_to(&mut self, tc: TestCase) { + let min_capacity = tc.draw( + gs::integers::().max_value(MAX_PANIC_KEY as usize), + ); + self.armed_op(&tc, "shrink_to", PanicSafety::Atomic, |map| { + map.shrink_to(min_capacity); + }); + } + + #[invariant] + fn check_post_op(&mut self, _: TestCase) { + let Some(p) = self.pending.take() else { + self.map + .validate(ValidateCompact::NonCompact) + .expect("map should be valid"); + return; + }; + let step = self.step; + + // `NonCompact` since step-atomic panics can leave compactness in an + // indeterminate state. + self.map.validate(ValidateCompact::NonCompact).unwrap_or_else( + |err| { + panic!( + "map invalid after op {step} ({}, armed: {:?}, \ + panicked: {}): {err}", + p.label, p.armed, p.panicked + ) + }, + ); + let post_state = sorted_keys(&self.map, |item| { + (item.key1, item.key2, item.key3) + }); assert_post_op_invariants( - i, - &action_label, - armed, - panicked, - panic_safety, - &pre_state, + step, + &p.label, + p.armed, + p.panicked, + p.panic_safety, + &p.pre_state, &post_state, |&(k1, k2, k3)| { - map.contains_key1(&PanickySearchKey(k1)) - && map.contains_key2(&PanickySearchKey(k2)) - && map.contains_key3(&PanickySearchKey(k3)) + self.map.contains_key1(&PanickySearchKey(k1)) + && self.map.contains_key2(&PanickySearchKey(k2)) + && self.map.contains_key3(&PanickySearchKey(k3)) }, ); + self.step += 1; } } + + #[hegel::test(test_cases = 512)] + fn proptest_panic_ops(tc: TestCase) { + let map = PanickyMap::with_hasher_in( + iddqd::DefaultHashBuilder::default(), + PanickyAlloc::default(), + ); + hegel::stateful::run(PanicMachine { map, step: 0, pending: None }, tc); + } }