From c6955f99f046e0eba812618b5a19292104255f43 Mon Sep 17 00:00:00 2001 From: Rain Date: Wed, 24 Jun 2026 13:47:53 -0700 Subject: [PATCH] [integration-tests] port proptests to hegel state machines The newest versions of hegel no longer need `uv` or Python so I feel much better porting over the tests to this model. The commit is written slightly weirdly to try and preserve as much blame as possible. The next commit will fix up the indents and remove the shadow bindings (`let map = &mut self.map`), etc, with the idea that it'll be listed in `.git-blame-ignore-revs`. Also update the MSRV to 1.86 since Hegel requires this. --- .github/workflows/ci.yml | 8 +- .gitignore | 2 + CHANGELOG.md | 4 + Cargo.lock | 381 +++++++- Cargo.toml | 3 +- crates/iddqd-test-utils/src/naive_map.rs | 4 + crates/iddqd-test-utils/src/panic_safety.rs | 105 +-- crates/iddqd-test-utils/src/test_item.rs | 37 +- crates/iddqd/Cargo.toml | 1 + crates/iddqd/README.md | 4 +- crates/iddqd/src/lib.rs | 2 +- crates/iddqd/tests/integration/bi_hash_map.rs | 810 +++++++++-------- .../iddqd/tests/integration/hegel_support.rs | 191 +++++ crates/iddqd/tests/integration/id_hash_map.rs | 641 ++++++++------ crates/iddqd/tests/integration/id_ord_map.rs | 684 ++++++++------- crates/iddqd/tests/integration/main.rs | 1 + .../iddqd/tests/integration/tri_hash_map.rs | 811 ++++++++++-------- 17 files changed, 2278 insertions(+), 1411 deletions(-) create mode 100644 crates/iddqd/tests/integration/hegel_support.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97ffe893..22581168 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 3370c26f..7cef912d 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 7129745c..e928f081 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 69ad17fc..6df2168f 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 2264aa19..e6ac87a7 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 4d695879..5f3795e1 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 690a484a..41e4a039 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 2f9df902..8fc202de 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 319c3d27..382dcb71 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 bcff5032..fb908aaf 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 31f36675..5e7b8a52 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 6daefa17..0e844d3a 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 00000000..57c2720f --- /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 8867154c..ab86682a 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 e6a0b795..1ceafd1a 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 4f0890ca..83f2cf2d 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 bc19a592..bc11010b 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); + } }