diff --git a/Cargo.lock b/Cargo.lock index 55b321d4..53be2a36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,6 +39,25 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "alloy-rlp" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24671b1f62edcf0f9b62994c7bf72cd621a04a4b99f5020ece1a647b40e2f103" +dependencies = [ + "arrayvec", + "bytes", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anes" version = "0.1.6" @@ -129,12 +148,298 @@ dependencies = [ "yansi", ] +[[package]] +name = "ark-ff" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b3235cc41ee7a12aaaf2c575a2ad7b46713a8a50bda2fc3b003a04845c05dd6" +dependencies = [ + "ark-ff-asm 0.3.0", + "ark-ff-macros 0.3.0", + "ark-serialize 0.3.0", + "ark-std 0.3.0", + "derivative", + "num-bigint", + "num-traits", + "paste", + "rustc_version 0.3.3", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" +dependencies = [ + "ark-ff-asm 0.4.2", + "ark-ff-macros 0.4.2", + "ark-serialize 0.4.2", + "ark-std 0.4.0", + "derivative", + "digest 0.10.7", + "itertools 0.10.5", + "num-bigint", + "num-traits", + "paste", + "rustc_version 0.4.1", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70" +dependencies = [ + "ark-ff-asm 0.5.0", + "ark-ff-macros 0.5.0", + "ark-serialize 0.5.0", + "ark-std 0.5.0", + "arrayvec", + "digest 0.10.7", + "educe", + "itertools 0.13.0", + "num-bigint", + "num-traits", + "paste", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7a806ac6c8307b929df4645776290a50ee2aac754ad09d8bdf73391309e43af" +dependencies = [ + "ark-ff-asm 0.6.0", + "ark-ff-macros 0.6.0", + "ark-serialize 0.6.0", + "ark-std 0.6.0", + "digest 0.10.7", + "educe", + "num-bigint", + "num-traits", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db02d390bf6643fb404d3d22d31aee1c4bc4459600aef9113833d17e786c6e44" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-asm" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-asm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "ark-ff-asm" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1479009684adc073dff49a1025d3a7065b317a9ead25aaaca38cdc70058ba8a2" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "ark-ff-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fd794a08ccb318058009eefdf15bcaaaaf6f8161eb3345f907222bac38b20" +dependencies = [ + "num-bigint", + "num-traits", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "ark-ff-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0691ed21ef00ef89c1e9bda832eba493dda3ec2f8d892fb25b705f73f06bb8" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "ark-serialize" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d6c2b318ee6e10f8c2853e73a83adc0ccb88995aa978d8a3408d492ab2ee671" +dependencies = [ + "ark-std 0.3.0", + "digest 0.9.0", +] + +[[package]] +name = "ark-serialize" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" +dependencies = [ + "ark-std 0.4.0", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-serialize" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" +dependencies = [ + "ark-std 0.5.0", + "arrayvec", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-serialize" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a74dd304fd536fb95d0a328e72be759209cc496a9da094c5bc56e5fea4f9e86b" +dependencies = [ + "ark-serialize-derive", + "ark-std 0.6.0", + "digest 0.10.7", + "num-bigint", + "serde_with", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f153690697a2b91e5e1251ff98411ee5371500a111a0fd317a70e588eb300f9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "ark-std" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df2c09229cbc5a028b1d70e00fdb2acee28b1055dfb5ca73eea49c5a25c4e7c" +dependencies = [ + "num-traits", + "rand 0.8.7", +] + +[[package]] +name = "ark-std" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" +dependencies = [ + "num-traits", + "rand 0.8.7", +] + +[[package]] +name = "ark-std" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" +dependencies = [ + "num-traits", + "rand 0.8.7", +] + +[[package]] +name = "ark-std" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "367c9c827ed431bff6868b7aa926e05b16eb46603cc8b6e768e4a5553fa1d155" +dependencies = [ + "num-traits", + "rand 0.8.7", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "auto_impl" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bincode" version = "2.0.1" @@ -226,7 +531,16 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.118", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", ] [[package]] @@ -235,6 +549,12 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "byte-slice-cast" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" + [[package]] name = "bytemuck" version = "1.25.0" @@ -247,6 +567,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cassowary" version = "0.3.0" @@ -295,6 +621,18 @@ dependencies = [ "rand_core 0.10.0", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + [[package]] name = "chumsky" version = "0.10.1" @@ -381,7 +719,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -408,7 +746,7 @@ checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ "serde", "termcolor", - "unicode-width 0.1.14", + "unicode-width 0.2.0", ] [[package]] @@ -423,7 +761,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -452,6 +790,27 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + [[package]] name = "convert_case" version = "0.6.0" @@ -461,6 +820,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.3.0" @@ -594,6 +959,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "darling" version = "0.21.3" @@ -625,7 +1000,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.118", ] [[package]] @@ -638,7 +1013,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.118", ] [[package]] @@ -649,7 +1024,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -660,7 +1035,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -697,7 +1072,7 @@ dependencies = [ "defmt-parser", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -709,6 +1084,62 @@ dependencies = [ "thiserror", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "crypto-common", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "either" version = "1.15.0" @@ -721,6 +1152,26 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "enum-ordinalize" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07f808d588c10e464ea6f7d3eaed500049eff30aaac103460f61828c2d65b3eb" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e528e2d34ba8a67a1a650b86beae8ef69fc5fdb638016f386b973226590432" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "env_filter" version = "2.0.0" @@ -757,7 +1208,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -776,12 +1227,46 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fastrlp" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139834ddba373bbdd213dffe02c8d110508dcf1726c2be27e8d1f7d7e1856418" +dependencies = [ + "arrayvec", + "auto_impl", + "bytes", +] + +[[package]] +name = "fastrlp" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce8dba4714ef14b8274c371879b175aa55b16b30f269663f19d576f380018dc4" +dependencies = [ + "arrayvec", + "auto_impl", + "bytes", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixed-hash" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" +dependencies = [ + "byteorder", + "rand 0.8.7", + "rustc-hex", + "static_assertions", +] + [[package]] name = "fnv" version = "1.0.7" @@ -809,6 +1294,27 @@ dependencies = [ "byteorder", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + [[package]] name = "getrandom" version = "0.3.3" @@ -856,6 +1362,12 @@ dependencies = [ "crunchy", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -873,18 +1385,48 @@ dependencies = [ "foldhash", ] -[[package]] -name = "hashbrown" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" - [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -897,12 +1439,43 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "impl-codec" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba6a270039626615617f3f36d15fc827041df3b78c439da2cadfa47455a77f2f" +dependencies = [ + "parity-scale-codec", +] + +[[package]] +name = "impl-trait-for-tuples" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "indenter" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.11.4" @@ -910,7 +1483,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.15.5", "serde", "serde_core", ] @@ -946,7 +1519,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -964,6 +1537,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -1010,7 +1592,7 @@ checksum = "e165e897f662d428f3cd3828a919dbe067c2d42bb1031eede74ef9d27ecdedd2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1023,6 +1605,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1035,6 +1632,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libmimalloc-sys" version = "0.1.49" @@ -1081,6 +1684,16 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "memchr" version = "2.7.6" @@ -1108,6 +1721,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + [[package]] name = "num" version = "0.4.3" @@ -1138,9 +1766,16 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ + "bytemuck", "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-integer" version = "0.1.46" @@ -1179,6 +1814,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "numpy" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a5b15d63a5ff39e378daed0e1340d3a5964703ea9712eb09a0dc66fade996f4" +dependencies = [ + "libc", + "ndarray", + "num-complex", + "num-integer", + "num-traits", + "pyo3", + "pyo3-build-config", + "rustc-hash", ] [[package]] @@ -1218,6 +1880,34 @@ dependencies = [ "winapi", ] +[[package]] +name = "parity-scale-codec" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" +dependencies = [ + "arrayvec", + "bitvec", + "byte-slice-cast", + "const_format", + "impl-trait-for-tuples", + "parity-scale-codec-derive", + "rustversion", + "serde", +] + +[[package]] +name = "parity-scale-codec-derive" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "parking_lot" version = "0.12.4" @@ -1247,6 +1937,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pest" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +dependencies = [ + "memchr", + "ucd-trie", +] + [[package]] name = "plotters" version = "0.3.7" @@ -1290,6 +1990,12 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1319,6 +2025,23 @@ dependencies = [ "ratatui", ] +[[package]] +name = "ppvm-lindblad" +version = "0.1.0" +dependencies = [ + "approx", + "fxhash", + "ndarray", + "num", + "ppvm-pauli-sum", + "ppvm-pauli-word", + "ppvm-traits", + "quspin-expm", + "quspin-types", + "rayon", + "sprs", +] + [[package]] name = "ppvm-pauli-sum" version = "0.1.0" @@ -1329,7 +2052,7 @@ dependencies = [ "dashmap", "fxhash", "gxhash", - "indexmap", + "indexmap 2.11.4", "insta", "itertools 0.14.0", "num", @@ -1359,11 +2082,17 @@ name = "ppvm-python-native" version = "0.1.0" dependencies = [ "bnum", + "mimalloc", + "num", + "numpy", "paste", + "ppvm-lindblad", "ppvm-pauli-sum", + "ppvm-pauli-word", "ppvm-stim", "ppvm-tableau", "ppvm-tableau-sum", + "ppvm-traits", "pyo3", ] @@ -1455,7 +2184,7 @@ dependencies = [ "dashmap", "fxhash", "gxhash", - "indexmap", + "indexmap 2.11.4", "insta", "num", "rayon", @@ -1498,7 +2227,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.118", +] + +[[package]] +name = "primitive-types" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b34d9fd68ae0b74a41b21c03c2f62847aa0ffea044eee893b4c140b37e244e2" +dependencies = [ + "fixed-hash", + "impl-codec", + "uint", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", ] [[package]] @@ -1521,7 +2270,7 @@ dependencies = [ "bitflags 2.11.1", "num-traits", "rand 0.9.4", - "rand_chacha", + "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax 0.8.11", "rusty-fork", @@ -1582,7 +2331,7 @@ dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1594,7 +2343,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1604,12 +2353,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] -name = "quote" -version = "1.0.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quspin-expm" +version = "0.1.0" +source = "git+https://github.com/QuSpin/QuSpin-rust?rev=a0ad6c9fe2e8063208f9ba1c6677150c993bb554#a0ad6c9fe2e8063208f9ba1c6677150c993bb554" +dependencies = [ + "ndarray", + "num-complex", + "quspin-types", + "rayon", +] + +[[package]] +name = "quspin-types" +version = "0.1.0" +source = "git+https://github.com/QuSpin/QuSpin-rust?rev=a0ad6c9fe2e8063208f9ba1c6677150c993bb554#a0ad6c9fe2e8063208f9ba1c6677150c993bb554" dependencies = [ - "proc-macro2", + "ndarray", + "num-complex", + "ruint", + "thiserror", ] [[package]] @@ -1624,13 +2395,24 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] @@ -1645,6 +2427,16 @@ dependencies = [ "rand_core 0.10.0", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -1655,6 +2447,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "rand_core" version = "0.9.5" @@ -1700,6 +2501,12 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + [[package]] name = "rayon" version = "1.11.0" @@ -1729,6 +2536,26 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "regex" version = "1.12.4" @@ -1775,6 +2602,81 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rlp" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb919243f34364b6bd2fc10ef797edbfa75f33c252e7998527479c6d6b47e1ec" +dependencies = [ + "bytes", + "rustc-hex", +] + +[[package]] +name = "ruint" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45caf26f647c19115bf9c453c70ffe4a4a3a6390dceebd942610584f99b8ddce" +dependencies = [ + "alloy-rlp", + "ark-ff 0.3.0", + "ark-ff 0.4.2", + "ark-ff 0.5.0", + "ark-ff 0.6.0", + "bytes", + "fastrlp 0.3.1", + "fastrlp 0.4.0", + "num-bigint", + "num-integer", + "num-traits", + "parity-scale-codec", + "primitive-types", + "proptest", + "rand 0.8.7", + "rand 0.9.4", + "rlp", + "ruint-macro", + "serde_core", + "valuable", + "zeroize", +] + +[[package]] +name = "ruint-macro" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc-hex" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" + +[[package]] +name = "rustc_version" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dfe2087c51c460008730de8b57e6a320782fbfb312e1f4d520e6c6fae155ee" +dependencies = [ + "semver 0.11.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver 1.0.27", +] + [[package]] name = "rustix" version = "0.38.44" @@ -1798,7 +2700,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1834,18 +2736,60 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" +dependencies = [ + "semver-parser", +] + [[package]] name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +[[package]] +name = "semver-parser" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9900206b54a3527fdc7b8a938bffd94a568bac4f4aa8113b209df75a09c0dec2" +dependencies = [ + "pest", +] + [[package]] name = "serde" version = "1.0.228" @@ -1873,7 +2817,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1889,6 +2833,25 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.11.4", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "time", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1938,6 +2901,20 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "sprs" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dca58a33be2188d4edc71534f8bafa826e787cc28ca1c47f31be3423f0d6e55" +dependencies = [ + "ndarray", + "num-complex", + "num-traits", + "num_cpus", + "rayon", + "smallvec", +] + [[package]] name = "stacker" version = "0.1.24" @@ -1991,7 +2968,18 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.118", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] @@ -2027,7 +3015,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix 1.1.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2056,7 +3044,37 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", ] [[package]] @@ -2069,6 +3087,75 @@ dependencies = [ "serde_json", ] +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0db3bae107c9522f86d361697dee1d7386a2ddcf659d5aea5159819a21a3c4a7" +dependencies = [ + "indexmap 2.11.4", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "uint" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + [[package]] name = "unarray" version = "0.1.4" @@ -2128,6 +3215,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version_check" version = "0.9.5" @@ -2190,7 +3283,7 @@ dependencies = [ "convert_case", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2201,7 +3294,7 @@ checksum = "b6b0c3b54b4caeaaeeda2953fa273155887e89ebdb0ce80b65d23c435b1e70a5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "vihaco-parser-core", ] @@ -2295,7 +3388,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn", + "syn 2.0.118", "wasm-bindgen-shared", ] @@ -2317,7 +3410,7 @@ checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -2348,7 +3441,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.11.4", "wasm-encoder", "wasmparser", ] @@ -2361,8 +3454,8 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.11.1", "hashbrown 0.15.5", - "indexmap", - "semver", + "indexmap 2.11.4", + "semver 1.0.27", ] [[package]] @@ -2397,7 +3490,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2406,12 +3499,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -2494,6 +3640,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.46.0" @@ -2528,9 +3683,9 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.11.4", "prettyplease", - "syn", + "syn 2.0.118", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -2546,7 +3701,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.118", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -2559,7 +3714,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.11.1", - "indexmap", + "indexmap 2.11.4", "log", "serde", "serde_derive", @@ -2578,9 +3733,9 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.11.4", "log", - "semver", + "semver 1.0.27", "serde", "serde_derive", "serde_json", @@ -2620,5 +3775,25 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", ] diff --git a/Cargo.toml b/Cargo.toml index 35cb8102..b957023b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "crates/ppvm-pauli-word", "crates/ppvm-pauli-sum", "crates/ppvm-sym", + "crates/ppvm-lindblad", "crates/ppvm-python-native", "crates/ppvm-tableau", "crates/ppvm-stim", "crates/stim-parser", diff --git a/crates/ppvm-lindblad/Cargo.toml b/crates/ppvm-lindblad/Cargo.toml new file mode 100644 index 00000000..7e3b9763 --- /dev/null +++ b/crates/ppvm-lindblad/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "ppvm-lindblad" +version = "0.1.0" +edition = "2024" +description = "Direct Heisenberg-picture Lindbladian evolution on an adaptive Pauli-string basis." + +[dependencies] +fxhash = "0.2.1" +ndarray = "0.17" +num = "0.4.3" +ppvm-traits = { version = "0.1.0", path = "../ppvm-traits" } +ppvm-pauli-word = { version = "0.1.0", path = "../ppvm-pauli-word" } +ppvm-pauli-sum = { version = "0.1.0", path = "../ppvm-pauli-sum" } +rayon = "1.11" +sprs = { version = "0.11", default-features = false, features = ["multi_thread"] } +# Matrix-exponential action (Al-Mohy & Higham). QuSpin-rust is MIT-licensed; +# the pinned rev is the commit that added the LICENSE file. +quspin-expm = { git = "https://github.com/QuSpin/QuSpin-rust", rev = "a0ad6c9fe2e8063208f9ba1c6677150c993bb554" } +# `QuSpinError` (the error type returned by the `LinearOperator` trait methods +# we implement in `mf_expm.rs`) is not re-exported from `quspin-expm`'s root, +# so we depend on `quspin-types` directly. Same git rev as `quspin-expm`. +quspin-types = { git = "https://github.com/QuSpin/QuSpin-rust", rev = "a0ad6c9fe2e8063208f9ba1c6677150c993bb554" } + +[dev-dependencies] +approx = "0.5.1" diff --git a/crates/ppvm-lindblad/src/config.rs b/crates/ppvm-lindblad/src/config.rs new file mode 100644 index 00000000..e2e36e1c --- /dev/null +++ b/crates/ppvm-lindblad/src/config.rs @@ -0,0 +1,58 @@ +//! Configuration objects for the predictor-corrector stepper. + +/// Truncation and execution policy for a single predictor-corrector step +/// ([`crate::LindbladSpec::pc_step`], [`crate::LindbladSpec::pc_step_timed`], +/// [`crate::orbit_rep::pc_step_orbit_rep`]). +/// +/// These are the per-run *tuning knobs*, kept separate from the per-call data +/// (`basis`, `coeffs`, `dt`, `protected`, and — on the orbit path — the +/// translation group and momentum). +/// +/// `max_basis` is the primary accuracy/cost dial; `admit_basis` selects the +/// displacement scheme; `drop_tol` is the churn valve of the admission-bound +/// scheme; `tau_add` is a wall optimization at most. +#[derive(Debug, Clone, Copy)] +pub struct PcStepConfig { + /// Hard rank cap on the retained basis: after the corrector, only the + /// top-`max_basis` strings by `|coeff|` are kept (protected words always + /// survive). The primary convergence dial — verify by re-running at 2×. + pub max_basis: usize, + /// Working-set (admission) bound. When `Some(a)` with `a > max_basis`, + /// enrichment may grow the live basis to `a` and the final cap performs a + /// genuine top-`max_basis`-of-union rank displacement (the analog of + /// two-site TDVP truncation at `χ_max`); `drop_tol` is then not needed + /// for membership turnover. `None` bounds admission by `max_basis` + /// itself — the valve scheme, which requires `drop_tol > 0` to keep the + /// basis adapting once it fills. + pub admit_basis: Option, + /// Magnitude prune applied after the corrector: basis entries whose + /// `|coeff|` is below `drop_tol` are discarded (protected words are + /// always kept). `<= 0.0` disables pruning — valid only with + /// `admit_basis` set, otherwise the basis freezes once it fills the cap. + pub drop_tol: f64, + /// Optional absolute rate threshold on leakage admission: a candidate is + /// admitted only if its inflow rate exceeds `tau_add`. This is the + /// natural (dt- and drop_tol-independent) parameterization — the + /// admission accuracy cliff sits at a fixed `tau_add`. `None` = no + /// filter, the recommended default with cap-based truncation. + pub tau_add: Option, + /// When `Some(n)`, run the entire step inside a freshly built rayon + /// thread pool of `n` threads (useful for benchmarking parallel + /// scaling). When `None`, the global rayon pool is used. + pub num_threads: Option, +} + +impl Default for PcStepConfig { + /// Uncapped, unfiltered, no pruning: the near-exact reference + /// configuration. Production runs should set `max_basis` (and usually + /// `admit_basis ≈ 2-3×` it). + fn default() -> Self { + Self { + max_basis: usize::MAX, + admit_basis: None, + drop_tol: 0.0, + tau_add: None, + num_threads: None, + } + } +} diff --git a/crates/ppvm-lindblad/src/error.rs b/crates/ppvm-lindblad/src/error.rs new file mode 100644 index 00000000..e678a552 --- /dev/null +++ b/crates/ppvm-lindblad/src/error.rs @@ -0,0 +1,73 @@ +//! Error type for [`crate::LindbladSpec`] construction and stepping. + +use crate::MAX_QUBITS; +use std::fmt; + +/// Errors raised when constructing a [`crate::LindbladSpec`]. +#[derive(Debug, Clone)] +pub enum Error { + TooManyQubits { + got: usize, + }, + LengthMismatch { + what: &'static str, + a: usize, + b: usize, + }, + InvalidPauliCode { + code: u8, + }, + InvalidPauliChar { + c: char, + }, + WrongLength { + expected: usize, + got: usize, + }, + NegativeRate { + index: usize, + rate: f64, + }, + EmptyLincomb { + index: usize, + }, + Internal(String), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::TooManyQubits { got } => { + write!( + f, + "LindbladSpec supports n_qubits ≤ {MAX_QUBITS}; got {got}" + ) + } + Error::LengthMismatch { what, a, b } => { + write!(f, "{what}: expected matching lengths, got {a} and {b}") + } + Error::InvalidPauliCode { code } => write!( + f, + "Pauli code must be 0 (I), 1 (X), 2 (Z), or 3 (Y); got {code}" + ), + Error::InvalidPauliChar { c } => { + write!(f, "invalid Pauli character '{c}'; expected I, X, Y, or Z") + } + Error::WrongLength { expected, got } => { + write!(f, "Pauli string has length {got} but n_qubits = {expected}") + } + Error::NegativeRate { index, rate } => { + write!(f, "jump rate must be non-negative; got γ_{index} = {rate}") + } + Error::EmptyLincomb { index } => { + write!( + f, + "jump {index}: lincomb must contain at least one Pauli term" + ) + } + Error::Internal(msg) => write!(f, "internal error: {msg}"), + } + } +} + +impl std::error::Error for Error {} diff --git a/crates/ppvm-lindblad/src/expm.rs b/crates/ppvm-lindblad/src/expm.rs new file mode 100644 index 00000000..f203f4f5 --- /dev/null +++ b/crates/ppvm-lindblad/src/expm.rs @@ -0,0 +1,145 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Taylor-partition selection for the `quspin-expm`-backed `exp(t·A)·b` +//! engine (driven from [`crate::mf_expm`]): the `(m, s)` selection tables +//! [`THETA`] / [`THETA_LOOSE`] from Al-Mohy & Higham (2011), used to pick +//! the partition handed to `quspin-expm`'s `from_parts`. + +/// `θ_m` table from Al-Mohy & Higham (2011), Table A.3, for double +/// precision (unit roundoff `u = 2^{-53}`). +/// +/// `θ_m` bounds `‖A‖₁` such that the degree-`m` Taylor polynomial +/// approximates `exp(A)` to within `u`. We pick `(m, s)` with +/// `s ≥ ⌈‖tA‖₁ / θ_m⌉` and minimise `m·s` (total SpMV count). +pub(crate) const THETA: &[(u32, f64)] = &[ + (1, 2.29e-16), + (2, 2.58e-8), + (3, 1.39e-5), + (4, 3.40e-4), + (5, 2.40e-3), + (6, 9.07e-3), + (7, 2.38e-2), + (8, 5.00e-2), + (9, 8.96e-2), + (10, 1.44e-1), + (11, 2.14e-1), + (12, 3.00e-1), + (13, 4.00e-1), + (14, 5.14e-1), + (15, 6.41e-1), + (16, 7.81e-1), + (17, 9.31e-1), + (18, 1.09), + (19, 1.26), + (20, 1.44), + (21, 1.62), + (22, 1.82), + (23, 2.01), + (24, 2.22), + (25, 2.43), + (26, 2.64), + (27, 2.86), + (28, 3.08), + (29, 3.31), + (30, 3.54), +]; + +/// `θ_m` table for a relaxed backward-error tolerance `tol = 1e-6`, computed +/// with the same Al-Mohy & Higham (2011) construction as [`THETA`] (the +/// backward-error series `h_{m+1}(x) = log(e^{-x} T_m(x))`; validated by +/// reproducing the `u = 2^{-53}` table above to ~2 significant figures). +/// +/// The predictor-corrector truncates the Pauli basis at `drop_tol` (typically +/// 1e-3), so computing `exp` to double-precision backward error (~1e-16) is +/// ~10 orders more accurate than the state it acts on. Using `tol = 1e-6` +/// (still ~1000x tighter than the truncation) admits a lower-degree Taylor +/// polynomial for the same `‖tA‖`, cutting the SpMV count (e.g. 23 -> 13 at +/// `‖tA‖ ≈ 2`) with no measurable effect on the truncated result. +pub(crate) const THETA_LOOSE: &[(u32, f64)] = &[ + (1, 2.000e-06), + (2, 2.447e-03), + (3, 2.863e-02), + (4, 1.025e-01), + (5, 2.262e-01), + (6, 3.911e-01), + (7, 5.866e-01), + (8, 8.045e-01), + (9, 1.039), + (10, 1.285), + (11, 1.539), + (12, 1.801), + (13, 2.067), + (14, 2.337), + (15, 2.610), + (16, 2.885), + (17, 3.162), + (18, 3.441), + (19, 3.721), + (20, 4.001), + (21, 4.282), + (22, 4.564), + (23, 4.847), + (24, 5.129), + (25, 5.412), + (26, 5.696), + (27, 5.979), + (28, 6.263), + (29, 6.546), + (30, 6.830), +]; + +/// Pick `(m, s)` minimising `s·m` subject to `s ≥ ⌈t_norm / θ_m⌉, s ≥ 1`, +/// using the `θ_m` table `theta`. Restricted to the table's `m` range; for +/// larger norms `s` simply grows linearly. +fn select_ms_with(t_norm: f64, theta: &[(u32, f64)]) -> (u32, u32) { + if t_norm <= 0.0 { + return (1, 1); + } + let mut best_m = 1u32; + let mut best_s = 1u32; + let mut best_cost = u64::MAX; + for &(m, th) in theta { + let s_f = (t_norm / th).ceil(); + let s = if s_f >= 1.0 { s_f as u32 } else { 1 }; + let cost = (m as u64) * (s as u64); + if cost < best_cost { + best_cost = cost; + best_m = m; + best_s = s; + } + } + (best_m, best_s) +} + +/// `(m, s)` selection at double-precision backward error ([`THETA`]). +pub(crate) fn select_ms(t_norm: f64) -> (u32, u32) { + select_ms_with(t_norm, THETA) +} + +/// `(m, s)` selection at the relaxed `tol = 1e-6` backward error +/// ([`THETA_LOOSE`]) — fewer SpMVs, used on the truncated PC expm path. +pub(crate) fn select_ms_loose(t_norm: f64) -> (u32, u32) { + select_ms_with(t_norm, THETA_LOOSE) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ms_selection_sane() { + // tiny norm → small m, s = 1 + let (m, s) = select_ms(1e-9); + assert!(m <= 5, "expected small m for tiny norm, got m={m}"); + assert_eq!(s, 1); + + // moderate norm → m·s should be ~10-50 + let (m, s) = select_ms(1.0); + assert!((m * s) <= 50, "moderate norm cost too high: m={m} s={s}"); + + // large norm → s grows + let (_m, s) = select_ms(100.0); + assert!(s >= 20, "large norm should require many steps, got s={s}"); + } +} diff --git a/crates/ppvm-lindblad/src/lib.rs b/crates/ppvm-lindblad/src/lib.rs new file mode 100644 index 00000000..49b5efbf --- /dev/null +++ b/crates/ppvm-lindblad/src/lib.rs @@ -0,0 +1,1393 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Direct Heisenberg-picture Lindbladian evolution on an adaptive +//! Pauli-string basis. +//! +//! For a Hermitian Pauli Hamiltonian `H = Σ c_i P_i` and jump operators +//! `L_k = Σ_a λ_{k,a} P_{k,a}` (each a Hermitian-Pauli linear combination +//! with possibly complex coefficients) with rates `γ_k ≥ 0`, the adjoint +//! Lindbladian acts on a single Pauli string `p` as +//! +//! ```text +//! L*(p) = i [H, p] + Σ_k γ_k ( L_k† p L_k − 1/2 {L_k† L_k, p} ). +//! ``` +//! +//! Two jump shapes are supported with separate code paths: +//! +//! - **Hermitian Pauli** (`L = P`, `λ ∈ ℝ`): the dissipator collapses to a +//! diagonal `-2γ` on Pauli strings that anti-commute with `P`. Same fast +//! path used by every dephasing-style model. +//! +//! - **General** (complex `λ_a`, e.g. `σ± = (X ± iY)/2`): the dissipator +//! becomes a double sum `Σ_{a,b} λ_a* λ_b P_a p P_b` plus a Pauli- +//! linear-combination anti-commutator with `L†L`, which is precomputed +//! once at construction. Intermediate coefficients are complex; the +//! result is real because `L*` preserves Hermiticity, so we cast back +//! to `f64` at the boundary (with a debug-only check that `|Im|` is at +//! FP noise). +//! +//! Pauli strings are stored as [`ppvm_pauli_word::word::PauliWord`] backed by +//! `[u64; 2]` (≤128 qubits) with cached hashes for fast HashMap lookup. The +//! hot-path commutator/product loops bypass the higher-level word API and +//! operate directly on raw `u64` chunks for speed. + +use fxhash::{FxBuildHasher, FxHashMap, FxHashSet}; +use num::Complex; +use ppvm_traits::PauliWordTrait; +use ppvm_pauli_word::word::PauliWord; +use rayon::prelude::*; +use std::time::Instant; + +pub mod config; +pub mod error; +pub(crate) mod expm; + +/// Matrix-free / quspin-expm-backed `exp(dt·L*)·b` engine. See module docs. +pub(crate) mod mf_expm; + +/// Per-step orbit-rep evolution under translation symmetry, with a +/// phase-aware complex action. See module docs. +pub mod orbit_rep; + +/// Words pack up to 128 qubits. +const W_U64: usize = 2; + +/// Maximum number of qubits supported by [`Word`] (= `8 · W_U64 · sizeof(u64)`). +pub const MAX_QUBITS: usize = 64 * W_U64; + +/// The Pauli-word storage type used throughout this crate. +/// +/// `[u64; 2]` covers up to 128 qubits; the `FxBuildHasher` matches the +/// hash used by the `FxHashMap` keys we wrap with; `REHASH=true` means +/// `set()` keeps the cached hash in sync. +pub type Word = PauliWord<[u64; W_U64], FxBuildHasher, true>; + +pub use config::PcStepConfig; +pub use error::Error; + +/// Per-phase timing breakdown (microseconds) returned by +/// [`LindbladSpec::pc_step_timed`]. +#[derive(Default, Clone, Copy, Debug)] +pub struct PcStepTimings { + pub leakage1_us: u64, + pub expand1_us: u64, + pub expm1_us: u64, + pub leakage2_us: u64, + pub expand2_us: u64, + pub expm2_us: u64, +} + +impl PcStepTimings { + pub fn total_us(&self) -> u64 { + self.leakage1_us + + self.expand1_us + + self.expm1_us + + self.leakage2_us + + self.expand2_us + + self.expm2_us + } +} + +// ────────────────── codec helpers ────────────────── + +/// Build a [`Word`] from a length-`n_qubits` slice of Pauli codes +/// (`0=I, 1=X, 2=Z, 3=Y`). Sets all bits and rehashes once. +pub fn word_from_codes(codes: &[u8]) -> Result { + let n_qubits = codes.len(); + if n_qubits > MAX_QUBITS { + return Err(Error::TooManyQubits { got: n_qubits }); + } + let mut w = Word::new(n_qubits); + for (q, &b) in codes.iter().enumerate() { + if b > 3 { + return Err(Error::InvalidPauliCode { code: b }); + } + if b & 1 != 0 { + w.xbits.set(q, true); + } + if b & 2 != 0 { + w.zbits.set(q, true); + } + } + w.rehash(); + Ok(w) +} + +/// Inverse of [`word_from_codes`]: write `n_qubits` Pauli codes into `out`. +pub fn codes_from_word(w: &Word, out: &mut [u8]) { + debug_assert_eq!(out.len(), w.n_qubits()); + for (q, slot) in out.iter_mut().enumerate() { + let xb = w.xbits[q] as u8; + let zb = w.zbits[q] as u8; + *slot = xb | (zb << 1); + } +} + +/// Parse a `"IXYZ..."` string into a [`Word`] together with the list of +/// qubits where the Pauli is non-identity (the term's support). +pub fn parse_pauli_string(s: &str, n_qubits: usize) -> Result<(Word, Vec), Error> { + if n_qubits > MAX_QUBITS { + return Err(Error::TooManyQubits { got: n_qubits }); + } + let chars: Vec = s.chars().filter(|c| *c != '_').collect(); + if chars.len() != n_qubits { + return Err(Error::WrongLength { + expected: n_qubits, + got: chars.len(), + }); + } + let mut w = Word::new(n_qubits); + let mut support = Vec::new(); + for (q, c) in chars.into_iter().enumerate() { + match c { + 'I' => {} + 'X' => { + w.xbits.set(q, true); + support.push(q as u32); + } + 'Z' => { + w.zbits.set(q, true); + support.push(q as u32); + } + 'Y' => { + w.xbits.set(q, true); + w.zbits.set(q, true); + support.push(q as u32); + } + other => return Err(Error::InvalidPauliChar { c: other }), + } + } + w.rehash(); + Ok((w, support)) +} + +/// Compute the support (non-identity qubits) of `w`. +fn word_support(w: &Word, out: &mut Vec) { + out.clear(); + for q in 0..w.n_qubits() { + if w.xbits[q] || w.zbits[q] { + out.push(q as u32); + } + } +} + +// ────────────────── Pauli algebra ────────────────── +// +// Phase encoding: `Pauli product P·Q = ω · R` where `ω = i^phase` and +// `phase ∈ {0,1,2,3}` ↔ `ω ∈ {1, i, -1, -i}`. The per-byte XOR/AND +// formulas are the same ones used by `PhasedPauliWord::mul_assign` in +// `ppvm-runtime`. + +#[inline(always)] +fn phase_factor(phase: u8) -> Complex { + match phase & 3 { + 0 => Complex::new(1.0, 0.0), + 1 => Complex::new(0.0, 1.0), + 2 => Complex::new(-1.0, 0.0), + _ => Complex::new(0.0, -1.0), + } +} + +/// `true` if Pauli words `a` and `b` anti-commute. +/// +/// Two Pauli strings anti-commute iff +/// `popcount(a.x & b.z) + popcount(a.z & b.x)` is odd. +#[inline(always)] +fn anti_commutes(a: &Word, b: &Word) -> bool { + let mut bits: u32 = 0; + for i in 0..W_U64 { + bits += (a.xbits.data[i] & b.zbits.data[i]).count_ones(); + bits += (a.zbits.data[i] & b.xbits.data[i]).count_ones(); + } + bits & 1 == 1 +} + +/// Commutator product `h · p`: returns `(out, eps)` where `out = h ⊕ p` and +/// +/// - `eps = 0` if `h` and `p` commute (caller should skip — `[h,p] = 0`), +/// - `eps = -2.0` if `h·p` has phase `+i` (so `i·[h,p] = -2·out`), +/// - `eps = +2.0` if `h·p` has phase `-i` (so `i·[h,p] = +2·out`). +#[inline(always)] +fn comm_product(h: &Word, p: &Word) -> (Word, f64) { + let (out, phase) = pauli_mul(h, p); + let eps = match phase { + 1 => -2.0, + 3 => 2.0, + _ => 0.0, + }; + (out, eps) +} + +/// Full Pauli product `p · q`: returns `(out, phase)` where the product +/// is `ω · out` with `ω = i^phase`. +#[inline(always)] +fn pauli_mul(p: &Word, q: &Word) -> (Word, u8) { + let mut out = Word::new(p.n_qubits()); + let mut sign_count: u32 = 0; + let mut imag_count: u32 = 0; + for i in 0..W_U64 { + let a = p.xbits.data[i]; + let b = p.zbits.data[i]; + let c = q.xbits.data[i]; + let d = q.zbits.data[i]; + let sign = (a & b & c & !d) | (a & !b & !c & d) | (!a & b & c & d); + let imag = (a & !b & d) | (a & !c & d) | (!a & b & c) | (b & c & !d); + sign_count += sign.count_ones(); + imag_count += imag.count_ones(); + out.xbits.data[i] = a ^ c; + out.zbits.data[i] = b ^ d; + } + out.rehash(); + (out, ((2 * sign_count + imag_count) & 3) as u8) +} + +// ────────────────── Spec types ────────────────── + +/// Parsed Hamiltonian term. +#[derive(Clone)] +struct HTerm { + word: Word, + coeff: f64, +} + +/// One Pauli term in a complex linear combination (a single summand of +/// `L = Σ_a λ_a P_a` or of the precomputed `L†L`). +#[derive(Clone)] +struct PauliTerm { + word: Word, + coeff: Complex, +} + +/// One jump operator `L_k` with rate `γ_k`. The `HermitianPauli` variant +/// is a fast path; `General` handles arbitrary complex Pauli sums. +#[derive(Clone)] +enum JumpKind { + HermitianPauli { + word: Word, + rate: f64, + }, + General { + terms: Vec, // L = Σ_a λ_a P_a + dagger_dagger: Vec, // L†L = Σ_c μ_c P_c (μ_c ∈ ℝ) + rate: f64, + }, +} + +/// Expand `L†L = (Σ_a λ_a P_a)† (Σ_b λ_b P_b) = Σ_{a,b} λ_a* λ_b P_a P_b` +/// as a Pauli linear combination, dropping FP-noise zeros. Coefficients are +/// real because `L†L` is Hermitian; we keep them complex for arithmetic +/// uniformity. +fn precompute_ldagger_l(terms: &[PauliTerm]) -> Vec { + let zero = Complex::new(0.0, 0.0); + let mut acc: FxHashMap> = FxHashMap::default(); + for a in terms { + for b in terms { + let (word, phase) = pauli_mul(&a.word, &b.word); + let coeff = a.coeff.conj() * b.coeff * phase_factor(phase); + *acc.entry(word).or_insert(zero) += coeff; + } + } + acc.into_iter() + .filter(|(_, c)| c.norm() > 1e-14) + .map(|(word, coeff)| PauliTerm { word, coeff }) + .collect() +} + +/// Precompiled Lindbladian. Constructed once from string-form Hamiltonian +/// terms + jump operators; reused across many calls to [`Self::action`], +/// [`Self::leakage`], [`Self::generator`]. `L*(p)` is recomputed on every +/// call rather than cached: for sparse-local Hamiltonians a per-word cache +/// costs more than the recompute (hash lookup ≳ recompute) and its several +/// KB per cached word dominate memory at large basis sizes. +pub struct LindbladSpec { + n_qubits: usize, + h_terms: Vec, + j_kinds: Vec, + /// `h_support[q]` = indices of Hamiltonian terms acting on qubit `q`. + h_support: Vec>, + /// `j_support[q]` = indices of jumps whose support contains qubit `q`. + j_support: Vec>, +} + +/// User-facing description of one jump operator: a complex Pauli linear +/// combination together with its rate. +#[derive(Clone, Debug)] +pub struct JumpInput { + /// `(pauli_string, λ)` pairs forming `L_k = Σ_a λ_a P_a`. + pub lincomb: Vec<(String, Complex)>, + /// Non-negative GKSL rate `γ_k`. + pub rate: f64, +} + +impl LindbladSpec { + /// Construct a Lindbladian spec from Hamiltonian terms and jump operators. + /// + /// `h_terms` are `(pauli_string, coefficient)` pairs forming the Hermitian + /// Hamiltonian. Each jump operator is a complex Pauli linear combination; + /// a length-1 jump with imaginary part `0` is routed to the Hermitian-Pauli + /// fast path (with rate scaled by the squared real coefficient). + pub fn new( + n_qubits: usize, + h_terms: &[(String, f64)], + jumps: &[JumpInput], + ) -> Result { + if n_qubits > MAX_QUBITS { + return Err(Error::TooManyQubits { got: n_qubits }); + } + + let mut h_parsed: Vec = Vec::with_capacity(h_terms.len()); + let mut h_support_idx: Vec> = vec![Vec::new(); n_qubits]; + for (i, (s, c)) in h_terms.iter().enumerate() { + let (word, support) = parse_pauli_string(s, n_qubits)?; + for q in support { + h_support_idx[q as usize].push(i as u32); + } + h_parsed.push(HTerm { word, coeff: *c }); + } + + let mut j_kinds: Vec = Vec::with_capacity(jumps.len()); + let mut j_support_idx: Vec> = vec![Vec::new(); n_qubits]; + for (k, jump) in jumps.iter().enumerate() { + if jump.rate < 0.0 { + return Err(Error::NegativeRate { + index: k, + rate: jump.rate, + }); + } + if jump.lincomb.is_empty() { + return Err(Error::EmptyLincomb { index: k }); + } + + // Fast path: single-term, purely real → Hermitian Pauli. + if jump.lincomb.len() == 1 && jump.lincomb[0].1.im == 0.0 { + let (s, c) = &jump.lincomb[0]; + let (word, support) = parse_pauli_string(s, n_qubits)?; + for q in support { + j_support_idx[q as usize].push(k as u32); + } + j_kinds.push(JumpKind::HermitianPauli { + word, + rate: jump.rate * c.re * c.re, + }); + continue; + } + + // General path: parse all terms, precompute L†L, record union support. + let mut terms: Vec = Vec::with_capacity(jump.lincomb.len()); + let mut union_support: std::collections::BTreeSet = + std::collections::BTreeSet::new(); + for (s, c) in &jump.lincomb { + let (word, support) = parse_pauli_string(s, n_qubits)?; + for q in &support { + union_support.insert(*q); + } + terms.push(PauliTerm { word, coeff: *c }); + } + for q in union_support { + j_support_idx[q as usize].push(k as u32); + } + let dagger_dagger = precompute_ldagger_l(&terms); + j_kinds.push(JumpKind::General { + terms, + dagger_dagger, + rate: jump.rate, + }); + } + + Ok(Self { + n_qubits, + h_terms: h_parsed, + j_kinds, + h_support: h_support_idx, + j_support: j_support_idx, + }) + } + + pub fn n_qubits(&self) -> usize { + self.n_qubits + } + + pub fn num_h_terms(&self) -> usize { + self.h_terms.len() + } + + pub fn num_jump_terms(&self) -> usize { + self.j_kinds.len() + } + + /// Apply `L*` to a single Pauli string `p`. Returns the output Pauli + /// strings and their real coefficients (zero entries omitted). + pub fn action(&self, p: &Word) -> Vec<(Word, f64)> { + let mut out: FxHashMap = FxHashMap::default(); + let mut s1 = Vec::new(); + let mut s2 = Vec::new(); + self.accumulate_action(p, 1.0, &mut out, &mut s1, &mut s2); + out.into_iter().filter(|(_, c)| *c != 0.0).collect() + } + + /// Off-basis component of `L*( Σ_j coeffs[j] · basis[j] )`. Output + /// strings that lie in `basis` or in `protected` are dropped. + pub fn leakage( + &self, + basis: &[Word], + coeffs: &[f64], + protected: &[Word], + ) -> Result, Error> { + self.leakage_with_prune(basis, coeffs, protected, usize::MAX, 0.0) + } + + /// Like [`Self::leakage`], but caps the live off-basis leakage map to + /// the *available room* `room = max_basis − basis.len()` — only the + /// strings we could actually add to the basis are worth keeping. The + /// cap is applied during accumulation (after each chunk), keeping the + /// `room` largest-magnitude entries. + /// + /// Basis indices are processed in descending-`|c|` order so the + /// running cap keeps the entries that are most likely to be the true + /// largest contributors. When `max_basis` is large enough that + /// `room ≥ all candidates`, nothing is dropped — the near-exact case. + pub fn leakage_with_prune( + &self, + basis: &[Word], + coeffs: &[f64], + protected: &[Word], + max_basis: usize, + tau_add: f64, + ) -> Result, Error> { + if basis.len() != coeffs.len() { + return Err(Error::LengthMismatch { + what: "basis and coeffs", + a: basis.len(), + b: coeffs.len(), + }); + } + // Hash-only membership tables: storing 8-byte `u64` keys instead + // of 48-byte Words shrinks the in-basis structure ~6×, keeping it + // in L3 (and often L2) at basis sizes where the full-Word version + // would spill to DRAM. + let in_basis: FxHashMap = basis.iter().map(|w| (word_hash(w), ())).collect(); + let protected_set: FxHashMap = + protected.iter().map(|w| (word_hash(w), ())).collect(); + + // Descending sort by |c|: process largest-magnitude contributors + // first so the running room-cap keeps the right entries. + let mut order: Vec = (0..basis.len()).collect(); + order.sort_by(|&a, &b| { + coeffs[b] + .abs() + .partial_cmp(&coeffs[a].abs()) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + const CHUNK_SIZE: usize = 4096; + let room = max_basis.saturating_sub(basis.len()); + let mut merged: FxHashMap = FxHashMap::default(); + for chunk_indices in order.chunks(CHUNK_SIZE) { + let local: Vec> = chunk_indices + .par_iter() + .map_init( + || { + ( + Vec::::with_capacity(self.n_qubits), + Vec::::with_capacity(128), + FxHashMap::>::with_capacity_and_hasher( + 128, + FxBuildHasher::default(), + ), + ) + }, + |(s1, s2, lm), &i| { + let p = &basis[i]; + let c = coeffs[i]; + let terms = self.compute_action_terms(p, s1, s2, lm); + let mut out = Vec::with_capacity(terms.len()); + for (w, v) in terms.iter() { + let h = word_hash(w); + if !in_basis.contains_key(&h) && !protected_set.contains_key(&h) { + out.push((*w, c * *v)); + } + } + out + }, + ) + .collect(); + for v in local { + for (k, val) in v { + *merged.entry(k).or_insert(0.0) += val; + } + } + + // Room-cap: keep only the `room` largest-magnitude entries. + if merged.len() > room { + if room == 0 { + merged.clear(); + } else { + let mut mags: Vec = merged.values().map(|v| v.abs()).collect(); + let k = room.min(mags.len() - 1); + mags.select_nth_unstable_by(k, |a, b| { + b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal) + }); + let cutoff = mags[k]; + merged.retain(|_, &mut v| v.abs() >= cutoff); + } + } + } + // Rate-based admission: keep only candidates whose leakage rate + // exceeds `tau_add`. `tau_add = 0` admits everything except exact + // zeros. + Ok(merged + .into_iter() + .filter(|(_, c)| c.abs() > tau_add) + .collect()) + } + + /// Sparse generator matrix in COO form: returns `(row, col, val)` + /// triplets. Row = output Pauli's position in `basis`; col = input + /// Pauli's position. Output Paulis not in `basis` are silently dropped. + /// + /// Precondition: `basis` must not contain duplicate Pauli words + /// (asserted in debug builds). + pub fn generator(&self, basis: &[Word]) -> Vec<(usize, usize, f64)> { + let index = build_basis_index(basis); + + // `compute_action_terms` returns a deduplicated `Vec<(Word, f64)>`, + // so it can be scattered directly into COO triplets. + let local: Vec> = basis + .par_iter() + .enumerate() + .map_init( + || { + ( + Vec::::with_capacity(self.n_qubits), + Vec::::with_capacity(128), + FxHashMap::>::with_capacity_and_hasher( + 128, + FxBuildHasher::default(), + ), + ) + }, + |(s1, s2, lm), (col, p)| { + let terms = self.compute_action_terms(p, s1, s2, lm); + let mut out = Vec::with_capacity(terms.len()); + for (w, v) in terms.iter() { + if let Some(&row) = index.get(w) { + out.push((row as usize, col, *v)); + } + } + out + }, + ) + .collect(); + + // Pre-allocate the flat output to avoid sequential push reallocation. + let total: usize = local.iter().map(|v| v.len()).sum(); + let mut flat = Vec::with_capacity(total); + for v in local { + flat.extend(v); + } + flat + } + + + /// Complex-coefficient variant of [`Self::leakage`]: off-basis + /// component of `L*( Σ_j coeffs[j] · basis[j] )` with complex `coeffs`. + pub fn leakage_complex( + &self, + basis: &[Word], + coeffs: &[Complex], + protected: &[Word], + ) -> Result)>, Error> { + if basis.len() != coeffs.len() { + return Err(Error::LengthMismatch { + what: "basis and coeffs", + a: basis.len(), + b: coeffs.len(), + }); + } + let in_basis: FxHashMap = + basis.iter().map(|w| (word_hash(w), ())).collect(); + let protected_set: FxHashMap = + protected.iter().map(|w| (word_hash(w), ())).collect(); + + const CHUNK_SIZE: usize = 4096; + let mut merged: FxHashMap> = FxHashMap::default(); + for chunk_start in (0..basis.len()).step_by(CHUNK_SIZE) { + let chunk_end = (chunk_start + CHUNK_SIZE).min(basis.len()); + let chunk_basis = &basis[chunk_start..chunk_end]; + let chunk_coeffs = &coeffs[chunk_start..chunk_end]; + let local: Vec)>> = chunk_basis + .par_iter() + .zip(chunk_coeffs.par_iter()) + .map_init( + || { + ( + Vec::::with_capacity(self.n_qubits), + Vec::::with_capacity(128), + FxHashMap::>::with_capacity_and_hasher( + 128, + FxBuildHasher::default(), + ), + ) + }, + |(s1, s2, lm), (p, &c)| { + let terms = self.compute_action_terms(p, s1, s2, lm); + let mut out = Vec::with_capacity(terms.len()); + for (w, v) in terms.iter() { + let h = word_hash(w); + if !in_basis.contains_key(&h) && !protected_set.contains_key(&h) { + out.push((*w, c * *v)); + } + } + out + }, + ) + .collect(); + for v in local { + for (k, val) in v { + *merged.entry(k).or_insert(Complex::new(0.0, 0.0)) += val; + } + } + } + Ok(merged.into_iter().filter(|(_, c)| c.norm() > 0.0).collect()) + } + + /// One predictor-corrector step `O ← exp(dt·L*) O` in the adaptive + /// real-coefficient Pauli basis: first-hop leakage admission, predictor + /// exponential, second-hop admission from the predicted state, corrector + /// exponential from the saved pre-step state, then truncation (prune + + /// rank cap) per [`PcStepConfig`]. Exact in `dt` within the working + /// basis — the only error is basis truncation. + /// + /// `protected` words are never dropped. All tuning knobs live in `cfg`. + pub fn pc_step( + &self, + basis: &mut Vec, + coeffs: &mut Vec, + dt: f64, + protected: &[Word], + cfg: &PcStepConfig, + ) -> Result<(), Error> { + if let Some(n) = cfg.num_threads { + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(n) + .build() + .map_err(|e| Error::Internal(format!("rayon pool build: {e}")))?; + pool.install(|| self.pc_step_inner(basis, coeffs, dt, protected, cfg)) + } else { + self.pc_step_inner(basis, coeffs, dt, protected, cfg) + } + } + + /// Same as [`Self::pc_step`] but also returns a per-phase timing + /// breakdown (microseconds), for profiling parallel scaling and hot + /// spots. + pub fn pc_step_timed( + &self, + basis: &mut Vec, + coeffs: &mut Vec, + dt: f64, + protected: &[Word], + cfg: &PcStepConfig, + ) -> Result { + if let Some(n) = cfg.num_threads { + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(n) + .build() + .map_err(|e| Error::Internal(format!("rayon pool build: {e}")))?; + pool.install(|| self.pc_step_inner_timed(basis, coeffs, dt, protected, cfg)) + } else { + self.pc_step_inner_timed(basis, coeffs, dt, protected, cfg) + } + } + + fn pc_step_inner_timed( + &self, + basis: &mut Vec, + coeffs: &mut Vec, + dt: f64, + protected: &[Word], + cfg: &PcStepConfig, + ) -> Result { + let PcStepConfig { max_basis, admit_basis, drop_tol, tau_add, .. } = *cfg; + let admit = admit_basis.unwrap_or(max_basis).max(max_basis); + let tau_add = tau_add.unwrap_or(0.0); + let mut t = PcStepTimings::default(); + + let t0 = Instant::now(); + let leak = self.leakage_with_prune(basis, coeffs, protected, admit, tau_add)?; + t.leakage1_us = t0.elapsed().as_micros() as u64; + + let t0 = Instant::now(); + add_leakage_capped(basis, coeffs, leak, admit); + t.expand1_us = t0.elapsed().as_micros() as u64; + + // `coeffs` is read-only in the predictor, so it also serves as the + // pre-step input for the corrector below — no clone needed. + let t0 = Instant::now(); + let coeffs_predict = self.expm_step(basis, dt, coeffs, drop_tol); + t.expm1_us = t0.elapsed().as_micros() as u64; + + let t0 = Instant::now(); + let leak2 = self.leakage_with_prune(basis, &coeffs_predict, protected, admit, tau_add)?; + t.leakage2_us = t0.elapsed().as_micros() as u64; + drop(coeffs_predict); + + let t0 = Instant::now(); + add_leakage_capped(basis, coeffs, leak2, admit); + t.expand2_us = t0.elapsed().as_micros() as u64; + + let t0 = Instant::now(); + *coeffs = self.expm_step(basis, dt, coeffs, drop_tol); + t.expm2_us = t0.elapsed().as_micros() as u64; + + prune_basis(basis, coeffs, drop_tol, protected); + cap_basis(basis, coeffs, max_basis, protected); + + Ok(t) + } + + fn pc_step_inner( + &self, + basis: &mut Vec, + coeffs: &mut Vec, + dt: f64, + protected: &[Word], + cfg: &PcStepConfig, + ) -> Result<(), Error> { + let PcStepConfig { max_basis, admit_basis, drop_tol, tau_add, .. } = *cfg; + // Admission bound: enrichment may grow the live basis to `admit` + // >= `max_basis`; the final `cap_basis` then keeps the top- + // `max_basis` strings by evolved |coeff| over the whole union + // (retained + admitted) — rank displacement. With `admit_basis = + // None` admission is bounded by `max_basis` itself, `cap_basis` is + // a no-op, and membership turnover requires `drop_tol > 0`. + let admit = admit_basis.unwrap_or(max_basis).max(max_basis); + let tau_add = tau_add.unwrap_or(0.0); + // 1. First-hop expansion. After this, `coeffs` contains the pre-step + // coefficients followed by zeros for the newly-added leakage strings. + // We rely on `coeffs` itself as the pre-step buffer for the corrector + // — no `.clone()` is needed because `expm_step` only borrows it. + let leak = self.leakage_with_prune(basis, coeffs, protected, admit, tau_add)?; + add_leakage_capped(basis, coeffs, leak, admit); + // 2. Predictor: `expm_step` reads `coeffs` immutably and returns a + // new owned vector with the predicted state. + let coeffs_predict = self.expm_step(basis, dt, coeffs, drop_tol); + // 3. Second-hop expansion from the predicted state. After leakage2 + // we no longer need `coeffs_predict`. Extend `coeffs` with zeros for + // any newly-added second-hop strings so it remains a valid input + // (pre-step state) for the corrector. + let leak2 = self.leakage_with_prune(basis, &coeffs_predict, protected, admit, tau_add)?; + drop(coeffs_predict); + add_leakage_capped(basis, coeffs, leak2, admit); + // 4. Corrector: redo from pre-step state on the doubly-enlarged basis. + *coeffs = self.expm_step(basis, dt, coeffs, drop_tol); + // 5. Prune basis entries below `drop_tol` (protected words never dropped). + prune_basis(basis, coeffs, drop_tol, protected); + cap_basis(basis, coeffs, max_basis, protected); + Ok(()) + } + + /// Compute `exp(dt · M) · b` for the in-basis-restricted generator + /// `M`, matrix-free, via `quspin-expm` (see [`mf_expm`]). + fn expm_step( + &self, + basis: &[Word], + dt: f64, + b: &[f64], + drop_tol: f64, + ) -> Vec { + mf_expm::expm_apply_mf(self, basis, dt, b, drop_tol) + } + + /// Compute the unscaled list of `(output, coefficient)` pairs that + /// `L*(p)` contributes (without the input coefficient). + pub(crate) fn compute_action_terms( + &self, + p: &Word, + scratch_support: &mut Vec, + scratch_cands: &mut Vec, + scratch_local: &mut FxHashMap>, + ) -> Vec<(Word, f64)> { + word_support(p, scratch_support); + let zero = Complex::new(0.0, 0.0); + scratch_local.clear(); + let local = scratch_local; + + // ── i [H, p] ───────────────────────────────────────────────── + candidate_terms(scratch_support, &self.h_support, scratch_cands); + for &i in scratch_cands.iter() { + let h = &self.h_terms[i as usize]; + let (r, eps) = comm_product(&h.word, p); + if eps != 0.0 { + *local.entry(r).or_insert(zero) += Complex::new(h.coeff * eps, 0.0); + } + } + + // ── dissipator ─────────────────────────────────────────────── + candidate_terms(scratch_support, &self.j_support, scratch_cands); + for &k in scratch_cands.iter() { + match &self.j_kinds[k as usize] { + JumpKind::HermitianPauli { word, rate } => { + if anti_commutes(word, p) { + *local.entry(*p).or_insert(zero) += Complex::new(-2.0 * *rate, 0.0); + } + } + JumpKind::General { + terms, + dagger_dagger, + rate, + } => { + let rate_c = Complex::new(*rate, 0.0); + // Sandwich: γ Σ_{a,b} λ_a* λ_b P_a p P_b. + for a in terms { + let (r_ap, phi1) = pauli_mul(&a.word, p); + for b in terms { + let (s, phi2) = pauli_mul(&r_ap, &b.word); + let coeff = + a.coeff.conj() * b.coeff * phase_factor(phi1 + phi2) * rate_c; + *local.entry(s).or_insert(zero) += coeff; + } + } + // -1/2 γ {L†L, p}. For Hermitian Pauli P_c and Pauli p, + // {P_c, p} = 2·sign·R if they commute (P_c·p = sign·R), + // = 0 if they anti-commute. + for c_term in dagger_dagger { + let (r, phase) = pauli_mul(&c_term.word, p); + if phase & 1 == 0 { + let sign = if phase == 0 { 1.0 } else { -1.0 }; + let coeff = -c_term.coeff * rate_c * Complex::new(sign, 0.0); + *local.entry(r).or_insert(zero) += coeff; + } + } + } + } + } + + // L* preserves Hermiticity; imaginary parts must cancel to FP noise. + // `drain()` empties `scratch_local` so its allocation can be reused + // by the next call on the same thread (`Vec` keeps capacity). + local + .drain() + .filter_map(|(w, c)| { + debug_assert!( + c.im.abs() < 1e-9, + "L*(p) produced non-real coefficient {c}; bug in dissipator" + ); + if c.re == 0.0 { None } else { Some((w, c.re)) } + }) + .collect() + } + + /// Accumulate `scale · L*(p)` into `out`. + fn accumulate_action( + &self, + p: &Word, + scale: f64, + out: &mut FxHashMap, + scratch_support: &mut Vec, + scratch_cands: &mut Vec, + ) { + let mut scratch_local = FxHashMap::default(); + let terms = + self.compute_action_terms(p, scratch_support, scratch_cands, &mut scratch_local); + for (w, c) in terms.iter() { + *out.entry(*w).or_insert(0.0) += scale * c; + } + } +} + +/// Compact 64-bit hash of a [`Word`], used as the key in cache-friendly +/// membership tables: an `FxHashMap` over the basis has a working +/// set ~6× smaller than `FxHashMap`. The hash mixes the word's +/// cached hash once through `FxHasher` and never touches the 32-byte +/// payload. +#[inline(always)] +fn word_hash(w: &Word) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = fxhash::FxHasher::default(); + w.hash(&mut h); + h.finish() +} + +/// Compact `basis` / `coeffs` in place: drop entries whose absolute +/// coefficient is below `drop_tol` unless the word appears in `protected`. +/// No-op when `drop_tol ≤ 0`. +fn prune_basis(basis: &mut Vec, coeffs: &mut Vec, drop_tol: f64, protected: &[Word]) { + if drop_tol <= 0.0 { + return; + } + debug_assert_eq!(basis.len(), coeffs.len()); + let protected_set: FxHashSet<&Word> = protected.iter().collect(); + let mut write = 0; + for read in 0..basis.len() { + if coeffs[read].abs() >= drop_tol || protected_set.contains(&basis[read]) { + if write != read { + basis.swap(write, read); + coeffs.swap(write, read); + } + write += 1; + } + } + basis.truncate(write); + coeffs.truncate(write); +} + +/// Global max-basis cap (PauliStrings.jl-style top-M trim): keep only the +/// `max_basis` largest-|coeff| terms (protected strings always kept), +/// dropping the rest. Rank-based total-basis bound; dual of `drop_tol`. +/// A `max_basis` large enough to cover the whole basis is a no-op. +fn cap_basis(basis: &mut Vec, coeffs: &mut Vec, max_basis: usize, protected: &[Word]) { + if basis.len() <= max_basis { + return; + } + let protected_set: FxHashSet<&Word> = protected.iter().collect(); + let n_prot = basis.iter().filter(|w| protected_set.contains(w)).count(); + let slots = max_basis.saturating_sub(n_prot); + let mut mags: Vec = basis + .iter() + .zip(coeffs.iter()) + .filter(|(w, _)| !protected_set.contains(w)) + .map(|(_, c)| c.abs()) + .collect(); + let cutoff = if slots == 0 { + f64::INFINITY + } else if slots >= mags.len() { + return; + } else { + let k = slots - 1; + mags.select_nth_unstable_by(k, |a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)); + mags[k] + }; + let mut write = 0; + for read in 0..basis.len() { + if protected_set.contains(&basis[read]) || coeffs[read].abs() >= cutoff { + if write != read { + basis.swap(write, read); + coeffs.swap(write, read); + } + write += 1; + } + } + basis.truncate(write); + coeffs.truncate(write); +} + +/// Add the largest leakage strings to the basis, up to the available room +/// `room = max_basis − basis.len()` — so the in-step basis (hence the +/// expm/leakage peak memory) never exceeds `max_basis`. New strings get +/// coefficient 0; the surrounding expm fills them. No magnitude filter: the +/// top-`room` by `|leakage|` are added (a large `max_basis` adds them all). +fn add_leakage_capped( + basis: &mut Vec, + coeffs: &mut Vec, + mut leak: Vec<(Word, f64)>, + max_basis: usize, +) { + let room = max_basis.saturating_sub(basis.len()); + if leak.len() > room { + if room > 0 { + leak.select_nth_unstable_by(room - 1, |a, b| { + b.1.abs().partial_cmp(&a.1.abs()).unwrap_or(std::cmp::Ordering::Equal) + }); + } + leak.truncate(room); + } + for (w, _) in leak { + basis.push(w); + coeffs.push(0.0); + } +} + +/// Build a `word → row` map for a basis assumed to contain unique Pauli +/// words; debug-asserts the uniqueness invariant. +pub fn build_basis_index(basis: &[Word]) -> FxHashMap { + let mut index: FxHashMap = FxHashMap::default(); + for (i, w) in basis.iter().enumerate() { + let prev = index.insert(*w, i as u32); + debug_assert!( + prev.is_none(), + "basis contains duplicate Pauli word at positions {} and {}", + prev.unwrap(), + i, + ); + } + index +} + +/// Union of `index[q]` for each `q ∈ p_support`, deduped. +#[inline] +fn candidate_terms(p_support: &[u32], index: &[Vec], scratch: &mut Vec) { + scratch.clear(); + for &q in p_support { + scratch.extend_from_slice(&index[q as usize]); + } + scratch.sort_unstable(); + scratch.dedup(); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Test-only full-space complex predictor-corrector step, UNTRUNCATED: + /// adds every nonzero leakage string (two hops) and applies the exact + /// in-basis exponential to the complex coefficient vector. Reference + /// bridge between the real `pc_step` and the orbit-rep path. + fn pc_step_complex_full( + spec: &LindbladSpec, + basis: &mut Vec, + coeffs: &mut Vec>, + dt: f64, + ) { + let protected: Vec = Vec::new(); + let leak = spec.leakage_complex(basis, coeffs, &protected).unwrap(); + for (w, v) in leak { + if v.norm() > 0.0 { + basis.push(w); + coeffs.push(Complex::new(0.0, 0.0)); + } + } + let predict = mf_expm::expm_apply_mf_cxvec(spec, basis, dt, coeffs, 0.0); + let leak2 = spec.leakage_complex(basis, &predict, &protected).unwrap(); + drop(predict); + for (w, v) in leak2 { + if v.norm() > 0.0 { + basis.push(w); + coeffs.push(Complex::new(0.0, 0.0)); + } + } + *coeffs = mf_expm::expm_apply_mf_cxvec(spec, basis, dt, coeffs, 0.0); + } + + fn jump_hpauli(s: &str, rate: f64) -> JumpInput { + JumpInput { + lincomb: vec![(s.to_string(), Complex::new(1.0, 0.0))], + rate, + } + } + + #[test] + fn z_dephasing_action_on_x() { + // L = Z on a single qubit; L*(X) = γ(ZXZ - X) = γ(-X - X) = -2γ X. + let spec = LindbladSpec::new( + 1, + &[("X".to_string(), 0.0)], // no Hamiltonian + &[jump_hpauli("Z", 0.5)], + ) + .unwrap(); + let (x, _) = parse_pauli_string("X", 1).unwrap(); + let terms = spec.action(&x); + assert_eq!(terms.len(), 1); + assert!((terms[0].1 - (-1.0)).abs() < 1e-12); // -2·0.5 = -1 + } + + #[test] + fn amplitude_damping_action_on_z() { + // Single-qubit σ⁻ jump: L*(Z) = -γ(I + Z). With γ=1 we expect + // I coefficient = -1, Z coefficient = -1. + let sigma_minus = JumpInput { + lincomb: vec![ + ("X".to_string(), Complex::new(0.5, 0.0)), + ("Y".to_string(), Complex::new(0.0, -0.5)), + ], + rate: 1.0, + }; + let spec = LindbladSpec::new(1, &[], &[sigma_minus]).unwrap(); + let (z, _) = parse_pauli_string("Z", 1).unwrap(); + let terms = spec.action(&z); + let (i_word, _) = parse_pauli_string("I", 1).unwrap(); + let mut i_coeff = 0.0; + let mut z_coeff = 0.0; + for (w, c) in &terms { + if w == &i_word { + i_coeff = *c; + } else if w == &z { + z_coeff = *c; + } + } + assert!((i_coeff - (-1.0)).abs() < 1e-10, "I coeff = {i_coeff}"); + assert!((z_coeff - (-1.0)).abs() < 1e-10, "Z coeff = {z_coeff}"); + } + + #[test] + fn word_codec_roundtrip() { + let codes = [0u8, 1, 2, 3, 1, 0, 3, 2]; + let w = word_from_codes(&codes).unwrap(); + let mut out = vec![0u8; codes.len()]; + codes_from_word(&w, &mut out); + assert_eq!(out.as_slice(), &codes); + } + + /// Per-step orbit-rep evolution gives the SAME final orbit-rep + /// state as full-basis complex evolution followed by a single + /// projection at the end. Validates that the phase-aware complex + /// action machinery is consistent with the full-basis reference. + #[test] + fn pc_step_orbit_rep_matches_full_basis_projection() { + use std::f64::consts::PI; + use ppvm_pauli_sum::symmetry::canonicalize_pauli_sum_complex; + let n = 4usize; + let dt = 0.01f64; + let n_steps = 3usize; + let mut h_terms: Vec<(String, f64)> = Vec::new(); + for j in 0..n { + let nxt = (j + 1) % n; + for op in ["X", "Y"] { + let mut s = vec!['I'; n]; + s[j] = op.chars().next().unwrap(); + s[nxt] = op.chars().next().unwrap(); + h_terms.push((s.into_iter().collect(), 1.0)); + } + } + let spec = LindbladSpec::new(n, &h_terms, &[]).unwrap(); + let group = ppvm_pauli_sum::symmetry::TranslationGroup::chain_1d(n); + let k_mode: i32 = 1; + let k = vec![k_mode]; + + // Build the k=1 eigenstate in FULL basis form. + let basis_full: Vec = (0..n) + .map(|j| { + let mut s = vec!['I'; n]; + s[j] = 'Z'; + let (w, _) = parse_pauli_string(&s.into_iter().collect::(), n).unwrap(); + w + }) + .collect(); + let coeffs_full: Vec> = (0..n as i32) + .map(|a| Complex::from_polar(1.0, -2.0 * PI * (k_mode as f64) * (a as f64) / (n as f64))) + .collect(); + + // ----- Full-basis path ----- + let mut bf = basis_full.clone(); + let mut cf = coeffs_full.clone(); + let protected: Vec = Vec::new(); + for _ in 0..n_steps { + // Full enrichment (tau_add = 0.0 adds every leakage string): + // for a momentum eigenstate the leakage is pure-sector, so the + // full-basis and orbit-rep paths build corresponding bases and + // the projection theorem gives an exact match. The orbit-rep + // side uses a large max_basis so its rank cap never binds. + pc_step_complex_full(&spec, &mut bf, &mut cf, dt); + } + // Project at the end. + canonicalize_pauli_sum_complex(&mut bf, &mut cf, &group, &k); + + // ----- Orbit-rep path ----- + // Initial orbit-rep form: project the full-basis input. + let mut br = basis_full.clone(); + let mut cr = coeffs_full.clone(); + canonicalize_pauli_sum_complex(&mut br, &mut cr, &group, &k); + // Evolve in orbit-rep form (max_basis large ⇒ full enrichment). + for _ in 0..n_steps { + orbit_rep::pc_step_orbit_rep( + &spec, + &mut br, + &mut cr, + dt, + &protected, + &group, + &k, + &PcStepConfig { max_basis: 10_000_000, ..Default::default() }, + ) + .unwrap(); + } + + // Compare. + let mf: FxHashMap> = bf.into_iter().zip(cf).collect(); + let mr: FxHashMap> = br.into_iter().zip(cr).collect(); + assert_eq!( + mf.len(), + mr.len(), + "orbit-rep ({}) and full-basis-projected ({}) basis sizes differ", + mr.len(), + mf.len() + ); + let mut max_diff = 0.0_f64; + for (w, cm) in &mr { + let cf_val = mf + .get(w) + .copied() + .unwrap_or_else(|| panic!("rep {:?} in orbit-rep but not in full-basis", w)); + max_diff = max_diff.max((cm - cf_val).norm()); + } + assert!( + max_diff < 1e-9, + "orbit-rep diverged from full-basis: max |Δc| = {max_diff:e}" + ); + } + + /// The full-space complex step at momentum k=0 must reproduce the real + /// pc_step on the same trajectory exactly. + #[test] + fn complex_full_matches_real_at_kzero() { + let n = 4usize; + let dt = 0.01f64; + let n_steps = 5usize; + let mut h_terms: Vec<(String, f64)> = Vec::new(); + for j in 0..n { + let nxt = (j + 1) % n; + for op in ["X", "Y"] { + let mut s = vec!['I'; n]; + s[j] = op.chars().next().unwrap(); + s[nxt] = op.chars().next().unwrap(); + h_terms.push((s.into_iter().collect(), 1.0)); + } + } + let spec = LindbladSpec::new(n, &h_terms, &[]).unwrap(); + + let mut basis_r: Vec = (0..n) + .map(|j| { + let mut s = vec!['I'; n]; + s[j] = 'Z'; + let st: String = s.into_iter().collect(); + let (w, _) = parse_pauli_string(&st, n).unwrap(); + w + }) + .collect(); + let mut coeffs_r: Vec = vec![1.0; n]; + + let mut basis_c = basis_r.clone(); + let mut coeffs_c: Vec> = + coeffs_r.iter().map(|&v| Complex::new(v, 0.0)).collect(); + + let protected: Vec = Vec::new(); + for _ in 0..n_steps { + // Large max_basis: rank cap never binds, so the real path + // enriches fully (adds every leakage string). Match the + // complex path by setting its tau_add=0.0 (also full + // enrichment) so the two stay in lock-step at k=0. + spec.pc_step( + &mut basis_r, + &mut coeffs_r, + dt, + &protected, + &PcStepConfig { max_basis: 10_000_000, ..Default::default() }, + ) + .unwrap(); + pc_step_complex_full(&spec, &mut basis_c, &mut coeffs_c, dt); + } + // Match as (word → coeff) maps. + let map_r: FxHashMap = + basis_r.into_iter().zip(coeffs_r).collect(); + let map_c: FxHashMap> = + basis_c.into_iter().zip(coeffs_c).collect(); + assert_eq!( + map_r.len(), + map_c.len(), + "real and complex pc_step produced different basis sizes ({} vs {})", + map_r.len(), + map_c.len() + ); + let mut max_diff = 0.0_f64; + for (w, cr) in &map_r { + let cc = map_c + .get(w) + .copied() + .unwrap_or_else(|| panic!("word {:?} in real but not complex", w)); + assert!(cc.im.abs() < 1e-10, "expected zero imag at k=0, got {cc:?}"); + max_diff = max_diff.max((cr - cc.re).abs()); + } + assert!( + max_diff < 1e-10, + "real vs complex pc_step diverged: max |Δc| = {max_diff:e}" + ); + } + + /// Small-system end-to-end check that orbit-rep merging gives the + /// same physics as standard evolution, when no truncation is applied. + /// + /// Setup: n=4 qubit chain, PBC, translation-invariant XY Hamiltonian + /// `H = Σ_j (X_j X_{j+1} + Y_j Y_{j+1})`, no dissipation. Initial + /// operator `O(0) = Σ_j Z_j` is translation-invariant (k=0 sector). + /// + /// Run 10 pc_step iterations with `drop_tol = 0` (no truncation): + /// once without merging, once applying `canonicalize_pauli_sum` + /// after each step. Canonicalize the un-merged final state once at + /// the end. The two orbit-rep representations should be + /// bit-identical up to FP noise. + #[test] + fn pc_step_matches_symmetry_merged_on_small_chain() { + use ppvm_pauli_sum::symmetry::{TranslationGroup, canonicalize_pauli_sum}; + + let n = 4usize; + let dt = 0.05f64; + let n_steps = 10usize; + + // Build XY-chain Hamiltonian with PBC. 8 terms (4 bonds × {XX, YY}). + let mut h_terms: Vec<(String, f64)> = Vec::new(); + for j in 0..n { + let nxt = (j + 1) % n; + for op in ["X", "Y"] { + let mut s = vec!['I'; n]; + s[j] = op.chars().next().unwrap(); + s[nxt] = op.chars().next().unwrap(); + h_terms.push((s.into_iter().collect(), 1.0)); + } + } + // No dissipation. + let spec = LindbladSpec::new(n, &h_terms, &[]).unwrap(); + let group = TranslationGroup::chain_1d(n); + + // Initial: O(0) = Σ_j Z_j (translation-invariant). + let mut basis_u: Vec = (0..n) + .map(|j| { + let mut s = vec!['I'; n]; + s[j] = 'Z'; + let st: String = s.into_iter().collect(); + let (w, _) = parse_pauli_string(&st, n).unwrap(); + w + }) + .collect(); + let mut coeffs_u: Vec = vec![1.0; n]; + + // Mirror state for the "with merging" run. + let mut basis_m = basis_u.clone(); + let mut coeffs_m = coeffs_u.clone(); + + let protected: Vec = Vec::new(); + for _ in 0..n_steps { + // max_basis == current basis size → room = 0: no leakage + // enrichment, only the expm step (the regime where merging + // commutes with evolution). drop_tol = 0 → no truncation. + let cfg_u = PcStepConfig { max_basis: basis_u.len(), ..Default::default() }; + spec.pc_step(&mut basis_u, &mut coeffs_u, dt, &protected, &cfg_u) + .unwrap(); + + let cfg_m = PcStepConfig { max_basis: basis_m.len(), ..Default::default() }; + spec.pc_step(&mut basis_m, &mut coeffs_m, dt, &protected, &cfg_m) + .unwrap(); + // Apply symmetry merging on the "with merging" run only. + canonicalize_pauli_sum(&mut basis_m, &mut coeffs_m, &group); + } + + // Canonicalize the un-merged final state once. + canonicalize_pauli_sum(&mut basis_u, &mut coeffs_u, &group); + + // Both representations should now be in orbit-rep form; compare + // as (word → coeff) maps with FP tolerance. + let map_u: FxHashMap = basis_u.into_iter().zip(coeffs_u).collect(); + let map_m: FxHashMap = basis_m.into_iter().zip(coeffs_m).collect(); + assert_eq!( + map_u.len(), + map_m.len(), + "merged basis size {} != post-merged-unmerged basis size {}", + map_m.len(), + map_u.len() + ); + let mut max_diff = 0.0f64; + for (w, c_u) in &map_u { + let c_m = map_m.get(w).copied().unwrap_or_else(|| { + panic!( + "rep {:?} present in un-merged-then-canonicalized but not in merged", + w + ); + }); + max_diff = max_diff.max((c_u - c_m).abs()); + } + assert!( + max_diff < 1e-9, + "with-merging vs without-merging diverged: max |Δc| = {max_diff:e}" + ); + } +} diff --git a/crates/ppvm-lindblad/src/mf_expm.rs b/crates/ppvm-lindblad/src/mf_expm.rs new file mode 100644 index 00000000..5dc61f18 --- /dev/null +++ b/crates/ppvm-lindblad/src/mf_expm.rs @@ -0,0 +1,309 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Matrix-free `exp(dt · L*) · b` for the real (`f64`) path, driven by the +//! external `quspin-expm` crate. +//! +//! Instead of materialising the in-basis-restricted generator as a CSR, the +//! per-column generator action is computed ONCE per expm call (via +//! [`build_mf_cols`]) and reused, CSC-style, across every Krylov/Taylor matvec +//! by [`CscOp`] (a [`quspin_types::LinearOperator`]) fed to +//! [`quspin_expm::ExpmOp::from_parts`]. Each matvec is then a cheap CSC +//! scatter; the Pauli-commutator action is never recomputed per matvec. +//! `from_parts` (rather than `ExpmOp::new`) supplies the diagonal shift `μ`, +//! the partition count `s`, and the truncation order `m*` directly, bypassing +//! quspin's adaptive parameter selection — so the 1-norm *estimator* and +//! `dot_transpose` are never invoked on the single-vector `apply` path; only +//! [`LinearOperator::dot`] runs. +//! +//! `μ`, the trace, and the exact column 1-norm of `A − μ·I` are computed in +//! the same single action pass as the cache. The `(m, s)` Taylor partition is +//! picked with the tolerance-matched tables in [`crate::expm`]: a relaxed +//! `tol=1e-6` table when the PC prunes coarsely (`drop_tol ≥ 1e-4`), else the +//! double-precision table (keeping the exact-reference test paths bit-exact). + +use crate::{LindbladSpec, Word, build_basis_index, expm}; +use fxhash::{FxBuildHasher, FxHashMap}; +use num::Complex; +use quspin_types::{ExpmComputation, LinearOperator, QuSpinError}; +use rayon::prelude::*; + +/// Per-column in-basis action of the real generator `M`, plus the data the +/// `(m, s)`/`μ` selection needs — all from ONE action pass over the basis. +/// +/// Returns `(cols, per_col)` where `cols[c]` holds `(row, coeff)` for every +/// action output of `L*(basis[c])` that lands back in `basis` (CSC column +/// `c`), and `per_col[c] = (raw, diag)` with `raw = Σ|coeff|` over ALL action +/// outputs (in- and out-of-basis, an upper bound on the column 1-norm) and +/// `diag` the coefficient of the output Word equal to the input Word. The +/// cache is reused by [`CscOp`] across every Krylov/Taylor matvec. Same +/// scratch pattern as [`crate::orbit_rep::build_orbit_rep_cols`]. +/// CSC columns of the cached in-basis action: `cols[c]` = `(row, coeff)`. +type MfCols = Vec>; +/// Per-column `(raw, diag)` for the `μ`/1-norm selection. +type MfPerCol = Vec<(f64, f64)>; + +fn build_mf_cols( + spec: &LindbladSpec, + basis: &[Word], + index: &FxHashMap, +) -> (MfCols, MfPerCol) { + basis + .par_iter() + .map_init( + || { + ( + Vec::::with_capacity(spec.n_qubits()), + Vec::::with_capacity(128), + FxHashMap::>::with_capacity_and_hasher( + 128, + FxBuildHasher::default(), + ), + ) + }, + |(s1, s2, lm), p| { + let terms = spec.compute_action_terms(p, s1, s2, lm); + let mut out = Vec::with_capacity(terms.len()); + let mut raw = 0.0; + let mut diag = 0.0; + for (w, c) in terms.iter() { + raw += c.abs(); + if w == p { + diag = *c; + } + if let Some(&row) = index.get(w) { + out.push((row, *c)); + } + } + (out, (raw, diag)) + }, + ) + .unzip() +} + +/// Borrowed CSC-style view of an in-basis-restricted generator `M`, backed +/// by a cached per-column action computed once per expm call +/// ([`build_mf_cols`] for the real path, +/// [`crate::orbit_rep::build_orbit_rep_cols`] for the complex orbit-rep +/// path). `dot` performs the CSC matvec `y = M·x` against the cache; the +/// remaining `LinearOperator` entry points are unused on the `from_parts` + +/// single-vector `apply` path. +/// +/// Borrowed, not owned: `quspin-types` provides a blanket `LinearOperator` +/// impl for `&T`, so `ExpmOp::from_parts(op, ...)` accepts a `CscOp` by +/// value while it keeps borrowing `cols`. +pub(crate) struct CscOp<'a, T> { + pub(crate) cols: &'a [Vec<(u32, T)>], + pub(crate) dim: usize, +} + +impl LinearOperator for CscOp<'_, T> +where + T: ExpmComputation + + Copy + + PartialEq + + num::Zero + + std::ops::AddAssign + + std::ops::Mul + + Send + + Sync, +{ + fn dim(&self) -> usize { + self.dim + } + + fn parallel_hint(&self) -> bool { + // `dot` parallelises internally over column chunks, and we drive the + // sequential single-vector `apply` path; never let quspin run its + // persistent-thread pool on top of our rayon parallelism. + false + } + + fn dot(&self, overwrite: bool, input: &[T], output: &mut [T]) -> Result<(), QuSpinError> { + let n = self.dim; + if n == 0 { + return Ok(()); + } + let num_threads = rayon::current_num_threads().max(1); + let chunk_size = n.div_ceil(num_threads); + + // Parallelise over column chunks; each thread accumulates into a dense + // local `y` of length `dim`, reading the cached action; the partials + // are reduced into `output` sequentially at the end. + let partial_ys: Vec> = self + .cols + .par_chunks(chunk_size) + .enumerate() + .map(|(chunk_idx, chunk)| { + let c_offset = chunk_idx * chunk_size; + let mut y_local = vec![T::zero(); n]; + for (c_local, col) in chunk.iter().enumerate() { + let xc = input[c_offset + c_local]; + if xc == T::zero() { + continue; + } + for &(row, val) in col.iter() { + y_local[row as usize] += val * xc; + } + } + y_local + }) + .collect(); + + if overwrite { + output.fill(T::zero()); + } + for partial in &partial_ys { + for (oi, &pi) in output.iter_mut().zip(partial.iter()) { + *oi += pi; + } + } + Ok(()) + } + + fn trace(&self) -> T { + // Computed eagerly by the callers; never reached on the + // `from_parts` + single-vector `apply` path. + unreachable!("CscOp::trace not used on the from_parts apply path") + } + + fn onenorm(&self, _shift: T) -> ::Real { + unreachable!("CscOp::onenorm not used on the from_parts apply path") + } + + fn dot_transpose( + &self, + _overwrite: bool, + _input: &[T], + _output: &mut [T], + ) -> Result<(), QuSpinError> { + Err(QuSpinError::RuntimeError( + "CscOp: dot_transpose not used on the from_parts apply path".into(), + )) + } + + fn dot_many( + &self, + _overwrite: bool, + _input: ndarray::ArrayView2<'_, T>, + _output: ndarray::ArrayViewMut2<'_, T>, + ) -> Result<(), QuSpinError> { + Err(QuSpinError::RuntimeError( + "CscOp: dot_many not used on the from_parts apply path".into(), + )) + } + + fn dot_chunk( + &self, + _overwrite: bool, + _input: &[T], + _output_chunk: &mut [T], + _row_start: usize, + ) -> Result<(), QuSpinError> { + Err(QuSpinError::RuntimeError( + "CscOp: dot_chunk not used on the from_parts apply path".into(), + )) + } + + fn dot_transpose_chunk( + &self, + _input: &[T], + _output: &[::Atomic], + _rows: std::ops::Range, + ) -> Result<(), QuSpinError> { + Err(QuSpinError::RuntimeError( + "CscOp: dot_transpose_chunk not used on the from_parts apply path".into(), + )) + } +} + + +/// Compute `exp(dt · M) · coeffs` for the in-basis-restricted generator +/// `M`, matrix-free, via `quspin-expm`. Returns a fresh `Vec` of length +/// `basis.len()`. +/// +/// One matrix-free pass extracts the diagonal shift `μ = tr(M)/n` and the +/// exact column 1-norm of `M − μ·I`; from `‖dt·(M−μI)‖₁` we pick the Taylor +/// partition `(m*, s)` and hand everything to +/// [`quspin_expm::ExpmOp::from_parts`]. +pub(crate) fn expm_apply_mf( + spec: &LindbladSpec, + basis: &[Word], + dt: f64, + coeffs: &[f64], + drop_tol: f64, +) -> Vec { + let n = basis.len(); + if n == 0 { + return Vec::new(); + } + + // ONE action pass: build the CSC cache `cols` (reused across every matvec) + // and, in the same pass, `per_col = (raw, diag)` for the `μ`/1-norm + // selection. `raw = Σ|coeff|` (all outputs), `diag` = coeff of the + // output == input term. From these: `trace = Σ diag`, `μ = trace/n`, and + // the column 1-norm of `M − μ·I` is `raw − |diag| + |diag − μ|`. + let index = build_basis_index(basis); + let (cols, per_col) = build_mf_cols(spec, basis, &index); + + let trace: f64 = per_col.iter().map(|(_, d)| *d).sum(); + let mu = trace / n as f64; + let onenorm = per_col + .iter() + .map(|(raw, diag)| raw - diag.abs() + (diag - mu).abs()) + .fold(0.0_f64, f64::max); + + // Pick the Taylor backward-error tolerance to match the basis truncation: + // when the PC prunes coarsely (drop_tol >= 1e-4) a double-precision exp is + // ~10 orders more accurate than the state it acts on, so the relaxed + // (tol=1e-6, still >=100x tighter than the cut) table is used — it admits a + // lower-degree Taylor polynomial and cuts the SpMV count with no effect on + // the truncated result. At tight/zero drop_tol we keep double precision so + // the exact-reference paths (orbit-rep / merged) still agree bit-for-bit. + let t_norm = dt.abs() * onenorm; + let (m_star, s, expm_tol) = if drop_tol >= 1e-4 { + let (m, s) = expm::select_ms_loose(t_norm); + (m, s, 1e-6_f64) + } else { + let (m, s) = expm::select_ms(t_norm); + (m, s, 1e-12_f64) + }; + + let mut v = coeffs.to_vec(); + let op = CscOp { cols: &cols, dim: n }; + let expm = quspin_expm::ExpmOp::from_parts(op, dt, mu, s as usize, m_star as usize, expm_tol); + expm.apply(ndarray::ArrayViewMut1::from(v.as_mut_slice())) + .expect("expm apply"); + v +} + +/// `exp(dt · M) · b` where `M` is the REAL in-basis-restricted generator but +/// the input vector `b` is complex. Because `M` is real, +/// `exp(dt·M)·(re + i·im) = exp(dt·M)·re + i·exp(dt·M)·im`, so we split the +/// complex vector into its real and imaginary parts, run two real +/// matrix-free applies, and recombine. Used by the test-only full-space +/// complex reference step. +#[cfg(test)] +pub(crate) fn expm_apply_mf_cxvec( + spec: &LindbladSpec, + basis: &[Word], + dt: f64, + b: &[Complex], + drop_tol: f64, +) -> Vec> { + let n = basis.len(); + if n == 0 { + return Vec::new(); + } + let re: Vec = b.iter().map(|z| z.re).collect(); + let im: Vec = b.iter().map(|z| z.im).collect(); + let re_out = expm_apply_mf(spec, basis, dt, &re, drop_tol); + let im_out = expm_apply_mf(spec, basis, dt, &im, drop_tol); + re_out + .into_iter() + .zip(im_out) + .map(|(r, i)| Complex::new(r, i)) + .collect() +} + + diff --git a/crates/ppvm-lindblad/src/orbit_rep.rs b/crates/ppvm-lindblad/src/orbit_rep.rs new file mode 100644 index 00000000..7bf7a27c --- /dev/null +++ b/crates/ppvm-lindblad/src/orbit_rep.rs @@ -0,0 +1,445 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Per-step orbit-representative evolution under translation symmetry. +//! +//! The state lives entirely in **orbit-rep form** throughout: `basis` +//! contains only canonical translation-orbit representatives, and +//! `coeffs` are complex (one per rep). The dynamics `L*` is computed +//! with **phase-aware action** — for each output Pauli `q`, we +//! canonicalize `q` to its orbit rep `r_q` with shift counter `cnt_q`, +//! and accumulate `χ_k(g_{cnt_q}) · v · c_r` (where `v` is the matrix +//! element of `L*` between input rep `r` and output `q`). +//! +//! The orbit-rep basis is ~|G|× smaller than the full-basis +//! representation, throughout the entire evolution. +//! +//! The phase-aware action is genuinely **complex** (because of the +//! `χ_k(g)` phase factors). Rather than materialise a sparse matrix, the +//! per-column action — for each input rep, the list of `(row, χ_k·v)` +//! pairs for the in-basis outputs — is computed **once per expm call** +//! (via [`build_orbit_rep_cols`]) and then reused, CSC-style, across +//! every Krylov–Taylor matvec driving the external `quspin-expm` engine. +//! +//! ## Limitations +//! +//! - Caller is responsible for ensuring the input basis is in orbit-rep +//! form (i.e. each entry is the canonical representative of its +//! translation orbit). Use [`canonicalize_basis_to_rep`] if needed. +//! - The momentum sector `k_modes` is fixed for the duration of one +//! pc_step call. To compute a full site-resolved profile, call +//! `pc_step_orbit_rep` once per momentum mode and inverse-Fourier +//! the results. + +use crate::mf_expm::CscOp; +use crate::{Error, LindbladSpec}; +use fxhash::{FxBuildHasher, FxHashMap}; +use num::Complex; +use ppvm_pauli_sum::symmetry::TranslationGroup; +use quspin_expm::ExpmOp; +use rayon::prelude::*; + +// Word type re-exported from lib.rs. +use crate::Word; + +/// Replace each entry of `basis` with its canonical orbit +/// representative under `group`. Pure rewrite; coefficients are +/// untouched. Useful to enforce the orbit-rep invariant before calling +/// [`pc_step_orbit_rep`]. +/// +/// Does NOT deduplicate — if multiple input entries collapse to the +/// same rep, both are kept (caller should run a merge afterwards). +pub fn canonicalize_basis_to_rep(basis: &mut [Word], group: &TranslationGroup) { + for w in basis.iter_mut() { + *w = group.canonicalize(w); + } +} + +/// Build the per-column phase-aware action of the in-basis-restricted +/// orbit-rep generator `M` at momentum sector `k_modes`. +/// +/// Returns, for each input rep `basis[c]` (column `c`), the list of +/// `(row, χ_k(g_{cnt_q}) · v_q)` pairs for every action output Pauli `q` +/// of `L*(basis[c])` whose orbit rep `r_q` is in `basis` at index `row`. +/// Outputs not in `basis` are dropped. This is the expensive part of the +/// orbit-rep dynamics (`compute_action_terms`, `canonicalize_with_shift`, +/// `character`); it is computed once and reused by the CSC-style matvec +/// in [`CscOp`]. +pub(crate) fn build_orbit_rep_cols( + spec: &LindbladSpec, + basis: &[Word], + index: &FxHashMap, + group: &TranslationGroup, + k_modes: &[i32], +) -> Vec)>> { + basis + .par_iter() + .map_init( + || { + ( + Vec::::with_capacity(spec.n_qubits()), + Vec::::with_capacity(128), + FxHashMap::>::with_capacity_and_hasher( + 128, + FxBuildHasher::default(), + ), + ) + }, + |(s1, s2, lm), r| { + let terms = spec.compute_action_terms(r, s1, s2, lm); + let mut out = Vec::with_capacity(terms.len()); + for (q, v) in terms.iter() { + let (r_q, cnt_q) = group.canonicalize_with_shift(q); + if let Some(&row) = index.get(&r_q) { + let phase = group.character(k_modes, &cnt_q); + out.push((row, phase * *v)); + } + } + out + }, + ) + .collect() +} + +/// Compute `exp(dt · M) · coeffs` for the in-basis-restricted orbit-rep +/// generator `M` at momentum sector `k_modes`, via `quspin-expm`. Returns +/// a fresh `Vec>` of length `basis.len()`. +/// +/// The expensive phase-aware action is computed ONCE here (via +/// [`build_orbit_rep_cols`]) and reused, CSC-style, across every Krylov– +/// Taylor matvec (see [`CscOp`]). One pass over the cached columns +/// extracts the diagonal shift `μ = tr(M)/n` and a valid upper bound on +/// the column 1-norm of `M − μ·I`; from `‖dt·(M−μI)‖₁` we pick the Taylor +/// partition `(m*, s)` and hand everything to +/// [`quspin_expm::ExpmOp::from_parts`] (mirroring +/// [`crate::mf_expm::expm_apply_mf`]). +pub(crate) fn expm_apply_orbit_rep_cached( + spec: &LindbladSpec, + basis: &[Word], + group: &TranslationGroup, + k_modes: &[i32], + dt: f64, + coeffs: &[Complex], +) -> Vec> { + let n = basis.len(); + if n == 0 { + return Vec::new(); + } + + let index = crate::build_basis_index(basis); + let cols = build_orbit_rep_cols(spec, basis, &index, group, k_modes); + + // One pass for the per-column `(raw, diag)` used by the `μ`/1-norm + // selection: `raw = Σ|val|` (upper bound on the absolute column sum), + // `diag = M[c,c]`. From these: `trace = Σ diag`, `μ = trace/n`, and an + // upper bound on the column 1-norm of `M − μ·I`: `raw − |diag| + |diag − μ|`. + let per_col: Vec<(f64, Complex)> = cols + .par_iter() + .enumerate() + .map(|(c, col)| { + let mut raw = 0.0_f64; + let mut diag = Complex::new(0.0, 0.0); + for &(row, val) in col.iter() { + raw += val.norm(); + if row as usize == c { + diag += val; + } + } + (raw, diag) + }) + .collect(); + + let trace: Complex = per_col.iter().map(|(_, d)| *d).sum(); + let mu = trace / n as f64; + let onenorm = per_col + .iter() + .map(|(raw, diag)| raw - diag.norm() + (diag - mu).norm()) + .fold(0.0_f64, f64::max); + + let (m_star, s) = crate::expm::select_ms(dt.abs() * onenorm); + + let mut v = coeffs.to_vec(); + let op = CscOp { cols: &cols, dim: n }; + let e = ExpmOp::from_parts( + op, + Complex::new(dt, 0.0), + mu, + s as usize, + m_star as usize, + 1e-12_f64, + ); + e.apply(ndarray::ArrayViewMut1::from(v.as_mut_slice())) + .expect("expm apply (orbit-rep cached)"); + v +} + +/// Phase-aware leakage: out-of-basis component of `L*(O_k)` where `O_k` +/// is the operator represented by `basis` (orbit reps) and `coeffs` +/// (complex coefficients in momentum sector `k_modes`). +/// +/// For each input rep `r` with coefficient `c_r`, and each output `q` +/// of `L*(r) = Σ_q v_q · q`: +/// 1. Canonicalize `q` → `(r_q, cnt_q)`. +/// 2. If `r_q` NOT in `basis` and NOT in `protected`: +/// `merged[r_q] += χ_k(g_{cnt_q}) · v_q · c_r`. +/// +/// Returns `(r_q, sum)` pairs for all candidates with nonzero sum. +/// +/// The live candidate map is capped to the *available room* +/// `room = max_basis − basis.len()` (the reps we could actually add), +/// applied during accumulation: input reps are processed in descending +/// `|c|` order and after each chunk only the `room` largest-`|sum|` +/// candidates are kept. A large `max_basis` (room ≥ all candidates) +/// disables the cap — the near-exact case. +#[allow(clippy::too_many_arguments)] +pub fn leakage_orbit_rep( + spec: &LindbladSpec, + basis: &[Word], + coeffs: &[Complex], + protected: &[Word], + group: &TranslationGroup, + k_modes: &[i32], + max_basis: usize, +) -> Result)>, Error> { + if basis.len() != coeffs.len() { + return Err(Error::LengthMismatch { + what: "basis and coeffs", + a: basis.len(), + b: coeffs.len(), + }); + } + let in_basis: FxHashMap<&Word, ()> = basis.iter().map(|w| (w, ())).collect(); + let protected_set: FxHashMap<&Word, ()> = protected.iter().map(|w| (w, ())).collect(); + + // Descending sort by |c|: process largest-magnitude contributors first + // so the running room-cap keeps the right entries. + let mut order: Vec = (0..basis.len()).collect(); + order.sort_by(|&a, &b| { + coeffs[b] + .norm() + .partial_cmp(&coeffs[a].norm()) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + const CHUNK_SIZE: usize = 4096; + let room = max_basis.saturating_sub(basis.len()); + let mut merged: FxHashMap> = FxHashMap::default(); + for chunk_indices in order.chunks(CHUNK_SIZE) { + let local: Vec)>> = chunk_indices + .par_iter() + .map_init( + || { + ( + Vec::::with_capacity(spec.n_qubits()), + Vec::::with_capacity(128), + FxHashMap::>::with_capacity_and_hasher( + 128, + FxBuildHasher::default(), + ), + ) + }, + |(s1, s2, lm), &i| { + let r = &basis[i]; + let c_r = coeffs[i]; + let terms = spec.compute_action_terms(r, s1, s2, lm); + let mut out = Vec::with_capacity(terms.len()); + for (q, v) in terms.iter() { + let (r_q, cnt_q) = group.canonicalize_with_shift(q); + if !in_basis.contains_key(&r_q) && !protected_set.contains_key(&r_q) { + let phase = group.character(k_modes, &cnt_q); + out.push((r_q, phase * *v * c_r)); + } + } + out + }, + ) + .collect(); + for v in local { + for (k, val) in v { + *merged.entry(k).or_insert(Complex::new(0.0, 0.0)) += val; + } + } + + // Room-cap: keep only the `room` largest-magnitude entries. + if merged.len() > room { + if room == 0 { + merged.clear(); + } else { + let mut mags: Vec = merged.values().map(|v| v.norm()).collect(); + let k = room.min(mags.len() - 1); + mags.select_nth_unstable_by(k, |a, b| { + b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal) + }); + let cutoff = mags[k]; + merged.retain(|_, &mut v| v.norm() >= cutoff); + } + } + } + Ok(merged.into_iter().filter(|(_, c)| c.norm() > 0.0).collect()) +} + +/// Per-step orbit-rep predictor-corrector evolution. +/// +/// All state lives in orbit-rep form throughout. Each pc step does: +/// 1. Phase-aware leakage from `(basis, coeffs)`; append the largest +/// leakage reps, up to the admission room. +/// 2. Predictor: cached-action expm ([`expm_apply_orbit_rep_cached`]). +/// 3. Phase-aware leakage from the predicted state; append further reps. +/// 4. Corrector: cached-action expm from the pre-step coefficients on +/// the doubly-enlarged basis. +/// 5. Prune `|c| < drop_tol`, then trim to the top-`max_basis` reps by +/// `|c|`; protected reps never dropped. +/// +/// `max_basis` is a hard rank cap on the live orbit-rep basis: enrichment +/// adds at most `max_basis − basis.len()` of the largest leakage reps, the +/// leakage map is capped to the same room, and the post-step basis is +/// trimmed to the top-`max_basis` by `|c|`. Pass a large value (e.g. +/// `usize::MAX`) for the near-exact, uncapped case. `drop_tol` additionally +/// prunes by magnitude. +/// +/// `basis` is assumed to contain only canonical orbit representatives. +/// If not, [`canonicalize_basis_to_rep`] should be called first. +#[allow(clippy::too_many_arguments)] +pub fn pc_step_orbit_rep( + spec: &LindbladSpec, + basis: &mut Vec, + coeffs: &mut Vec>, + dt: f64, + protected: &[Word], + group: &TranslationGroup, + k_modes: &[i32], + cfg: &crate::PcStepConfig, +) -> Result<(), Error> { + let crate::PcStepConfig { max_basis, admit_basis, drop_tol, tau_add, .. } = *cfg; + // Admission bound, mirroring the real-space `pc_step`: enrichment may + // grow the live basis to `admit` >= `max_basis`; the final + // `cap_basis_complex` keeps the top-`max_basis` reps by evolved |coeff| + // over the whole union (rank displacement). With `admit_basis = None` + // admission is bounded by `max_basis` itself and membership turnover + // requires `drop_tol > 0`. + let admit = admit_basis.unwrap_or(max_basis).max(max_basis); + let tau_add = tau_add.unwrap_or(0.0); + // 1. First-hop phase-aware leakage. + let mut leak = leakage_orbit_rep(spec, basis, coeffs, protected, group, k_modes, admit)?; + if tau_add > 0.0 { + leak.retain(|(_, c)| c.norm() > tau_add); + } + add_leakage_capped_complex(basis, coeffs, leak, admit); + // 2. Predictor: cached-action expm (the phase-aware action is built + // once via `build_orbit_rep_cols`). + let coeffs_predict = expm_apply_orbit_rep_cached(spec, basis, group, k_modes, dt, coeffs); + // 3. Second-hop leakage from predicted state. + let mut leak2 = + leakage_orbit_rep(spec, basis, &coeffs_predict, protected, group, k_modes, admit)?; + drop(coeffs_predict); + if tau_add > 0.0 { + leak2.retain(|(_, c)| c.norm() > tau_add); + } + add_leakage_capped_complex(basis, coeffs, leak2, admit); + // 4. Corrector: cache-the-action expm from pre-step state (basis grew). + *coeffs = expm_apply_orbit_rep_cached(spec, basis, group, k_modes, dt, coeffs); + // 5. Prune by magnitude, then rank-cap to max_basis. + if drop_tol > 0.0 { + prune_basis_complex_local(basis, coeffs, drop_tol, protected); + } + cap_basis_complex(basis, coeffs, max_basis, protected); + Ok(()) +} + +/// Complex analogue of `crate::add_leakage_capped`: add the largest leakage +/// reps to the basis, up to the available room `room = max_basis − +/// basis.len()`, so the in-step orbit-rep basis never exceeds `max_basis`. +/// New reps get coefficient 0; the surrounding expm fills them. No +/// magnitude filter — the top-`room` by `|leakage|` are added. +fn add_leakage_capped_complex( + basis: &mut Vec, + coeffs: &mut Vec>, + mut leak: Vec<(Word, Complex)>, + max_basis: usize, +) { + let room = max_basis.saturating_sub(basis.len()); + if leak.len() > room { + if room > 0 { + leak.select_nth_unstable_by(room - 1, |a, b| { + b.1.norm() + .partial_cmp(&a.1.norm()) + .unwrap_or(std::cmp::Ordering::Equal) + }); + } + leak.truncate(room); + } + for (w, _) in leak { + basis.push(w); + coeffs.push(Complex::new(0.0, 0.0)); + } +} + +/// Complex analogue of `crate::cap_basis`: keep only the `max_basis` +/// largest-`|c|` reps (protected reps always kept), dropping the rest. +/// A `max_basis` large enough to cover the whole basis is a no-op. +fn cap_basis_complex( + basis: &mut Vec, + coeffs: &mut Vec>, + max_basis: usize, + protected: &[Word], +) { + if basis.len() <= max_basis { + return; + } + let protected_set: fxhash::FxHashSet<&Word> = protected.iter().collect(); + let n_prot = basis.iter().filter(|w| protected_set.contains(w)).count(); + let slots = max_basis.saturating_sub(n_prot); + let mut mags: Vec = basis + .iter() + .zip(coeffs.iter()) + .filter(|(w, _)| !protected_set.contains(w)) + .map(|(_, c)| c.norm()) + .collect(); + let cutoff = if slots == 0 { + f64::INFINITY + } else if slots >= mags.len() { + return; + } else { + let k = slots - 1; + mags.select_nth_unstable_by(k, |a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)); + mags[k] + }; + let mut write = 0; + for read in 0..basis.len() { + if protected_set.contains(&basis[read]) || coeffs[read].norm() >= cutoff { + if write != read { + basis.swap(write, read); + coeffs.swap(write, read); + } + write += 1; + } + } + basis.truncate(write); + coeffs.truncate(write); +} + +/// Complex analogue of `crate::prune_basis`: drop reps with `|c| < +/// drop_tol`, never dropping `protected` reps. No-op when `drop_tol <= 0`. +fn prune_basis_complex_local( + basis: &mut Vec, + coeffs: &mut Vec>, + drop_tol: f64, + protected: &[Word], +) { + if drop_tol <= 0.0 { + return; + } + let protected_set: fxhash::FxHashSet<&Word> = protected.iter().collect(); + let mut write = 0; + for read in 0..basis.len() { + if coeffs[read].norm() >= drop_tol || protected_set.contains(&basis[read]) { + if write != read { + basis.swap(write, read); + coeffs.swap(write, read); + } + write += 1; + } + } + basis.truncate(write); + coeffs.truncate(write); +} diff --git a/crates/ppvm-pauli-sum/src/lib.rs b/crates/ppvm-pauli-sum/src/lib.rs index 7f509c5d..097ba4ad 100644 --- a/crates/ppvm-pauli-sum/src/lib.rs +++ b/crates/ppvm-pauli-sum/src/lib.rs @@ -7,6 +7,7 @@ pub mod config; pub mod strategy; pub mod sum; +pub mod symmetry; /// Drop-in replacement for the old `ppvm_runtime::prelude`. pub mod prelude { diff --git a/crates/ppvm-pauli-sum/src/symmetry.rs b/crates/ppvm-pauli-sum/src/symmetry.rs new file mode 100644 index 00000000..56ceabde --- /dev/null +++ b/crates/ppvm-pauli-sum/src/symmetry.rs @@ -0,0 +1,949 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Lattice translation symmetry groups for operator-space Pauli evolution. +//! +//! A [`TranslationGroup`] represents a finite abelian group `G` acting on +//! qubit positions by permutations. Given such a group, every Pauli word +//! belongs to a translation orbit, and operator dynamics that commute +//! with `G` can be tracked using **one canonical representative per +//! orbit** instead of all `|G|` orbit members — reducing per-step memory +//! and compute by a factor up to `|G|`. +//! +//! Following Teng, Chang, Rudolph, and Holmes (arXiv:2512.12094), this +//! module implements **plain (real-coefficient) merging** of Pauli sums +//! into orbit-representative form — see [`canonicalize_pauli_sum`] and +//! [`symmetry_merge_pauli_sum`]. This handles observables in the trivial +//! (`k=0`) symmetry sector, e.g. sums of single-Z operators over the +//! lattice. +//! +//! **Non-trivial momentum sectors (`k ≠ 0`)** are handled by +//! [`canonicalize_pauli_sum_complex`], which folds with the character +//! phase `χ_k(g)` of each translation. On the Python side, an operator in +//! sector `k` is carried as a *real pair* (real + imaginary components, two +//! real `PauliSum`s) and merged via `PauliSum.momentum_merge`, which reuses +//! this routine — letting gate-based Trotter evolution stay symmetry- +//! compressed in any momentum sector with real coefficients throughout. +//! +//! ## Data model +//! +//! A `TranslationGroup` is specified by a list of generator permutations +//! and their cyclic orders. The group order is the product of the orders. +//! For instance, a 2D `L × L` torus has two generators (translation in +//! x and y) each of order `L`. +//! +//! ## Canonicalization +//! +//! [`TranslationGroup::canonicalize`] returns the **lex-minimum** Pauli +//! word reachable from the input via group action. The ordering is the +//! standard `Ord` impl on `PauliWord` (compare `xbits`, then `zbits`). +//! All orbit members canonicalize to the same representative; orbits are +//! disjoint by construction, so the rep uniquely identifies the orbit. +//! +//! ## Merging +//! +//! [`canonicalize_pauli_sum`] takes parallel `Vec` / `Vec` +//! buffers (the representation used by ppvm-lindblad's adaptive +//! evolution) and replaces each Pauli by its canonical rep, summing +//! coefficients for collisions. The output is an orbit-rep basis with +//! coefficients equal to the sum of the input coefficients over each +//! orbit's members. For dynamics that commute with `G` and initial +//! states that are also `G`-invariant, this preserves the expectation +//! value of any `G`-invariant observable (Theorem 1 of arXiv:2512.12094). +//! +//! See the dedicated tests for correctness against full-basis evolution +//! on small systems with no truncation. + +use crate::sum::PauliSum; +use ppvm_pauli_word::word::PauliWord; +use ppvm_traits::Config; +use ppvm_traits::{HashFinalize, PauliStorage, PauliWordTrait}; +use fxhash::FxHashMap; +use num::Complex; +use std::f64::consts::PI; +use std::hash::BuildHasher; + +/// A finite abelian symmetry group acting on qubit positions by +/// permutations. +/// +/// Build via the convenience constructors [`Self::chain_1d`], +/// [`Self::torus_2d`], [`Self::torus_3d`], [`Self::ladder`], or +/// [`Self::from_generators`] for an arbitrary list of generator +/// permutations. +/// +/// `perms[g]` is the permutation that **generator `g`** applies to qubit +/// indices: a qubit at position `q` moves to position `perms[g][q]` +/// under one application of generator `g`. `orders[g]` is the cyclic +/// order of generator `g` (i.e. applying it `orders[g]` times returns +/// the identity). The full group is the direct product of the cyclic +/// subgroups, with size `Π orders[g]`. +/// +/// Only the **generators** are stored; the algorithm in +/// [`Self::canonicalize`] walks the group via mixed-radix increments. +#[derive(Debug, Clone)] +pub struct TranslationGroup { + /// Number of qubits the group acts on. + n_qubits: usize, + /// One permutation per generator. `perms[g][q]` is the position + /// that qubit `q` maps to under one application of generator `g`. + perms: Vec>, + /// Cyclic order of each generator. + orders: Vec, +} + +impl TranslationGroup { + /// Construct from explicit generator permutations and orders. + /// + /// Each `perm` must be a permutation of `0..n_qubits`. Each `order` + /// must satisfy `perm^order == identity`. + pub fn from_generators( + n_qubits: usize, + perms: Vec>, + orders: Vec, + ) -> Self { + assert_eq!(perms.len(), orders.len(), "perms and orders must match"); + for (g, perm) in perms.iter().enumerate() { + assert_eq!( + perm.len(), + n_qubits, + "generator {g} permutation has length {} != n_qubits {n_qubits}", + perm.len() + ); + let mut seen = vec![false; n_qubits]; + for &p in perm { + assert!( + (p as usize) < n_qubits, + "generator {g} maps to out-of-range position {p}" + ); + assert!( + !seen[p as usize], + "generator {g} is not a permutation (duplicate target {p})" + ); + seen[p as usize] = true; + } + } + Self { + n_qubits, + perms, + orders, + } + } + + /// 1D chain of `n` sites with periodic boundary conditions. + /// Single generator: cyclic shift by one site. + pub fn chain_1d(n: usize) -> Self { + let perm: Vec = (0..n).map(|q| ((q + 1) % n) as u32).collect(); + Self::from_generators(n, vec![perm], vec![n as u32]) + } + + /// 2D `lx × ly` torus, qubit at `(i, j)` indexed as `j*lx + i`. + /// Two generators: x-shift (i → i+1 mod lx) and y-shift (j → j+1 mod ly). + pub fn torus_2d(lx: usize, ly: usize) -> Self { + let n = lx * ly; + let perm_x: Vec = (0..n) + .map(|q| { + let (i, j) = (q % lx, q / lx); + (j * lx + (i + 1) % lx) as u32 + }) + .collect(); + let perm_y: Vec = (0..n) + .map(|q| { + let (i, j) = (q % lx, q / lx); + (((j + 1) % ly) * lx + i) as u32 + }) + .collect(); + Self::from_generators(n, vec![perm_x, perm_y], vec![lx as u32, ly as u32]) + } + + /// 3D `lx × ly × lz` torus, qubit at `(i, j, k)` indexed as + /// `k*lx*ly + j*lx + i`. + pub fn torus_3d(lx: usize, ly: usize, lz: usize) -> Self { + let n = lx * ly * lz; + let perm_x: Vec = (0..n) + .map(|q| { + let i = q % lx; + let j = (q / lx) % ly; + let k = q / (lx * ly); + (k * lx * ly + j * lx + (i + 1) % lx) as u32 + }) + .collect(); + let perm_y: Vec = (0..n) + .map(|q| { + let i = q % lx; + let j = (q / lx) % ly; + let k = q / (lx * ly); + (k * lx * ly + ((j + 1) % ly) * lx + i) as u32 + }) + .collect(); + let perm_z: Vec = (0..n) + .map(|q| { + let i = q % lx; + let j = (q / lx) % ly; + let k = q / (lx * ly); + (((k + 1) % lz) * lx * ly + j * lx + i) as u32 + }) + .collect(); + Self::from_generators( + n, + vec![perm_x, perm_y, perm_z], + vec![lx as u32, ly as u32, lz as u32], + ) + } + + /// Multi-leg ladder: `l` sites along the chain × `n_legs` legs. + /// Single generator: cyclic shift along the chain direction (all + /// legs simultaneously). Qubit at `(leg, j)` indexed as + /// `leg * l + j`. No translation along the leg axis (legs are + /// distinguished). + pub fn ladder(l: usize, n_legs: usize) -> Self { + let n = l * n_legs; + let perm: Vec = (0..n) + .map(|q| { + let leg = q / l; + let j = q % l; + (leg * l + (j + 1) % l) as u32 + }) + .collect(); + Self::from_generators(n, vec![perm], vec![l as u32]) + } + + /// Number of qubits the group acts on. + pub fn n_qubits(&self) -> usize { + self.n_qubits + } + + /// Number of generators (rank of the group as an abelian product). + pub fn n_generators(&self) -> usize { + self.perms.len() + } + + /// Total group order: `Π orders[g]`. + pub fn order(&self) -> usize { + self.orders.iter().map(|&o| o as usize).product() + } + + /// Permutation associated with the `g`-th generator (one application). + pub fn generator_perm(&self, g: usize) -> &[u32] { + &self.perms[g] + } + + /// Cyclic order of the `g`-th generator. + pub fn generator_order(&self, g: usize) -> u32 { + self.orders[g] + } + + /// Apply a single generator's permutation to a Pauli word, returning + /// the resulting word. + /// + /// For each qubit `q` of the input, the corresponding `(xbit, zbit)` + /// pair is placed at position `perm[q]` of the output. + fn apply_generator( + &self, + w: &PauliWord, + g: usize, + ) -> PauliWord + where + A: PauliStorage, + S: BuildHasher + Clone + Default + HashFinalize, + { + let perm = &self.perms[g]; + let mut out: PauliWord = PauliWord::new(self.n_qubits); + for (q, &pq) in perm.iter().enumerate().take(self.n_qubits) { + let xb = w.get_xbit(q); + let zb = w.get_zbit(q); + if xb { + out.set_xbit(pq as usize, true); + } + if zb { + out.set_zbit(pq as usize, true); + } + } + out.rehash(); + out + } + + /// Lex-min canonical representative of `w`'s translation orbit + /// under this group. Walks the full group via mixed-radix counters, + /// keeping the smallest word seen. + /// + /// Total cost: `O(|G| × n_qubits)` per call. + pub fn canonicalize( + &self, + w: &PauliWord, + ) -> PauliWord + where + A: PauliStorage, + S: BuildHasher + Clone + Default + HashFinalize, + { + debug_assert_eq!( + w.n_qubits(), + self.n_qubits, + "word and group must agree on n_qubits" + ); + if self.perms.is_empty() { + return *w; + } + // Mixed-radix counter `(c[0], c[1], …)` ranges over + // `0..orders[0] × 0..orders[1] × …`. We track the "current" + // word obtained by applying generator `g` once each time + // `c[g]` increments; rolling over `c[g]` means we apply + // generator `g` exactly `orders[g]` times (= identity), so + // `cur` returns to the orbit member that had `c[g..]` as its + // tail and `0` in slots 0..g. + // + // The simplest correct implementation just enumerates: for each + // group element index, build the corresponding word from scratch + // by applying the right number of each generator. + let mut best = *w; + let order = self.order(); + let mut idx = 0usize; + while idx < order { + // Decode `idx` to mixed-radix counter `c` + let mut rem = idx; + let mut counters: Vec = Vec::with_capacity(self.perms.len()); + for &o in &self.orders { + counters.push((rem as u32) % o); + rem /= o as usize; + } + // Construct the group element's permutation by composing + // `generator g` applied `c[g]` times, for each g. + // We do this lazily by iterating over qubits. + let mut cur = *w; + for (g, &c) in counters.iter().enumerate() { + for _ in 0..c { + cur = self.apply_generator(&cur, g); + } + } + if cur < best { + best = cur; + } + idx += 1; + } + best + } + + /// Lex-min canonical representative `r` of `w` together with the + /// **mixed-radix counter** `c = (c_0, c_1, …)` of the group element + /// `g` such that `g·r = w`. + /// + /// In other words: if `r = self.canonicalize(w)`, this returns + /// `(r, c)` where applying generator `i` exactly `c[i]` times in + /// sequence to `r` produces `w`. The counter is used to compute + /// momentum phases by the phase-aware merge routines. + /// + /// Same `O(|G| × n_qubits)` cost as `canonicalize`. + pub fn canonicalize_with_shift( + &self, + w: &PauliWord, + ) -> (PauliWord, Vec) + where + A: PauliStorage, + S: BuildHasher + Clone + Default + HashFinalize, + { + debug_assert_eq!(w.n_qubits(), self.n_qubits); + if self.perms.is_empty() { + return (*w, Vec::new()); + } + let mut best = *w; + let mut best_counter: Vec = vec![0; self.perms.len()]; + let order = self.order(); + for idx in 0..order { + // Decode `idx` to mixed-radix counter. + let mut rem = idx; + let mut counter: Vec = Vec::with_capacity(self.perms.len()); + for &o in &self.orders { + counter.push((rem as u32) % o); + rem /= o as usize; + } + // Build the candidate by applying generator `g` exactly + // `counter[g]` times. + let mut cur = *w; + for (g, &c) in counter.iter().enumerate() { + for _ in 0..c { + cur = self.apply_generator(&cur, g); + } + } + if cur < best { + best = cur; + // We need the counter such that g·best = w. The loop + // above computed cur = g·w with counter, so w = g^{-1}·cur. + // For abelian cyclic groups, g^{-1} = g^{order-1}, i.e. + // the counter `(orders[g] - counter[g]) mod orders[g]`. + best_counter = counter + .iter() + .zip(self.orders.iter()) + .map(|(&c, &o)| (o - c) % o) + .collect(); + } + } + (best, best_counter) + } + + /// Momentum-sector character `χ_k(g) = exp(i Σ_g 2π · k[g] · counter[g] / orders[g])` + /// where `k[g] ∈ ℤ` is the integer momentum mode along generator `g` + /// (the corresponding wavenumber is `2π · k[g] / orders[g]`). + /// + /// `k.len()` must equal `self.n_generators()`. The character of the + /// identity element (`counter = [0, …]`) is `1`. For the trivial + /// (`k = [0, …]`) sector all characters are `1` — phase-aware merging + /// reduces to plain merging. + pub fn character(&self, k_modes: &[i32], counter: &[u32]) -> Complex { + debug_assert_eq!(k_modes.len(), self.perms.len()); + debug_assert_eq!(counter.len(), self.perms.len()); + let mut phase = 0.0_f64; + for ((&k, &c), &o) in k_modes.iter().zip(counter.iter()).zip(self.orders.iter()) { + phase += 2.0 * PI * (k as f64) * (c as f64) / (o as f64); + } + Complex::from_polar(1.0, phase) + } + + /// Iterate over all group elements applied to `w`. Yields `|G|` + /// Pauli words (including `w` itself for the identity element). + pub fn orbit<'a, A, S, const R: bool>( + &'a self, + w: &'a PauliWord, + ) -> impl Iterator> + 'a + where + A: PauliStorage + 'a, + S: BuildHasher + Clone + Default + HashFinalize + 'a, + { + let order = self.order(); + (0..order).map(move |idx| { + let mut rem = idx; + let mut cur = *w; + for (g, &o) in self.orders.iter().enumerate() { + let c = (rem as u32) % o; + rem /= o as usize; + for _ in 0..c { + cur = self.apply_generator(&cur, g); + } + } + cur + }) + } +} + +/// Replace `(basis, coeffs)` in-place with the orbit-representative +/// form: each Pauli word becomes its canonical rep, and coefficients +/// of words that collapse to the same rep are summed. +/// +/// Output length ≤ input length. Entries whose summed coefficient +/// equals zero exactly are *not* removed — caller should run a final +/// `drop_tol` prune if desired. +/// +/// For dynamics that commute with `group` and initial states that are +/// `group`-invariant (i.e. in the trivial momentum sector), this +/// preserves all `G`-invariant expectation values. +pub fn canonicalize_pauli_sum( + basis: &mut Vec>, + coeffs: &mut Vec, + group: &TranslationGroup, +) where + A: PauliStorage, + S: BuildHasher + Clone + Default + HashFinalize, +{ + assert_eq!(basis.len(), coeffs.len(), "basis and coeffs length mismatch"); + let mut merged: FxHashMap, f64> = + FxHashMap::with_capacity_and_hasher(basis.len(), Default::default()); + for (w, &c) in basis.iter().zip(coeffs.iter()) { + let rep = group.canonicalize(w); + *merged.entry(rep).or_insert(0.0) += c; + } + basis.clear(); + coeffs.clear(); + basis.reserve(merged.len()); + coeffs.reserve(merged.len()); + for (w, c) in merged { + basis.push(w); + coeffs.push(c); + } +} + +/// Replace `(basis, complex_coeffs)` in-place with the orbit-rep form +/// **projected onto momentum sector `k_modes`**. +/// +/// Each Pauli `p` is replaced by its canonical rep `r`; the contribution +/// is `(1/|G|) · χ_k(g) · c_p` where `g` is the group element such that +/// `g · r = p` and `χ_k(g) = exp(2πi · Σ_g k_modes[g] · counter[g] / orders[g])`. +/// +/// If the input was already a momentum-`k_modes` eigenstate (i.e. the +/// coefficients satisfy `c_{g·p} = χ_k(g)⁻¹ · c_p` for every orbit), +/// the output is the orbit-rep coefficients of that state unchanged. +/// Otherwise the merge discards the components in other sectors — +/// use [`check_momentum_sector`] beforehand to validate. +/// +/// For the `k_modes = [0, 0, …]` (trivial) sector this reduces to plain +/// [`canonicalize_pauli_sum`] (real coefficients work, but on complex +/// input the result is complex with vanishing imaginary part). +pub fn canonicalize_pauli_sum_complex( + basis: &mut Vec>, + coeffs: &mut Vec>, + group: &TranslationGroup, + k_modes: &[i32], +) where + A: PauliStorage, + S: BuildHasher + Clone + Default + HashFinalize, +{ + assert_eq!(basis.len(), coeffs.len(), "basis and coeffs length mismatch"); + assert_eq!( + k_modes.len(), + group.n_generators(), + "k_modes length {} != number of generators {}", + k_modes.len(), + group.n_generators() + ); + let inv_g: f64 = 1.0 / (group.order() as f64); + let mut merged: FxHashMap, Complex> = + FxHashMap::with_capacity_and_hasher(basis.len(), Default::default()); + for (w, &c) in basis.iter().zip(coeffs.iter()) { + let (rep, cnt) = group.canonicalize_with_shift(w); + let chi = group.character(k_modes, &cnt); + let contrib = inv_g * chi * c; + *merged.entry(rep).or_insert(Complex::new(0.0, 0.0)) += contrib; + } + basis.clear(); + coeffs.clear(); + basis.reserve(merged.len()); + coeffs.reserve(merged.len()); + for (w, c) in merged { + basis.push(w); + coeffs.push(c); + } +} + +/// Verify that a `(basis, complex_coeffs)` Pauli sum lies entirely in +/// the momentum sector `k_modes` under `group`. +/// +/// Concretely: for every orbit represented in the basis, all members +/// must satisfy `c_{g·r} = χ_k(g)⁻¹ · c_r` for some choice of orbit-rep +/// coefficient `c_r`. +/// +/// Returns `Ok(())` on pass; `Err(SectorCheckError)` on fail with the +/// offending orbit-rep, expected coefficient, and actual coefficient. +/// +/// Use this on a user-supplied initial state before feeding it to a +/// phase-aware merging pipeline — silently projecting a wrongly-typed +/// input throws away meaningful physics. +pub fn check_momentum_sector( + basis: &[PauliWord], + coeffs: &[Complex], + group: &TranslationGroup, + k_modes: &[i32], + tol: f64, +) -> Result<(), SectorCheckError> +where + A: PauliStorage, + S: BuildHasher + Clone + Default + HashFinalize, +{ + assert_eq!(basis.len(), coeffs.len()); + assert_eq!(k_modes.len(), group.n_generators()); + + // Group entries by orbit rep, picking the first-seen member as + // reference and checking later members against it. + let mut reference: FxHashMap, (Complex, Vec)> = + FxHashMap::default(); + for (p, &c) in basis.iter().zip(coeffs.iter()) { + let (rep, cnt) = group.canonicalize_with_shift(p); + let chi = group.character(k_modes, &cnt); + // expected c_p given the rep coefficient c_r: + // c_p = χ_k(g)⁻¹ · c_r, where p = g·r + // equivalently, c_r = χ_k(g) · c_p (a rearrangement). + let implied_rep_coeff = chi * c; + if let Some((rep_coeff, _ref_cnt)) = reference.get(&rep) { + if (implied_rep_coeff - rep_coeff).norm() > tol * rep_coeff.norm().max(1.0) { + return Err(SectorCheckError { + rep, + expected: *rep_coeff, + got_implied: implied_rep_coeff, + offending_pauli: *p, + offending_coeff: c, + shift: cnt.clone(), + }); + } + } else { + reference.insert(rep, (implied_rep_coeff, cnt)); + } + } + Ok(()) +} + +/// Detail report for a failed [`check_momentum_sector`]. +pub struct SectorCheckError { + /// Canonical orbit representative for which the check failed. + pub rep: PauliWord, + /// Coefficient that the *first* basis entry implied for `rep`. + pub expected: Complex, + /// Coefficient that `offending_pauli` implies for `rep` under the + /// purported momentum sector. + pub got_implied: Complex, + /// The basis entry whose coefficient is inconsistent with the + /// expected `rep` value. + pub offending_pauli: PauliWord, + /// Original coefficient of `offending_pauli` in the input basis. + pub offending_coeff: Complex, + /// Counter encoding the group element `g` such that + /// `g · rep == offending_pauli`. + pub shift: Vec, +} + +impl std::fmt::Debug for SectorCheckError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "SectorCheckError {{ rep: , expected: {:?}, got_implied: {:?}, \ + offending: , offending_coeff: {:?}, shift: {:?} }}", + self.expected, self.got_implied, self.offending_coeff, self.shift, + ) + } +} + +impl std::fmt::Display for SectorCheckError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "input not in target momentum sector: orbit rep expected c={:?}, but \ + orbit member (shift {:?}, coeff {:?}) implies c={:?}", + self.expected, self.shift, self.offending_coeff, self.got_implied, + ) + } +} + +/// Symmetry-merge a [`PauliSum`] in place: each Pauli word becomes its +/// canonical orbit representative, and entries collapsing to the same +/// rep accumulate coefficients. +/// +/// This is the Trotter-mode counterpart to [`canonicalize_pauli_sum`] +/// (which operates on the `Vec, Vec` representation used by +/// `ppvm-lindblad`'s adaptive evolution). Same semantics: preserves all +/// `G`-invariant expectation values when the dynamics commutes with +/// `group` and the initial state is `group`-invariant. +/// +/// Generic over the [`Config`] but constrained to PauliWord-backed +/// representations (i.e. not the loss-aware variant) since +/// canonicalization needs raw `(xbit, zbit)` access. +pub fn symmetry_merge_pauli_sum( + psum: &mut PauliSum, + group: &TranslationGroup, +) where + T: Config>, + A: PauliStorage, + S: BuildHasher + Clone + Default + HashFinalize, +{ + psum.map_add(|word, coeff| (group.canonicalize(word), coeff.clone())); +} + +#[cfg(test)] +mod tests { + use super::*; + + type W = PauliWord<[u8; 1], fxhash::FxBuildHasher, true>; + + fn word(s: &str) -> W { + W::from(s) + } + + #[test] + fn chain_1d_canonicalizes_via_cyclic_shift() { + let g = TranslationGroup::chain_1d(4); + // All cyclic shifts of "IIXY" should canonicalize to the same rep. + let candidates = ["IIXY", "IXYI", "XYII", "YIIX"]; + let canon: Vec = candidates.iter().map(|s| g.canonicalize(&word(s))).collect(); + for c in &canon[1..] { + assert_eq!(*c, canon[0], "all cyclic shifts must canonicalize to same rep"); + } + } + + #[test] + fn chain_1d_canonicalize_is_lex_min() { + let g = TranslationGroup::chain_1d(4); + let canon = g.canonicalize(&word("YIIX")); + let orbit: Vec = g.orbit(&word("YIIX")).collect(); + let min = orbit.iter().min().unwrap(); + assert_eq!(canon, *min); + } + + #[test] + fn orbit_has_correct_size_for_chain() { + let g = TranslationGroup::chain_1d(4); + // "XIII" has orbit of size 4 (full chain). + let orbit: Vec = g.orbit(&word("XIII")).collect(); + assert_eq!(orbit.len(), 4); + // "XIXI" has orbit of size 2 (period-2 invariant); 4 elements + // total in the orbit iterator, but only 2 unique. + let orbit: Vec = g.orbit(&word("XIXI")).collect(); + assert_eq!(orbit.len(), 4); // iterator yields |G|, including duplicates + let unique: std::collections::HashSet = orbit.into_iter().collect(); + assert_eq!(unique.len(), 2); + } + + #[test] + fn torus_2d_canonicalize() { + // 3x2 torus, 6 qubits. + let g = TranslationGroup::torus_2d(3, 2); + assert_eq!(g.n_qubits(), 6); + assert_eq!(g.order(), 6); + // X at (0,0) — orbit is all 6 single-X positions. + let w = word("XIIIII"); + let orbit: Vec = g.orbit(&w).collect(); + let unique: std::collections::HashSet = orbit.into_iter().collect(); + assert_eq!(unique.len(), 6); + // All canonicalize to the same rep. + let canon = g.canonicalize(&w); + for u in &unique { + assert_eq!(g.canonicalize(u), canon); + } + } + + #[test] + fn ladder_canonicalize() { + // 2-leg ladder, L=3 → 6 qubits, group order 3 (no swap of legs). + let g = TranslationGroup::ladder(3, 2); + assert_eq!(g.n_qubits(), 6); + assert_eq!(g.order(), 3); + // X on leg 0 site 0: orbit = {(0,0), (0,1), (0,2)}, NOT including leg 1 sites. + let w = word("XIIIII"); // qubit 0 = X + let orbit: Vec = g.orbit(&w).collect(); + assert_eq!(orbit.len(), 3); + let unique: std::collections::HashSet = orbit.into_iter().collect(); + assert_eq!(unique.len(), 3); + // The orbit should be {qubit 0=X, qubit 1=X, qubit 2=X} — all leg 0. + let expected: std::collections::HashSet = + ["XIIIII", "IXIIII", "IIXIII"].iter().map(|s| word(s)).collect(); + assert_eq!(unique, expected); + } + + #[test] + fn canonicalize_pauli_sum_merges_orbit_members() { + let g = TranslationGroup::chain_1d(4); + let mut basis: Vec = vec![word("XIII"), word("IXII"), word("IIXI"), word("IIIX")]; + let mut coeffs: Vec = vec![1.0, 2.0, 3.0, 4.0]; + canonicalize_pauli_sum(&mut basis, &mut coeffs, &g); + // All four collapse to one rep with coeff 1+2+3+4 = 10. + assert_eq!(basis.len(), 1); + assert!((coeffs[0] - 10.0).abs() < 1e-12); + } + + #[test] + fn canonicalize_pauli_sum_keeps_distinct_orbits() { + let g = TranslationGroup::chain_1d(4); + // Two distinct orbits: {XIII, ...} (size 4) and {ZIII, ...} (size 4). + let mut basis: Vec = vec![word("XIII"), word("IXII"), word("ZIII"), word("IZII")]; + let mut coeffs: Vec = vec![1.0, 1.0, 2.0, 2.0]; + canonicalize_pauli_sum(&mut basis, &mut coeffs, &g); + assert_eq!(basis.len(), 2); + // Coefficients should be {2.0, 4.0} in some order. + let mut cs = coeffs.clone(); + cs.sort_by(|a, b| a.partial_cmp(b).unwrap()); + assert!((cs[0] - 2.0).abs() < 1e-12); + assert!((cs[1] - 4.0).abs() < 1e-12); + } + + #[test] + fn canonicalize_with_shift_round_trip() { + // For each cyclic shift of "IIXY" by `a` positions, the shift + // counter returned should reproduce the original word when + // applied to the canonical rep. + let g = TranslationGroup::chain_1d(4); + for src in ["IIXY", "IXYI", "XYII", "YIIX"] { + let w = word(src); + let (rep, cnt) = g.canonicalize_with_shift(&w); + // Apply gen 0 `cnt[0]` times to rep, should equal w. + let mut cur = rep; + for _ in 0..cnt[0] { + cur = g.apply_generator(&cur, 0); + } + assert_eq!(cur, w, "shift {cnt:?} doesn't reproduce {src}"); + } + } + + #[test] + fn character_trivial_sector_is_one() { + let g = TranslationGroup::chain_1d(4); + // k=0 mode → character is always 1. + for cnt in [vec![0u32], vec![1u32], vec![2u32], vec![3u32]] { + let chi = g.character(&[0], &cnt); + assert!((chi - Complex::new(1.0, 0.0)).norm() < 1e-12); + } + } + + #[test] + fn character_obeys_unit_modulus() { + let g = TranslationGroup::chain_1d(4); + for k in 0..4 { + for a in 0..4 { + let chi = g.character(&[k], &[a as u32]); + assert!( + (chi.norm() - 1.0).abs() < 1e-12, + "|χ_{k}(T^{a})| should be 1, got {}", + chi.norm() + ); + } + } + } + + #[test] + fn momentum_zero_complex_merge_matches_real_merge() { + // k=0 sector: complex merge with all-real input should give + // real-valued orbit-rep coefficients equal to the plain + // canonicalize_pauli_sum result. + let g = TranslationGroup::chain_1d(4); + let basis: Vec = vec![word("XIII"), word("IXII"), word("IIXI"), word("IIIX")]; + let real_coeffs = vec![1.0, 2.0, 3.0, 4.0]; + + let mut basis_real = basis.clone(); + let mut coeffs_real = real_coeffs.clone(); + canonicalize_pauli_sum(&mut basis_real, &mut coeffs_real, &g); + + let mut basis_c = basis.clone(); + let mut coeffs_c: Vec> = + real_coeffs.iter().map(|&v| Complex::new(v, 0.0)).collect(); + canonicalize_pauli_sum_complex(&mut basis_c, &mut coeffs_c, &g, &[0]); + + // Plain merge sums all coefficients onto the single orbit-rep: + // 1+2+3+4 = 10. Complex merge does the same with a 1/|G| + // prefactor, so we expect 10/4 = 2.5 on the rep. + assert_eq!(basis_real.len(), 1); + assert_eq!(basis_c.len(), 1); + assert!((coeffs_real[0] - 10.0).abs() < 1e-12); + assert!((coeffs_c[0].re - 2.5).abs() < 1e-12); + assert!(coeffs_c[0].im.abs() < 1e-12); + } + + #[test] + fn momentum_eigenstate_check_passes() { + // O = Σ_j e^{ikj} Z_j for k = 2π/4 (mode 1) is a momentum-k + // eigenstate. check_momentum_sector should accept. + let g = TranslationGroup::chain_1d(4); + let basis: Vec = vec![word("ZIII"), word("IZII"), word("IIZI"), word("IIIZ")]; + let k_mode: i32 = 1; + // Sector condition: c_{T^a p} = e^{-2πi k a / N} c_p. + // Picking c_{Z_0} = 1: c_{Z_a} = e^{-2πi · 1 · a / 4} = (-i)^a. + let coeffs: Vec> = (0..4_i32) + .map(|a| Complex::from_polar(1.0, -2.0 * PI * (k_mode as f64) * (a as f64) / 4.0)) + .collect(); + let res = check_momentum_sector(&basis, &coeffs, &g, &[k_mode], 1e-10); + assert!(res.is_ok(), "valid k-eigenstate failed sector check: {res:?}"); + } + + #[test] + fn momentum_eigenstate_check_fails_for_wrong_sector() { + // Same eigenstate as above, but check against the wrong momentum. + let g = TranslationGroup::chain_1d(4); + let basis: Vec = vec![word("ZIII"), word("IZII"), word("IIZI"), word("IIIZ")]; + let coeffs: Vec> = (0..4_i32) + .map(|a| Complex::from_polar(1.0, -2.0 * PI * 1.0 * (a as f64) / 4.0)) + .collect(); + // Check against k=0 (constant) — should fail. + let res = check_momentum_sector(&basis, &coeffs, &g, &[0], 1e-10); + assert!( + res.is_err(), + "k=1 eigenstate wrongly passed as k=0 sector" + ); + } + + #[test] + fn momentum_eigenstate_round_trip_merge_preserves_rep_coeff() { + // Merge a k=1 eigenstate; the orbit-rep coefficient should be + // unchanged (= 1.0 for our chosen normalization, picking + // c_{Z_0} = 1). + let g = TranslationGroup::chain_1d(4); + let mut basis: Vec = vec![word("ZIII"), word("IZII"), word("IIZI"), word("IIIZ")]; + let mut coeffs: Vec> = (0..4_i32) + .map(|a| Complex::from_polar(1.0, -2.0 * PI * 1.0 * (a as f64) / 4.0)) + .collect(); + canonicalize_pauli_sum_complex(&mut basis, &mut coeffs, &g, &[1]); + assert_eq!(basis.len(), 1); + // The canonical rep of single-Z orbit is Z_0 (lex-min of + // {ZIII, IZII, IIZI, IIIZ} is IIIZ since 'I' < 'Z' lex-wise on + // the (xbits, zbits) tuple; let's just check we got a single + // entry with norm 1. + assert!( + (coeffs[0].norm() - 1.0).abs() < 1e-10, + "expected |c_rep|=1, got {}", + coeffs[0].norm() + ); + } + + /// Trotter-mode end-to-end check that `PauliSum::symmetry_merge` + /// matches plain Trotter evolution post-canonicalized. + /// + /// Setup: n=4 qubit chain, PBC, XY rotations on each bond. Initial + /// operator `O(0) = Σ_j Z_j` is translation-invariant. + /// + /// **dt must be tiny.** First-order Trotter on a chain with PBC is + /// only translation-equivariant up to `O(dt^2)` (gate-order + /// commutator errors are NOT themselves T-symmetric). The + /// "merge-after-each-step" trajectory and the "merge-at-end" + /// trajectory therefore diverge by an amount proportional to that + /// Trotter error. We test in the dt → 0 limit where the divergence + /// is below FP noise. + #[test] + fn pauli_sum_symmetry_merge_matches_plain_trotter() { + use crate::config::indexmap::ByteFxHashF64; + use crate::prelude::*; + + type Cfg = ByteFxHashF64<1>; + + let n: usize = 4; + // Tiny dt — Trotter per-step error scales as dt^2 and shows up + // as a translation-non-equivariant correction; we want it below + // FP noise at the tolerance we assert below (1e-7). + let dt = 1e-5_f64; + let n_steps = 2usize; + let group = TranslationGroup::chain_1d(n); + + // Total-Z initial: O(0) = Σ_j Z_j (translation-invariant). + let mut o_u: PauliSum = PauliSum::builder().n_qubits(n).build(); + let mut o_m: PauliSum = PauliSum::builder().n_qubits(n).build(); + for j in 0..n { + let mut s: Vec = vec!['I'; n]; + s[j] = 'Z'; + let st: String = s.into_iter().collect(); + o_u += (st.as_str(), 1.0); + o_m += (st.as_str(), 1.0); + } + assert_eq!(o_u.len(), n); + assert_eq!(o_m.len(), n); + + // Apply XY Trotter steps to both copies. With merging, call + // symmetry_merge_pauli_sum after each step. + for _ in 0..n_steps { + for j in 0..n { + let nxt = (j + 1) % n; + o_u.rxx(j, nxt, dt); + o_u.ryy(j, nxt, dt); + o_m.rxx(j, nxt, dt); + o_m.ryy(j, nxt, dt); + } + symmetry_merge_pauli_sum(&mut o_m, &group); + } + + // Canonicalize the un-merged result once at the end. + symmetry_merge_pauli_sum(&mut o_u, &group); + + // Compare as (word → coeff) maps, FP tolerance. + let u: FxHashMap<_, f64> = o_u.iter().map(|(w, c)| (*w, *c)).collect(); + let m: FxHashMap<_, f64> = o_m.iter().map(|(w, c)| (*w, *c)).collect(); + assert_eq!( + u.len(), + m.len(), + "post-merge basis sizes differ: u={} vs m={}", + u.len(), + m.len() + ); + let mut max_diff = 0.0_f64; + for (w, &cu) in &u { + let cm = *m.get(w).unwrap_or_else(|| { + panic!("rep present in u but not in m: {:?}", w); + }); + max_diff = max_diff.max((cu - cm).abs()); + } + // At dt = 1e-5 over 2 steps, accumulated Trotter + // commutator-induced T-eq error is ~2·dt^2·|H|^2 ≈ 1e-9; we + // assert 1e-7 to leave safety margin. + assert!( + max_diff < 1e-7, + "Trotter with-merging diverged from without-merging: max |Δc| = {max_diff:e}" + ); + } +} diff --git a/crates/ppvm-python-native/Cargo.toml b/crates/ppvm-python-native/Cargo.toml index 7d948575..4c775866 100644 --- a/crates/ppvm-python-native/Cargo.toml +++ b/crates/ppvm-python-native/Cargo.toml @@ -13,7 +13,16 @@ test = false [dependencies] bnum = "0.13.0" +# mimalloc reduces peak RSS for the allocation-heavy adaptive-Pauli (lindblad) +# paths; installed as the global allocator in lib.rs. +mimalloc = { version = "0.1", default-features = false } +num = "0.4.3" +# numpy 0.29 pairs with pyo3 0.29 (used by the lindblad + symmetry array bindings). +numpy = "0.29" paste = "1.0.15" +ppvm-lindblad = { version = "0.1.0", path = "../ppvm-lindblad" } +ppvm-traits = { version = "0.1.0", path = "../ppvm-traits" } +ppvm-pauli-word = { version = "0.1.0", path = "../ppvm-pauli-word" } ppvm-pauli-sum = { version = "0.1.0", path = "../ppvm-pauli-sum" } ppvm-stim = { version = "0.1.0", path = "../ppvm-stim", features = ["rayon"] } ppvm-tableau = { version = "0.1.0", path = "../ppvm-tableau" } diff --git a/crates/ppvm-python-native/src/interface.rs b/crates/ppvm-python-native/src/interface.rs index 44a47b83..ec690ae8 100644 --- a/crates/ppvm-python-native/src/interface.rs +++ b/crates/ppvm-python-native/src/interface.rs @@ -48,6 +48,139 @@ macro_rules! create_interface_loss_methods { }; } +macro_rules! create_interface_symmetry_methods { + // Skip loss variants: LossyPauliWord canonicalization would need + // simultaneous permutation of the loss bitmap, which we don't + // implement here. + ($name: ident, $type: ident, true) => {}; + ($name: ident, $type: ident, false) => { + #[pymethods] + impl $name { + /// Symmetry-merge this PauliSum in place: replace every + /// Pauli word by its canonical orbit representative under + /// `group`, accumulating coefficients on collision. Reduces + /// entry count by up to `|group|×` for translation-invariant + /// operators. + /// + /// See `ppvm._core.TranslationGroup` for constructors + /// (`chain_1d`, `torus_2d`, `torus_3d`, `ladder`). + /// + /// Plain real-coefficient merge (the `k=0` symmetry sector). + /// For non-trivial momentum sectors use `momentum_merge`. + pub fn symmetry_merge( + &mut self, + group: &crate::symmetry::TranslationGroup, + ) -> pyo3::PyResult<()> { + if self.inner.n_qubits() != group.core().n_qubits() { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "PauliSum has {} qubits but the TranslationGroup acts on {}", + self.inner.n_qubits(), + group.core().n_qubits(), + ))); + } + ppvm_pauli_sum::symmetry::symmetry_merge_pauli_sum( + &mut self.inner, + group.core(), + ); + Ok(()) + } + + /// Phase-aware (momentum-sector) merge for a complex operator + /// carried as a *real pair*: `self` is the real part, `other` + /// the imaginary part of `O = self + i·other`. Both are + /// overwritten in place with the orbit-representative form + /// projected onto momentum sector `momentum` (one integer mode + /// per group generator; `[0,…]` is the trivial sector and + /// reduces to `symmetry_merge`). This generalizes + /// `symmetry_merge` to k != 0 while keeping real coefficients on + /// the Python side — the only place complex arithmetic appears + /// is the internal character-weighted fold, reusing the tested + /// `canonicalize_pauli_sum_complex`. + /// + /// `self` and `other` must be distinct objects with identical + /// qubit count. After a translation-covariant gate layer this + /// is exact; under a generic Trotter step it carries the same + /// O(dt^{p+1}) equivariance error as the k=0 merge. + #[pyo3(signature = (other, group, momentum))] + pub fn momentum_merge( + &mut self, + mut other: pyo3::PyRefMut<'_, Self>, + group: &crate::symmetry::TranslationGroup, + momentum: Vec, + ) -> pyo3::PyResult<()> { + let n_g = group.core().n_qubits(); + for (label, n) in [ + ("self", self.inner.n_qubits()), + ("other", other.inner.n_qubits()), + ] { + if n != n_g { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "{label} PauliSum has {n} qubits but the \ + TranslationGroup acts on {n_g}", + ))); + } + } + if momentum.len() != group.core().n_generators() { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "momentum has {} entries but the group has {} generators", + momentum.len(), + group.core().n_generators(), + ))); + } + // Gather both real components into word -> (re + i·im). + let mut combined: std::collections::HashMap< + <$type as Config>::PauliWordType, + num::Complex, + > = std::collections::HashMap::new(); + for (w, v) in self.inner.data().iter() { + combined + .entry(w.clone()) + .or_insert(num::Complex::new(0.0, 0.0)) + .re += *v; + } + for (w, v) in other.inner.data().iter() { + combined + .entry(w.clone()) + .or_insert(num::Complex::new(0.0, 0.0)) + .im += *v; + } + let mut basis = Vec::with_capacity(combined.len()); + let mut coeffs = Vec::with_capacity(combined.len()); + for (w, c) in combined { + basis.push(w); + coeffs.push(c); + } + // Character-weighted fold onto orbit reps. + // `canonicalize_pauli_sum_complex` carries a 1/|G| prefactor; + // we rescale by |G| so the merge is the *summing* projector + // (like `symmetry_merge`): idempotent on already-merged input, + // hence stable under merging after every Trotter step. + ppvm_pauli_sum::symmetry::canonicalize_pauli_sum_complex( + &mut basis, + &mut coeffs, + group.core(), + &momentum, + ); + let scale = group.core().order() as f64; + // Write the real/imag parts back into the two sums. + self.inner.data_mut().clear(); + other.inner.data_mut().clear(); + for (w, c) in basis.into_iter().zip(coeffs.into_iter()) { + let re = c.re * scale; + let im = c.im * scale; + if re != 0.0 { + self.inner += (w.clone(), re); + } + if im != 0.0 { + other.inner += (w, im); + } + } + Ok(()) + } + } + }; +} + macro_rules! create_strategy { (false, $min_abs_coeff:ident, $max_pauli_weight:ident, $_max_loss_weight:ident) => { CombinedStrategy( @@ -403,6 +536,12 @@ macro_rules! create_interface { } } + // `symmetry_merge` only makes sense on non-loss variants — the + // canonicalization permutes qubit positions and the loss + // bitmap would need a parallel permutation that we don't + // attempt here. + create_interface_symmetry_methods!($name, $type, $loss); + create_interface_loss_methods!($name, $type, $loss); }; } diff --git a/crates/ppvm-python-native/src/lib.rs b/crates/ppvm-python-native/src/lib.rs index d78dd665..5ebc9532 100644 --- a/crates/ppvm-python-native/src/lib.rs +++ b/crates/ppvm-python-native/src/lib.rs @@ -1,13 +1,22 @@ // SPDX-FileCopyrightText: 2026 The PPVM Authors // SPDX-License-Identifier: Apache-2.0 +// mimalloc returns freed pages to the kernel more aggressively than the +// default system allocator. This materially reduces peak RSS for the +// allocation-heavy adaptive-Pauli paths (leakage + generator each +// allocate hundreds of MB of transient Vec data per pc_step). +#[global_allocator] +static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; + use pyo3::exceptions::PyValueError; use pyo3::prelude::*; pub mod interface; pub mod interface_tableau; pub mod interface_tableau_sum; +pub mod lindblad; pub mod stim_program; +pub mod symmetry; pub(crate) fn flat_pairs(targets: &[usize]) -> PyResult> { if !targets.len().is_multiple_of(2) { @@ -296,4 +305,18 @@ pub mod _core { // Stim #[pymodule_export] pub use crate::stim_program::PyStimProgram; + + // Lindbladian time evolution + #[pymodule_export] + pub use crate::lindblad::LindbladSpec; + + // Symmetry merging + #[pymodule_export] + pub use crate::symmetry::TranslationGroup; + #[pymodule_export] + pub use crate::symmetry::canonicalize_basis_arr; + #[pymodule_export] + pub use crate::symmetry::canonicalize_basis_arr_complex; + #[pymodule_export] + pub use crate::symmetry::check_momentum_sector_arr; } diff --git a/crates/ppvm-python-native/src/lindblad.rs b/crates/ppvm-python-native/src/lindblad.rs new file mode 100644 index 00000000..064d5954 --- /dev/null +++ b/crates/ppvm-python-native/src/lindblad.rs @@ -0,0 +1,486 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! PyO3 wrapper around [`ppvm_lindblad::LindbladSpec`]. +//! +//! All algorithmic work — Pauli arithmetic, active-site iteration, and the +//! dissipator branches (Hermitian Pauli fast path vs general complex Pauli +//! sum) — lives in the [`ppvm_lindblad`] crate. This module is responsible +//! only for the Python boundary: decoding the `(N, n_qubits)` numpy uint8 +//! arrays into [`ppvm_lindblad::Word`] vectors, and re-encoding outputs +//! back into numpy. + +use std::collections::HashMap; + +use num::Complex; +use numpy::{ + Complex64, IntoPyArray, PyArray1, PyArray2, PyArrayMethods, PyReadonlyArray1, + PyReadonlyArray2, +}; +use ppvm_lindblad::{ + JumpInput, LindbladSpec as CoreSpec, Word, codes_from_word, word_from_codes, +}; +use pyo3::{exceptions::PyValueError, prelude::*}; + +type PyPauliMap<'py> = (Bound<'py, PyArray2>, Bound<'py, PyArray1>); +type PyCoo<'py> = ( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +); + +fn map_err(e: ppvm_lindblad::Error) -> PyErr { + PyValueError::new_err(e.to_string()) +} + +/// Reject a basis that contains the same Pauli word at two distinct rows. +/// Duplicate rows would silently overwrite each other in the generator's +/// row-index map and produce an incorrect sparse matrix. +fn assert_basis_unique(basis: &[Word]) -> PyResult<()> { + let mut seen: HashMap<&Word, usize> = HashMap::with_capacity(basis.len()); + for (i, w) in basis.iter().enumerate() { + if let Some(prev) = seen.insert(w, i) { + return Err(PyValueError::new_err(format!( + "basis contains duplicate Pauli word at row {prev} and row {i}" + ))); + } + } + Ok(()) +} + +/// Decode a `(N, n_qubits)` uint8 ndarray view into `N` packed [`Word`]s. +pub(crate) fn decode_basis(view: &numpy::ndarray::ArrayView2, n_qubits: usize) -> PyResult> { + let n_basis = view.shape()[0]; + let n_cols = view.shape()[1]; + if n_cols != n_qubits { + return Err(PyValueError::new_err(format!( + "basis has {n_cols} columns but spec.n_qubits = {n_qubits}" + ))); + } + let mut out = Vec::with_capacity(n_basis); + let mut row_buf = vec![0u8; n_qubits]; + for i in 0..n_basis { + let row = view.row(i); + for (q, slot) in row_buf.iter_mut().enumerate() { + *slot = row[q]; + } + out.push(word_from_codes(&row_buf).map_err(map_err)?); + } + Ok(out) +} + +/// Pack `Vec<(Word, f64)>` into the standard PyO3 return shape. +fn pack_pauli_map<'py>( + py: Python<'py>, + pairs: Vec<(Word, f64)>, + n_qubits: usize, +) -> PyResult> { + let m = pairs.len(); + let mut basis = vec![0u8; m * n_qubits]; + let mut coeffs = vec![0f64; m]; + for (i, (w, c)) in pairs.into_iter().enumerate() { + codes_from_word(&w, &mut basis[i * n_qubits..(i + 1) * n_qubits]); + coeffs[i] = c; + } + let basis_arr = basis + .into_pyarray(py) + .reshape([m, n_qubits]) + .map_err(|e| PyValueError::new_err(format!("reshape failed: {e}")))?; + Ok((basis_arr, coeffs.into_pyarray(py))) +} + +/// PyO3 facade exposing [`ppvm_lindblad::LindbladSpec`] to Python. +#[pyclass] +pub struct LindbladSpec { + inner: CoreSpec, +} + +#[pymethods] +impl LindbladSpec { + /// Construct a Lindbladian spec from Hamiltonian terms and jump operators. + /// + /// `jump_lincombs[k]` is a list of `(pauli_string, real, imag)` triples + /// encoding `L_k = Σ_a (re + i·im) P_a`. A length-1 jump with `im == 0` + /// is routed to the Hermitian-Pauli fast path (with rate scaled by `re²`). + #[new] + #[pyo3(signature = (n_qubits, h_terms, h_coeffs, jump_lincombs, jump_rates))] + fn new( + n_qubits: usize, + h_terms: Vec, + h_coeffs: Vec, + jump_lincombs: Vec>, + jump_rates: Vec, + ) -> PyResult { + if h_terms.len() != h_coeffs.len() { + return Err(PyValueError::new_err( + "h_terms and h_coeffs must have the same length", + )); + } + if jump_lincombs.len() != jump_rates.len() { + return Err(PyValueError::new_err( + "jump_lincombs and jump_rates must have the same length", + )); + } + let h: Vec<(String, f64)> = h_terms.into_iter().zip(h_coeffs).collect(); + let jumps: Vec = jump_lincombs + .into_iter() + .zip(jump_rates) + .map(|(lincomb, rate)| JumpInput { + lincomb: lincomb + .into_iter() + .map(|(s, re, im)| (s, Complex::new(re, im))) + .collect(), + rate, + }) + .collect(); + let inner = CoreSpec::new(n_qubits, &h, &jumps).map_err(map_err)?; + Ok(Self { inner }) + } + + #[getter] + fn n_qubits(&self) -> usize { + self.inner.n_qubits() + } + + #[getter] + fn num_h_terms(&self) -> usize { + self.inner.num_h_terms() + } + + #[getter] + fn num_jump_terms(&self) -> usize { + self.inner.num_jump_terms() + } + + /// Apply `L*` to a single Pauli string `p`. + fn action<'py>( + &self, + py: Python<'py>, + p: PyReadonlyArray1<'py, u8>, + ) -> PyResult> { + let p_slice = p.as_slice()?; + let p_word = word_from_codes(p_slice).map_err(map_err)?; + let pairs = self.inner.action(&p_word); + pack_pauli_map(py, pairs, self.inner.n_qubits()) + } + + /// Off-basis component of `L*( Σ_j coeffs[j] · basis[j] )`. + #[pyo3(signature = (basis, coeffs, protected = None))] + fn leakage<'py>( + &self, + py: Python<'py>, + basis: PyReadonlyArray2<'py, u8>, + coeffs: PyReadonlyArray1<'py, f64>, + protected: Option>, + ) -> PyResult> { + let n_q = self.inner.n_qubits(); + let basis_view = basis.as_array(); + let basis_words = decode_basis(&basis_view, n_q)?; + let coeffs_slice = coeffs.as_slice()?; + if coeffs_slice.len() != basis_words.len() { + return Err(PyValueError::new_err(format!( + "coeffs has length {} but basis has {} rows", + coeffs_slice.len(), + basis_words.len() + ))); + } + let protected_words: Vec = if let Some(ref prot) = protected { + let pv = prot.as_array(); + decode_basis(&pv, n_q)? + } else { + Vec::new() + }; + let pairs = self + .inner + .leakage(&basis_words, coeffs_slice, &protected_words) + .map_err(map_err)?; + pack_pauli_map(py, pairs, n_q) + } + + /// One predictor-corrector adaptive step. + /// + /// Internally: expand basis with first-hop leakage, predictor step + /// (`exp(dt·M)`), expand again with second-hop leakage from the + /// predicted state, then redo the step from the pre-step coefficients + /// on the doubly-enlarged basis. The matrix exponential is computed in + /// Rust via `quspin-expm`; no scipy required. + /// + /// Returns `(new_basis, new_coeffs)`. + /// + /// `max_basis` is a hard rank cap on the retained basis; `admit_basis` + /// (when `> max_basis`) bounds in-step enrichment instead, so the final + /// cap selects the top-`max_basis` strings over the whole union + /// (displacement truncation). `drop_tol` prunes by magnitude after the + /// step; `tau_add` filters leakage admission by inflow rate. Protected + /// words are never dropped. + #[pyo3(signature = ( + basis, coeffs, dt, max_basis, + drop_tol = 0.0, + protected = None, + num_threads = None, + admit_basis = None, + tau_add = None, + ))] + #[allow(clippy::too_many_arguments)] + fn pc_step<'py>( + &self, + py: Python<'py>, + basis: PyReadonlyArray2<'py, u8>, + coeffs: PyReadonlyArray1<'py, f64>, + dt: f64, + max_basis: usize, + drop_tol: f64, + protected: Option>, + num_threads: Option, + admit_basis: Option, + tau_add: Option, + ) -> PyResult> { + let n_q = self.inner.n_qubits(); + let basis_view = basis.as_array(); + let mut basis_words = decode_basis(&basis_view, n_q)?; + assert_basis_unique(&basis_words)?; + let mut coeffs_vec = coeffs.as_slice()?.to_vec(); + if coeffs_vec.len() != basis_words.len() { + return Err(PyValueError::new_err(format!( + "coeffs has length {} but basis has {} rows", + coeffs_vec.len(), + basis_words.len() + ))); + } + let protected_words: Vec = if let Some(ref p) = protected { + decode_basis(&p.as_array(), n_q)? + } else { + Vec::new() + }; + self.inner + .pc_step( + &mut basis_words, + &mut coeffs_vec, + dt, + &protected_words, + &ppvm_lindblad::PcStepConfig { + max_basis, + admit_basis, + drop_tol, + tau_add, + num_threads, + }, + ) + .map_err(map_err)?; + + // Pack output. Basis may have grown; coeffs has the same new length. + let pairs: Vec<(Word, f64)> = basis_words.into_iter().zip(coeffs_vec).collect(); + pack_pauli_map(py, pairs, n_q) + } + + /// Same as [`Self::pc_step`] but also returns a dict mapping phase + /// name → microseconds spent in that phase, for profiling. + #[pyo3(signature = ( + basis, coeffs, dt, max_basis, + drop_tol = 0.0, + protected = None, + num_threads = None, + admit_basis = None, + tau_add = None, + ))] + #[allow(clippy::too_many_arguments)] + fn pc_step_timed<'py>( + &self, + py: Python<'py>, + basis: PyReadonlyArray2<'py, u8>, + coeffs: PyReadonlyArray1<'py, f64>, + dt: f64, + max_basis: usize, + drop_tol: f64, + protected: Option>, + num_threads: Option, + admit_basis: Option, + tau_add: Option, + ) -> PyResult<(PyPauliMap<'py>, Bound<'py, pyo3::types::PyDict>)> { + let n_q = self.inner.n_qubits(); + let basis_view = basis.as_array(); + let mut basis_words = decode_basis(&basis_view, n_q)?; + assert_basis_unique(&basis_words)?; + let mut coeffs_vec = coeffs.as_slice()?.to_vec(); + if coeffs_vec.len() != basis_words.len() { + return Err(PyValueError::new_err(format!( + "coeffs has length {} but basis has {} rows", + coeffs_vec.len(), + basis_words.len() + ))); + } + let protected_words: Vec = if let Some(ref p) = protected { + decode_basis(&p.as_array(), n_q)? + } else { + Vec::new() + }; + let timings = self + .inner + .pc_step_timed( + &mut basis_words, + &mut coeffs_vec, + dt, + &protected_words, + &ppvm_lindblad::PcStepConfig { + max_basis, + admit_basis, + drop_tol, + tau_add, + num_threads, + }, + ) + .map_err(map_err)?; + + let pairs: Vec<(Word, f64)> = basis_words.into_iter().zip(coeffs_vec).collect(); + let map = pack_pauli_map(py, pairs, n_q)?; + let d = pyo3::types::PyDict::new(py); + d.set_item("leakage1_us", timings.leakage1_us)?; + d.set_item("expand1_us", timings.expand1_us)?; + d.set_item("expm1_us", timings.expm1_us)?; + d.set_item("leakage2_us", timings.leakage2_us)?; + d.set_item("expand2_us", timings.expand2_us)?; + d.set_item("expm2_us", timings.expm2_us)?; + Ok((map, d)) + } + + /// Per-step orbit-rep predictor-corrector evolution under + /// translation symmetry. State lives entirely in **orbit-rep form**: + /// basis contains only canonical orbit representatives, coefficients + /// are complex. The action is phase-aware: output Paulis canonicalize + /// to their orbit rep with momentum-character weight. + /// + /// Per-step memory benefit: basis is ~|group|× smaller than the + /// full-basis representation, and the reduction persists through + /// every step. + /// + /// **Pre-condition**: every row of `basis` must be the canonical + /// orbit representative of its translation orbit under `group`. + /// Pass `canonicalize_first=True` to enforce this on entry (rewrites + /// each basis row to its canonical rep; coefficients unchanged). + /// Default `False` — the caller is trusted. + /// + /// `max_basis` is a hard rank cap on the live orbit-rep basis: + /// enrichment adds at most `max_basis − basis.len()` of the largest + /// leakage reps and the post-step basis is trimmed to the top-`max_basis` + /// by `|c|` (protected reps always kept). Pass a large value for the + /// near-exact case. `drop_tol` additionally prunes by magnitude. + #[pyo3(signature = ( + basis, coeffs, dt, max_basis, + group, momentum, + drop_tol = 0.0, + protected = None, + canonicalize_first = false, + admit_basis = None, + tau_add = None, + ))] + #[allow(clippy::too_many_arguments)] + fn pc_step_orbit_rep<'py>( + &self, + py: Python<'py>, + basis: PyReadonlyArray2<'py, u8>, + coeffs: PyReadonlyArray1<'py, Complex64>, + dt: f64, + max_basis: usize, + group: &crate::symmetry::TranslationGroup, + momentum: PyReadonlyArray1<'py, i32>, + drop_tol: f64, + protected: Option>, + canonicalize_first: bool, + admit_basis: Option, + tau_add: Option, + ) -> PyResult<(Bound<'py, PyArray2>, Bound<'py, PyArray1>)> { + use num::Complex; + use ppvm_lindblad::orbit_rep; + + let n_q = self.inner.n_qubits(); + let basis_view = basis.as_array(); + let mut basis_words = decode_basis(&basis_view, n_q)?; + let coeffs_slice = coeffs.as_slice()?; + if coeffs_slice.len() != basis_words.len() { + return Err(PyValueError::new_err(format!( + "coeffs has length {} but basis has {} rows", + coeffs_slice.len(), + basis_words.len() + ))); + } + let mut coeffs_vec: Vec> = coeffs_slice + .iter() + .map(|c| Complex::new(c.re, c.im)) + .collect(); + let protected_words: Vec = if let Some(ref p) = protected { + decode_basis(&p.as_array(), n_q)? + } else { + Vec::new() + }; + let k_slice = momentum.as_slice()?; + if k_slice.len() != group.core().n_generators() { + return Err(PyValueError::new_err(format!( + "momentum has {} entries but group has {} generators", + k_slice.len(), + group.core().n_generators() + ))); + } + if canonicalize_first { + orbit_rep::canonicalize_basis_to_rep(&mut basis_words, group.core()); + } + orbit_rep::pc_step_orbit_rep( + &self.inner, + &mut basis_words, + &mut coeffs_vec, + dt, + &protected_words, + group.core(), + k_slice, + &ppvm_lindblad::PcStepConfig { + max_basis, + admit_basis, + drop_tol, + tau_add, + num_threads: None, + }, + ) + .map_err(map_err)?; + + let m = basis_words.len(); + let mut out_basis = vec![0u8; m * n_q]; + for (i, w) in basis_words.iter().enumerate() { + codes_from_word(w, &mut out_basis[i * n_q..(i + 1) * n_q]); + } + let out_coeffs: Vec = coeffs_vec + .iter() + .map(|c| Complex64::new(c.re, c.im)) + .collect(); + let basis_arr = out_basis + .into_pyarray(py) + .reshape([m, n_q]) + .map_err(|e| PyValueError::new_err(format!("reshape failed: {e}")))?; + Ok((basis_arr, out_coeffs.into_pyarray(py))) + } + + /// Sparse generator matrix in COO form: `(rows, cols, vals)`. + fn generator<'py>( + &self, + py: Python<'py>, + basis: PyReadonlyArray2<'py, u8>, + ) -> PyResult> { + let n_q = self.inner.n_qubits(); + let basis_view = basis.as_array(); + let basis_words = decode_basis(&basis_view, n_q)?; + assert_basis_unique(&basis_words)?; + let triplets = self.inner.generator(&basis_words); + let total = triplets.len(); + let mut rows = Vec::with_capacity(total); + let mut cols = Vec::with_capacity(total); + let mut vals = Vec::with_capacity(total); + for (r, c, v) in triplets { + rows.push(r as u64); + cols.push(c as u64); + vals.push(v); + } + Ok(( + rows.into_pyarray(py), + cols.into_pyarray(py), + vals.into_pyarray(py), + )) + } +} diff --git a/crates/ppvm-python-native/src/symmetry.rs b/crates/ppvm-python-native/src/symmetry.rs new file mode 100644 index 00000000..d3c69b6e --- /dev/null +++ b/crates/ppvm-python-native/src/symmetry.rs @@ -0,0 +1,328 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Python bindings for the symmetry-merging primitive. +//! +//! Exposes: +//! - [`TranslationGroup`] PyO3 class with constructors for 1D, 2D, 3D +//! tori and multi-leg ladders, plus a generic generator-list path. +//! - [`canonicalize_basis_arr`] / [`canonicalize_basis_arr_complex`] free +//! functions that merge the numpy `(basis_arr, coeffs)` representation +//! used by `Lindbladian.pc_step_arr`. + +use num::Complex; +use numpy::{ + Complex64, IntoPyArray, PyArray1, PyArray2, PyArrayMethods, PyReadonlyArray1, + PyReadonlyArray2, +}; +use ppvm_lindblad::{codes_from_word, word_from_codes}; +use ppvm_pauli_sum::symmetry as core_sym; +use pyo3::{exceptions::PyValueError, prelude::*}; + +type PyPauliMap<'py> = (Bound<'py, PyArray2>, Bound<'py, PyArray1>); +type PyPauliMapComplex<'py> = (Bound<'py, PyArray2>, Bound<'py, PyArray1>); + +/// A finite abelian symmetry group acting on qubit positions by +/// permutations. Use this to merge translation-equivalent Pauli strings +/// in either the `Lindbladian.pc_step_arr` basis or the `PauliSum` +/// dictionary, reducing per-step memory by up to `|G|×`. +/// +/// Build via the static methods: +/// - `TranslationGroup.chain_1d(n)` — 1D chain of `n` sites with PBC. +/// - `TranslationGroup.torus_2d(lx, ly)` — 2D torus; qubit `(i, j)` at +/// index `j*lx + i`. +/// - `TranslationGroup.torus_3d(lx, ly, lz)` — 3D torus; qubit +/// `(i, j, k)` at index `k*lx*ly + j*lx + i`. +/// - `TranslationGroup.ladder(l, n_legs)` — `n_legs`-leg ladder of `l` +/// sites, translation only along chain direction; qubit `(leg, j)` at +/// index `leg*l + j`. +/// - `TranslationGroup.from_generators(n_qubits, perms, orders)` — +/// arbitrary list of generator permutations + cyclic orders. +#[pyclass(frozen)] +pub struct TranslationGroup { + pub(crate) inner: core_sym::TranslationGroup, +} + +impl TranslationGroup { + /// Accessor for the underlying [`ppvm_pauli_sum::symmetry::TranslationGroup`]. + /// Used by other crate-internal modules (e.g. the PauliSum interface + /// macro) to call into the core merging API. + pub fn core(&self) -> &core_sym::TranslationGroup { + &self.inner + } +} + +#[pymethods] +impl TranslationGroup { + #[staticmethod] + pub fn chain_1d(n: usize) -> Self { + Self { + inner: core_sym::TranslationGroup::chain_1d(n), + } + } + + #[staticmethod] + pub fn torus_2d(lx: usize, ly: usize) -> Self { + Self { + inner: core_sym::TranslationGroup::torus_2d(lx, ly), + } + } + + #[staticmethod] + pub fn torus_3d(lx: usize, ly: usize, lz: usize) -> Self { + Self { + inner: core_sym::TranslationGroup::torus_3d(lx, ly, lz), + } + } + + #[staticmethod] + pub fn ladder(l: usize, n_legs: usize) -> Self { + Self { + inner: core_sym::TranslationGroup::ladder(l, n_legs), + } + } + + #[staticmethod] + pub fn from_generators( + n_qubits: usize, + perms: Vec>, + orders: Vec, + ) -> PyResult { + if perms.len() != orders.len() { + return Err(PyValueError::new_err(format!( + "perms ({} generators) and orders ({}) must have the same length", + perms.len(), + orders.len() + ))); + } + for (g, perm) in perms.iter().enumerate() { + if perm.len() != n_qubits { + return Err(PyValueError::new_err(format!( + "generator {g}: permutation length {} != n_qubits {n_qubits}", + perm.len() + ))); + } + let mut seen = vec![false; n_qubits]; + for &p in perm { + let p = p as usize; + if p >= n_qubits { + return Err(PyValueError::new_err(format!( + "generator {g}: target {p} out of range [0, {n_qubits})" + ))); + } + if seen[p] { + return Err(PyValueError::new_err(format!( + "generator {g}: not a permutation (duplicate target {p})" + ))); + } + seen[p] = true; + } + } + Ok(Self { + inner: core_sym::TranslationGroup::from_generators(n_qubits, perms, orders), + }) + } + + /// Number of qubits this group acts on. + #[getter] + pub fn n_qubits(&self) -> usize { + self.inner.n_qubits() + } + + /// Number of generators (rank as an abelian product group). + #[getter] + pub fn n_generators(&self) -> usize { + self.inner.n_generators() + } + + /// Total group order: product of generator orders. + #[getter] + pub fn order(&self) -> usize { + self.inner.order() + } + + /// Return the canonical (lex-min) orbit representative of `pauli`. + /// `pauli` is a length-`n_qubits` uint8 array with the encoding + /// `0=I, 1=X, 2=Z, 3=Y`. Result is the same shape. + pub fn canonicalize<'py>( + &self, + py: Python<'py>, + pauli: PyReadonlyArray1<'py, u8>, + ) -> PyResult>> { + let codes = pauli.as_slice()?; + if codes.len() != self.inner.n_qubits() { + return Err(PyValueError::new_err(format!( + "pauli has length {} but group expects {} qubits", + codes.len(), + self.inner.n_qubits() + ))); + } + let w = word_from_codes(codes).map_err(|e| PyValueError::new_err(e.to_string()))?; + let canon = self.inner.canonicalize(&w); + let mut out = vec![0u8; codes.len()]; + codes_from_word(&canon, &mut out); + Ok(out.into_pyarray(py)) + } +} + +/// Phase-aware merge of a complex-coefficient `(basis_arr, coeffs)` +/// Pauli sum into orbit-rep form, projected onto momentum sector +/// `momentum`. +/// +/// `momentum` is a length-`group.n_generators` integer array of mode +/// indices; the wavenumber along generator `g` is +/// `2π · momentum[g] / group.generator_order(g)`. Use `momentum=[0, …]` +/// for the trivial (k=0) sector — equivalent to plain merging modulo +/// the 1/|G| normalization the complex merge applies. +/// +/// If the input is **not** in sector `momentum`, the projection +/// silently throws away the other components. Use +/// [`check_momentum_sector_arr`] beforehand to validate. +#[pyfunction] +pub fn canonicalize_basis_arr_complex<'py>( + py: Python<'py>, + basis: PyReadonlyArray2<'py, u8>, + coeffs: PyReadonlyArray1<'py, Complex64>, + group: &TranslationGroup, + momentum: PyReadonlyArray1<'py, i32>, +) -> PyResult> { + let basis_view = basis.as_array(); + let n_q = group.inner.n_qubits(); + if basis_view.shape().get(1).copied() != Some(n_q) { + return Err(PyValueError::new_err(format!( + "basis has {} qubits per row but group acts on {n_q}", + basis_view.shape().get(1).copied().unwrap_or(0) + ))); + } + let n = basis_view.shape()[0]; + let coeffs_slice = coeffs.as_slice()?; + if coeffs_slice.len() != n { + return Err(PyValueError::new_err(format!( + "coeffs has length {} but basis has {} rows", + coeffs_slice.len(), + n + ))); + } + let k_slice = momentum.as_slice()?; + if k_slice.len() != group.inner.n_generators() { + return Err(PyValueError::new_err(format!( + "momentum has {} entries but group has {} generators", + k_slice.len(), + group.inner.n_generators() + ))); + } + let mut basis_words = crate::lindblad::decode_basis(&basis_view, n_q)?; + let mut coeffs_vec: Vec> = coeffs_slice + .iter() + .map(|c| Complex::new(c.re, c.im)) + .collect(); + + core_sym::canonicalize_pauli_sum_complex( + &mut basis_words, + &mut coeffs_vec, + &group.inner, + k_slice, + ); + + let m = basis_words.len(); + let mut out_basis = vec![0u8; m * n_q]; + for (i, w) in basis_words.iter().enumerate() { + codes_from_word(w, &mut out_basis[i * n_q..(i + 1) * n_q]); + } + let out_coeffs: Vec = + coeffs_vec.iter().map(|c| Complex64::new(c.re, c.im)).collect(); + let basis_arr = out_basis + .into_pyarray(py) + .reshape([m, n_q]) + .map_err(|e| PyValueError::new_err(format!("reshape failed: {e}")))?; + Ok((basis_arr, out_coeffs.into_pyarray(py))) +} + +/// Verify that a `(basis_arr, complex_coeffs)` Pauli sum lies in the +/// momentum sector `momentum` under `group`. Returns `None` on pass, +/// raises a `ValueError` with diagnostic info on fail. +/// +/// `tol` is the relative tolerance on coefficient comparison; default +/// `1e-8`. +#[pyfunction] +#[pyo3(signature = (basis, coeffs, group, momentum, tol = 1e-8))] +pub fn check_momentum_sector_arr<'py>( + basis: PyReadonlyArray2<'py, u8>, + coeffs: PyReadonlyArray1<'py, Complex64>, + group: &TranslationGroup, + momentum: PyReadonlyArray1<'py, i32>, + tol: f64, +) -> PyResult<()> { + let basis_view = basis.as_array(); + let n_q = group.inner.n_qubits(); + if basis_view.shape().get(1).copied() != Some(n_q) { + return Err(PyValueError::new_err(format!( + "basis has {} qubits per row but group acts on {n_q}", + basis_view.shape().get(1).copied().unwrap_or(0) + ))); + } + let coeffs_slice = coeffs.as_slice()?; + let k_slice = momentum.as_slice()?; + let basis_words = crate::lindblad::decode_basis(&basis_view, n_q)?; + let coeffs_vec: Vec> = coeffs_slice + .iter() + .map(|c| Complex::new(c.re, c.im)) + .collect(); + core_sym::check_momentum_sector(&basis_words, &coeffs_vec, &group.inner, k_slice, tol) + .map_err(|e| PyValueError::new_err(format!("{e}"))) +} + +/// Merge a `(basis_arr, coeffs)` Pauli sum (the representation used by +/// `Lindbladian.pc_step_arr`) into orbit-representative form. +/// Each row of `basis_arr` is replaced by its canonical +/// representative; coefficients of rows collapsing to the same rep are +/// summed. +/// +/// Returns `(merged_basis_arr, merged_coeffs)`. Output length ≤ input +/// length. +/// +/// For dynamics that commute with `group` and initial states that are +/// `group`-invariant, this preserves all `group`-invariant expectation +/// values (Theorem 1 of Teng et al., arXiv:2512.12094). +#[pyfunction] +pub fn canonicalize_basis_arr<'py>( + py: Python<'py>, + basis: PyReadonlyArray2<'py, u8>, + coeffs: PyReadonlyArray1<'py, f64>, + group: &TranslationGroup, +) -> PyResult> { + let basis_view = basis.as_array(); + let n_q = group.inner.n_qubits(); + if basis_view.shape().get(1).copied() != Some(n_q) { + return Err(PyValueError::new_err(format!( + "basis has {} qubits per row but group acts on {n_q}", + basis_view.shape().get(1).copied().unwrap_or(0) + ))); + } + let n = basis_view.shape()[0]; + let coeffs_slice = coeffs.as_slice()?; + if coeffs_slice.len() != n { + return Err(PyValueError::new_err(format!( + "coeffs has length {} but basis has {} rows", + coeffs_slice.len(), + n + ))); + } + + let mut basis_words = crate::lindblad::decode_basis(&basis_view, n_q)?; + let mut coeffs_vec = coeffs_slice.to_vec(); + + core_sym::canonicalize_pauli_sum(&mut basis_words, &mut coeffs_vec, &group.inner); + + // Re-encode. + let m = basis_words.len(); + let mut out_basis = vec![0u8; m * n_q]; + for (i, w) in basis_words.iter().enumerate() { + codes_from_word(w, &mut out_basis[i * n_q..(i + 1) * n_q]); + } + let basis_arr = out_basis + .into_pyarray(py) + .reshape([m, n_q]) + .map_err(|e| PyValueError::new_err(format!("reshape failed: {e}")))?; + Ok((basis_arr, coeffs_vec.into_pyarray(py))) +} diff --git a/docs/autotune/2026-07-01-expm-memory/log.md b/docs/autotune/2026-07-01-expm-memory/log.md new file mode 100644 index 00000000..e6f74eec --- /dev/null +++ b/docs/autotune/2026-07-01-expm-memory/log.md @@ -0,0 +1,165 @@ +# Log for 2026-07-01-expm-memory + +Goal: reduce the optimized expm's memory (it wins/ties wall for N>=10 after the +cache-action + tol-matched work in [[../2026-07-01-expm-pc-step]], but uses +~2-6x Trotter's RSS, growing with N). + +Build reminder (learned the hard way last experiment): rebuild the native from +`ppvm-python/`, NOT `crates/ppvm-python-native/`, or `import ppvm._core` loads a +STALE .so: + `cd ppvm-python && VIRTUAL_ENV=.venv uvx --from maturin maturin develop --release --uv` + +## Profile first: where does the expm RSS go? (N=10, drop=1e-4, dim~262k, RSS 396MB) + +MEMPROF (env-gated eprintln, since removed): +- cols (CSC action cache, nnz*16B for (u32,f64) + dim*24B vec-of-vecs hdrs): **66 MB** +- index (Word->u32): ~14 MB +- partial_ys (threads x dim dense matvec accumulators): **20 MB (~5%)** +- coeffs: 2 MB +- remainder (~294 MB): python/numpy/ppvm baseline ~150 MB + pc_step transient + (leakage-enriched basis, coeffs_predict, expm work vectors, leak HashMaps). + +=> The matvec accumulator `partial_ys` I originally suspected is only ~5% of +RSS. The expm-specific memory is dominated by the action cache `cols`, and +everything (cols, index, coeffs, partial_ys, the basis itself) scales with +`dim` = basis size. The real memory lever is therefore whatever bounds `dim`: +`drop_tol`, `max_basis`, `dt`, and the accuracy target. + +## Iteration 1: atomic-scatter matvec (remove partial_ys) — DISCARD + +Replaced the threads x dim dense accumulators with atomic f64 CAS into one +shared output (O(dim) memory, f64 preserved -> tests bit-exact, 9/9 pass). +Measured N=10 drop=1e-4 (correct build): wall 19.4-20.0s vs 17.8s baseline +(~12% SLOWER), RSS 363-393MB vs 396MB (negligible, since partial_ys was ~5%). +Bad trade (CAS-per-nnz cost > the small memory saved). Reverted; kept only a +code comment recording the negative result. No other speed-neutral code fix +found: `cols` (nnz*16) is inherent to the cache that gave the ~10x speedup, and +f32 coeffs would halve it but break the drop_tol=0 exact-reference tests. + +## The real lever: knob characterization (N=10 ladder, T=2, exact-ED ref) + +(1) max_basis sweep @ dt=0.05, drop=1e-5: + 2k->rel 3.6e-1, 5k->2.4e-1, 10k->1.2e-1, 50k->9.9e-2, unbounded(259k)->1.1e-4. + Hard-capping bounds RSS (169->391MB) but costs accuracy STEEPLY here: the XY + ladder operator genuinely spreads to ~250k terms. + +(2) drop_tol sweep @ dt=0.05, unbounded: 3e-3->rel 6.8e-2 (2.6k terms, 282MB, 1.0s); + 1e-3->2.4e-2 (21k, 318MB); 3e-4->2.9e-3 (190k, 389MB); 3e-5->3.2e-4 (256k, 392MB). + drop_tol keeps the magnitude-selected set and is the efficient knob; + max_basis is a safety bound, not the primary control. + +(3) expm dt sweep @ drop=1e-4 (KEY): dt=0.025->rel 3.0e-3/27.6s, 0.05->2.2e-3/15.4s, + **0.1->7.4e-4/8.2s**, 0.2->2.3e-2/4.1s, 0.4->4.4e-1/0.9s. Optimal dt ~0.1 — + MORE accurate AND ~2x faster than dt=0.05, because expm has no Trotter + splitting error and fewer steps => fewer truncation events => less + accumulated truncation loss. Earlier benchmarks under-tuned expm at dt=0.05. + +(4) Trotter dt sweep @ mac=1e-5: 0.05->rel 9e-4/16.1s, 0.1->1.9e-3/8.2s, + 0.2->7.4e-3/3.9s; RSS flat ~173MB. + +## Tuned conclusion + +At N=10 with BOTH methods dt-tuned, wall is ~tied (~8s at rel~1e-3, dt=0.1); +expm uses ~2.3x Trotter RSS. Memory (not speed) is expm's cost, and it is only +reducible by accepting higher rel error (coarser drop / larger dt) — there is no +free code-level fix. At higher rel error (~1e-2) both are far cheaper and expm's +small-basis regime (drop=3e-3: 2.6k terms, 1s) is very competitive on wall. + +## Tuned N=12 — CORRECTS the earlier "expm wins 0.6x" claim + +The [[../2026-07-01-expm-pc-step]] scan reported expm 0.6x Trotter wall at N=12, +rel=1e-2. That was an ARTIFACT: it interpolated against a noisy Trotter point +(mac=3e-5 -> 491s while the TIGHTER mac=1e-5 -> 302s; non-monotonic = system-load +outlier). Re-measured cleanly, dt-tuned: + + expm dt0.1 drop1e-3 rel 3.8e-2 24.8s 827MB (41k terms) + expm dt0.1 drop3e-4 rel 2.1e-2 189.6s 1823MB (518k) + trotter dt0.1 mac1e-4 rel 1.6e-2 40.3s 263MB (781k) + trotter dt0.2 mac1e-4 rel 1.4e-2 34.2s 365MB (1.32M) + +At matched rel~1.5e-2, Trotter is ~34-40s / 263-365MB; expm (interp drop~4e-4) +is ~120s / ~1.4GB. => at N=12 Trotter wins BOTH wall (~3x) and memory (~5x). + +Note expm's 827MB for only 41k final terms (drop=1e-3): the RSS is dominated by +the predictor-corrector LEAKAGE TRANSIENT (basis is enriched 2x before pruning, +and the action cache is built on the enriched basis), not the final basis. This +transient is inherent to the PC method and is the true memory driver — ~20x more +RSS/term than Trotter at N=12. + +## Honest overall verdict (post-optimization, properly tuned) + +The ~10.7x per-step win (pc-step experiment) moved expm from ~6-8x SLOWER than +Trotter to roughly COMPETITIVE, but it does NOT clearly beat Trotter: +- N=8: Trotter wins (expm 3.3x wall, 1.4x RSS). +- N=10: wall ~tied, expm ~2.3x RSS. +- N=12: Trotter wins (~3x wall, ~5x RSS). +expm's structural weakness is memory (the PC leakage transient), growing with N. +Its genuine advantages: no Trotter splitting error (systematic, dt-controlled; +prefers dt~0.1) and a compact final basis at low accuracy. Measurement variance +on single runs at N>=12 is large — trust min-of-N microbenchmarks, not one-shot +walls. + +## Wide (dt x drop_tol x max_basis) scan -> optimal expm params (N=10, min of 2) + +Reran on a QUIET machine (the first pass was load-inflated). rel_err is +deterministic (bit-identical across both runs); only walls changed — these are +the clean walls. + +dt x drop_tol grid (max_basis=inf) reveals a dt-ERROR FLOOR (the PC does only +2 leakage hops/step, so too-large dt can't spread the operator far enough): +- dt=0.05: as accurate as 0.1 but ~1.8x slower (more steps -> more truncation + events). drop=3e-5 -> rel 3.2e-4 @ 10.4s. +- dt=0.10: OPTIMAL. best accuracy floor AND fastest to reach it: + drop 3e-3->rel 3.3e-2/0.4s, 1e-3->1.25e-2/2.5s, 3e-4->4.0e-3/4.3s, + 1e-4->7.4e-4/4.7s, 3e-5->1.0e-3/6.9s. +- dt=0.15 -> accuracy floors at ~1.9e-2 (finer drop no longer helps); + dt=0.20 -> ~2.2e-2; dt>=0.25 -> ~0.15. Above dt~0.1 you cannot reach small + rel_err at ANY drop_tol. Only exceed dt~0.1 if low accuracy suffices (then + dt=0.2-0.3 gives rel~3e-2 in ~0.3s). + +Optimal (dt,drop) per accuracy target, max_basis=inf, MIN WALL (clean): + rel~3e-2: dt=0.2 drop=3e-3 -> 0.3s (~8k terms) [very cheap regime] + rel~1e-2: dt=0.1 drop=3e-4 -> 4.3s (206k, rel 4.0e-3) + rel~3e-3: dt=0.1 drop=1e-4 -> 4.7s (244k, rel 7.4e-4) + rel<=1e-3: dt=0.1 drop=1e-4 -> 4.7s (rel 7.4e-4) + +max_basis interaction (base dt=0.1): capping BELOW the drop-tol-natural basis +size trades accuracy for RAM/wall. At the rel~1e-2 target, max_basis=200k +(vs inf) still meets it at 4.3s/310MB vs 5.3s/387MB — a mild win by trimming the +unneeded tail. For high accuracy the operator genuinely fills ~250k terms, so +max_basis must be >= that. => drop_tol is the primary knob; max_basis is a +memory safety cap that gives marginal savings at moderate accuracy. + +RECOMMENDED expm defaults (this ladder/observable): drop_tol chosen for the +accuracy target, and dt chosen so the O(dt^3) per-step floor is below it: +- rel ~1e-2 : dt=0.1, drop=1e-3 (~2.5s) +- rel ~1e-3 : dt=0.1, drop=1e-4 (~4.7s) +- rel ~1e-4 : dt=0.05, drop=3e-6 (~11s) +- rel ~1e-5 : dt=0.025, drop=1e-7 (~30s) +max_basis unbounded (or ~1.2x the saturated basis as a memory guard). Note the +optimal dt DECREASES with the accuracy target: the PC per-step error is ~O(dt^3) +(dt=0.1 floors ~1.1e-3, dt=0.05 ~1e-4, dt=0.025 <1e-5), so tighter targets need +smaller dt AND finer drop (the basis saturates ~261k at N=10, so fine drop is +nearly free once saturated — it only reduces per-step pruning loss). + +## Tight-accuracy expm-vs-Trotter (N=10, exact-ED ref, clean, min-wall to reach) + +Trotter error = O(dt^2) Strang splitting + truncation(min_abs_coeff); expm error += O(dt^3) PC + truncation(drop_tol). Both basis-saturate ~261k terms, so it is +STEP COUNT that differs. + + target expm (dt,drop) wall RSS | Trotter (dt,mac) wall RSS + 1e-4 dt0.05 drop3e-6 9.9e-5 11.4s 393MB | dt0.0125 mac1e-7 3.9e-5 37.2s 173MB + 1e-5 dt0.025 drop1e-7 9.7e-6 30.3s 422MB | mac1e-7 FLOORS 3.9e-5 (>1e-8 needed) + +=> At TIGHT accuracy the ranking INVERTS vs the moderate regime: + - rel 1e-4: expm ~3.3x FASTER (Trotter's O(dt^2) forces dt=0.0125 = 4x more + steps than expm's dt=0.05); Trotter still ~2.3x less RAM. + - rel 1e-5: expm reaches it in 30s; Trotter's mac=1e-7 truncation floors at + ~4e-5, needs mac<=1e-8 (basis -> near-full, cost >> expm). +Combined with the moderate-accuracy result (Trotter competitive/better on wall, +~2x less RAM for rel >= ~1e-3), the honest verdict: + * moderate accuracy (rel >~ 1e-3): Trotter wins (wall ~tie, memory ~2x). + * tight accuracy (rel <~ 1e-4): expm wins WALL (higher-order in dt), and is + the practical choice for rel ~1e-5+; Trotter keeps the ~2x memory edge. +This is the regime where the matrix-exponential method earns its keep. diff --git a/docs/autotune/2026-07-01-expm-memory/scan3d.py b/docs/autotune/2026-07-01-expm-memory/scan3d.py new file mode 100644 index 00000000..7975ad5d --- /dev/null +++ b/docs/autotune/2026-07-01-expm-memory/scan3d.py @@ -0,0 +1,65 @@ +"""Wide (dt, drop_tol, max_basis) scan for expm -> optimal params per accuracy. + +Uses /tmp/charac.py as the subprocess worker (fresh RSS per run). Exact-ED ref. +""" +import sys, json, subprocess +from functools import reduce +import numpy as np, scipy.sparse as sp + +SX=np.array([[0,1],[1,0]],complex); SY=np.array([[0,-1j],[1j,0]],complex) +def ladder(L): + N=2*L; site=lambda j,a:j+a*L; b=[] + for a in(0,1): + for j in range(L): b.append((site(j,a),site((j+1)%L,a))) + for j in range(L): b.append((site(j,0),site(j,1))) + return N,site,b +def sop(s,q,N): return reduce(lambda x,y:sp.kron(x,y,format='csr'),[sp.csr_matrix(s) if k==q else sp.identity(2,format='csr',dtype=complex) for k in range(N)]) +def exact(L,T): + N,site,bd=ladder(L); dim=2**N; H=sp.csr_matrix((dim,dim),dtype=complex) + for(p,q)in bd: H=H+sop(SX,p,N)@sop(SX,q,N)+sop(SY,p,N)@sop(SY,q,N) + E,V=np.linalg.eigh(H.toarray()); U=(V*np.exp(1j*E*T))@V.conj().T + idx=np.arange(dim); j0=L//2; a0,b0=site(j0,0),site(j0,1) + o0=0.5*((1-2*((idx>>(N-1-a0))&1))+(1-2*((idx>>(N-1-b0))&1))); dO=(np.abs(U)**2)@o0 + return np.array([((1-2*((idx>>(N-1-q))&1))*dO).sum()/dim for q in range(N)]) + +W="/tmp/charac.py"; L=5; T=2.0; REPS=2 +ex=exact(L,T); en=np.linalg.norm(ex); N=2*L +def run(dt,mb,dr): + best=None + for _ in range(REPS): + r=subprocess.run([sys.executable,W,"expm",str(L),str(T),str(dt),str(mb),str(dr)],capture_output=True,text=True) + try: d=json.loads(r.stdout.strip().splitlines()[-1]) + except Exception: continue + d["rel"]=float(np.linalg.norm(np.array(d["profile"])-ex)/en) + if best is None or d["wall"]5} {'drop':>7} {'rel_err':>9} {'wall_s':>7} {'RSS_mb':>7} {'peak':>8}",flush=True) +rows=[] +for dt in DTS: + for dr in DROPS: + d=run(dt,-1,dr); rows.append((dt,dr,d["rel"],d["wall"],d["rss"],d["peak"])) + print(f"{dt:>5} {dr:>7.0e} {d['rel']:>9.2e} {d['wall']:>7.1f} {d['rss']:>7.0f} {d['peak']:>8}",flush=True) + +print(f"\n== optimal (dt,drop) per accuracy target (max_basis=inf) ==",flush=True) +best_cfg={} +for tgt in (3e-2,1e-2,3e-3,1e-3): + ok=[r for r in rows if r[2]<=tgt] + if not ok: print(f" rel<= {tgt:.0e}: none reached",flush=True); continue + bw=min(ok,key=lambda r:r[3]); br=min(ok,key=lambda r:r[4]) + best_cfg[tgt]=bw + print(f" rel<= {tgt:.0e}: MIN-WALL dt={bw[0]} drop={bw[1]:.0e} -> {bw[3]:.1f}s {bw[4]:.0f}MB (rel {bw[2]:.1e}) | " + f"MIN-RSS dt={br[0]} drop={br[1]:.0e} -> {br[4]:.0f}MB {br[3]:.1f}s (rel {br[2]:.1e})",flush=True) + +# max_basis interaction on the min-wall config for a moderate & a tight target +print(f"\n== max_basis interaction (does capping help RAM without losing accuracy?) ==",flush=True) +for tgt in (1e-2,3e-3): + if tgt not in best_cfg: continue + dt,dr=best_cfg[tgt][0],best_cfg[tgt][1] + print(f" base config dt={dt} drop={dr:.0e} (target rel {tgt:.0e}):",flush=True) + print(f" {'max_basis':>10} {'rel_err':>9} {'wall_s':>7} {'RSS_mb':>7} {'peak':>8}",flush=True) + for mb in (20000,50000,100000,200000,-1): + d=run(dt,mb,dr); print(f" {mb:>10} {d['rel']:>9.2e} {d['wall']:>7.1f} {d['rss']:>7.0f} {d['peak']:>8}",flush=True) diff --git a/docs/autotune/2026-07-01-expm-memory/tight.py b/docs/autotune/2026-07-01-expm-memory/tight.py new file mode 100644 index 00000000..bbc548cf --- /dev/null +++ b/docs/autotune/2026-07-01-expm-memory/tight.py @@ -0,0 +1,49 @@ +"""Tight-accuracy expm scan: reach rel 1e-4 / 1e-5. Needs smaller dt (lower +dt-error floor) + finer drop_tol. Uses /tmp/charac.py worker, exact-ED ref.""" +import sys, json, subprocess +from functools import reduce +import numpy as np, scipy.sparse as sp +SX=np.array([[0,1],[1,0]],complex); SY=np.array([[0,-1j],[1j,0]],complex) +def ladder(L): + N=2*L; site=lambda j,a:j+a*L; b=[] + for a in(0,1): + for j in range(L): b.append((site(j,a),site((j+1)%L,a))) + for j in range(L): b.append((site(j,0),site(j,1))) + return N,site,b +def sop(s,q,N): return reduce(lambda x,y:sp.kron(x,y,format='csr'),[sp.csr_matrix(s) if k==q else sp.identity(2,format='csr',dtype=complex) for k in range(N)]) +def exact(L,T): + N,site,bd=ladder(L); dim=2**N; H=sp.csr_matrix((dim,dim),dtype=complex) + for(p,q)in bd: H=H+sop(SX,p,N)@sop(SX,q,N)+sop(SY,p,N)@sop(SY,q,N) + Hd=H.toarray(); E,V=np.linalg.eigh(Hd); U=(V*np.exp(1j*E*T))@V.conj().T + # reference sanity: ||U unitary|| and ||[H,rho] consistency|| via U U^dagger = I + uni=np.abs(U@U.conj().T-np.eye(dim)).max() + idx=np.arange(dim); j0=L//2; a0,b0=site(j0,0),site(j0,1) + o0=0.5*((1-2*((idx>>(N-1-a0))&1))+(1-2*((idx>>(N-1-b0))&1))); dO=(np.abs(U)**2)@o0 + prof=np.array([((1-2*((idx>>(N-1-q))&1))*dO).sum()/dim for q in range(N)]) + return prof, uni +W="/tmp/charac.py"; L=5; T=2.0; REPS=2 +ex,uni=exact(L,T); en=np.linalg.norm(ex); N=2*L +print(f"exact-ref unitarity residual max|UU^d - I| = {uni:.2e} (must be << target rel)",flush=True) +def run(dt,dr): + best=None + for _ in range(REPS): + r=subprocess.run([sys.executable,W,"expm",str(L),str(T),str(dt),"-1",str(dr)],capture_output=True,text=True) + try: d=json.loads(r.stdout.strip().splitlines()[-1]) + except Exception: continue + d["rel"]=float(np.linalg.norm(np.array(d["profile"])-ex)/en) + if best is None or d["wall"]7} {'drop':>7} {'rel_err':>9} {'wall_s':>7} {'RSS_mb':>7} {'peak':>8}",flush=True) +rows=[] +for dt in DTS: + for dr in DROPS: + d=run(dt,dr); rows.append((dt,dr,d["rel"],d["wall"],d["rss"],d["peak"])) + print(f"{dt:>7} {dr:>7.0e} {d['rel']:>9.2e} {d['wall']:>7.1f} {d['rss']:>7.0f} {d['peak']:>8}",flush=True) +print(f"\n== min-wall config per tight target (max_basis=inf) ==",flush=True) +for tgt in (1e-4,3e-5,1e-5): + ok=[r for r in rows if r[2]<=tgt] + if not ok: print(f" rel<= {tgt:.0e}: NOT REACHED (finest: rel {min(r[2] for r in rows):.1e})",flush=True); continue + bw=min(ok,key=lambda r:r[3]) + print(f" rel<= {tgt:.0e}: dt={bw[0]} drop={bw[1]:.0e} -> {bw[3]:.1f}s {bw[4]:.0f}MB {bw[5]} terms (rel {bw[2]:.1e})",flush=True) diff --git a/docs/autotune/2026-07-01-expm-memory/tight2.py b/docs/autotune/2026-07-01-expm-memory/tight2.py new file mode 100644 index 00000000..037cf4a3 --- /dev/null +++ b/docs/autotune/2026-07-01-expm-memory/tight2.py @@ -0,0 +1,43 @@ +"""Push expm to rel 1e-5: small dt (dt^3 floor < 1e-5 => dt<~0.02) AND fine +drop_tol to beat truncation accumulation over many steps.""" +import sys, json, subprocess +from functools import reduce +import numpy as np, scipy.sparse as sp +SX=np.array([[0,1],[1,0]],complex); SY=np.array([[0,-1j],[1j,0]],complex) +def ladder(L): + N=2*L; site=lambda j,a:j+a*L; b=[] + for a in(0,1): + for j in range(L): b.append((site(j,a),site((j+1)%L,a))) + for j in range(L): b.append((site(j,0),site(j,1))) + return N,site,b +def sop(s,q,N): return reduce(lambda x,y:sp.kron(x,y,format='csr'),[sp.csr_matrix(s) if k==q else sp.identity(2,format='csr',dtype=complex) for k in range(N)]) +def exact(L,T): + N,site,bd=ladder(L); dim=2**N; H=sp.csr_matrix((dim,dim),dtype=complex) + for(p,q)in bd: H=H+sop(SX,p,N)@sop(SX,q,N)+sop(SY,p,N)@sop(SY,q,N) + E,V=np.linalg.eigh(H.toarray()); U=(V*np.exp(1j*E*T))@V.conj().T + idx=np.arange(dim); j0=L//2; a0,b0=site(j0,0),site(j0,1) + o0=0.5*((1-2*((idx>>(N-1-a0))&1))+(1-2*((idx>>(N-1-b0))&1))); dO=(np.abs(U)**2)@o0 + return np.array([((1-2*((idx>>(N-1-q))&1))*dO).sum()/dim for q in range(N)]) +W="/tmp/charac.py"; L=5; T=2.0; REPS=2 +ex=exact(L,T); en=np.linalg.norm(ex); N=2*L +def run(dt,dr): + best=None + for _ in range(REPS): + r=subprocess.run([sys.executable,W,"expm",str(L),str(T),str(dt),"-1",str(dr)],capture_output=True,text=True) + try: d=json.loads(r.stdout.strip().splitlines()[-1]) + except Exception: continue + d["rel"]=float(np.linalg.norm(np.array(d["profile"])-ex)/en) + if best is None or d["wall"] rel 1e-5 (dt x fine drop), max_basis=inf, min of {REPS} ###",flush=True) +print(f"{'dt':>7} {'drop':>7} {'rel_err':>9} {'wall_s':>7} {'RSS_mb':>7} {'peak':>8}",flush=True) +rows=[] +for dt in DTS: + for dr in DROPS: + d=run(dt,dr); rows.append((dt,dr,d["rel"],d["wall"],d["rss"],d["peak"])) + print(f"{dt:>7} {dr:>7.0e} {d['rel']:>9.2e} {d['wall']:>7.1f} {d['rss']:>7.0f} {d['peak']:>8}",flush=True) +for tgt in (1e-5,3e-6): + ok=[r for r in rows if r[2]<=tgt] + if not ok: print(f" rel<= {tgt:.0e}: NOT REACHED (finest {min(r[2] for r in rows):.1e})",flush=True); continue + bw=min(ok,key=lambda r:r[3]); print(f" rel<= {tgt:.0e}: dt={bw[0]} drop={bw[1]:.0e} -> {bw[3]:.1f}s {bw[4]:.0f}MB {bw[5]} terms (rel {bw[2]:.1e})",flush=True) diff --git a/docs/autotune/2026-07-01-expm-memory/trot_tight.py b/docs/autotune/2026-07-01-expm-memory/trot_tight.py new file mode 100644 index 00000000..252a41de --- /dev/null +++ b/docs/autotune/2026-07-01-expm-memory/trot_tight.py @@ -0,0 +1,43 @@ +"""Trotter tight-accuracy scan (rel 1e-4, 1e-5) at N=10, to compare vs expm. +Trotter (2nd-order Strang) error is O(dt^2) + truncation(min_abs_coeff).""" +import sys, json, subprocess +from functools import reduce +import numpy as np, scipy.sparse as sp +SX=np.array([[0,1],[1,0]],complex); SY=np.array([[0,-1j],[1j,0]],complex) +def ladder(L): + N=2*L; site=lambda j,a:j+a*L; b=[] + for a in(0,1): + for j in range(L): b.append((site(j,a),site((j+1)%L,a))) + for j in range(L): b.append((site(j,0),site(j,1))) + return N,site,b +def sop(s,q,N): return reduce(lambda x,y:sp.kron(x,y,format='csr'),[sp.csr_matrix(s) if k==q else sp.identity(2,format='csr',dtype=complex) for k in range(N)]) +def exact(L,T): + N,site,bd=ladder(L); dim=2**N; H=sp.csr_matrix((dim,dim),dtype=complex) + for(p,q)in bd: H=H+sop(SX,p,N)@sop(SX,q,N)+sop(SY,p,N)@sop(SY,q,N) + E,V=np.linalg.eigh(H.toarray()); U=(V*np.exp(1j*E*T))@V.conj().T + idx=np.arange(dim); j0=L//2; a0,b0=site(j0,0),site(j0,1) + o0=0.5*((1-2*((idx>>(N-1-a0))&1))+(1-2*((idx>>(N-1-b0))&1))); dO=(np.abs(U)**2)@o0 + return np.array([((1-2*((idx>>(N-1-q))&1))*dO).sum()/dim for q in range(N)]) +W="/tmp/charac.py"; L=5; T=2.0; REPS=2 +ex=exact(L,T); en=np.linalg.norm(ex); N=2*L +def run(dt,mac): + best=None + for _ in range(REPS): + r=subprocess.run([sys.executable,W,"trotter",str(L),str(T),str(dt),str(mac)],capture_output=True,text=True) + try: d=json.loads(r.stdout.strip().splitlines()[-1]) + except Exception: continue + d["rel"]=float(np.linalg.norm(np.array(d["profile"])-ex)/en) + if best is None or d["wall"]8} {'mac':>7} {'rel_err':>9} {'wall_s':>7} {'RSS_mb':>7} {'peak':>8}",flush=True) +rows=[] +for dt in DTS: + for mc in MACS: + d=run(dt,mc); rows.append((dt,mc,d["rel"],d["wall"],d["rss"],d["peak"])) + print(f"{dt:>8} {mc:>7.0e} {d['rel']:>9.2e} {d['wall']:>7.1f} {d['rss']:>7.0f} {d['peak']:>8}",flush=True) +for tgt in (1e-4,1e-5): + ok=[r for r in rows if r[2]<=tgt] + if not ok: print(f" rel<= {tgt:.0e}: NOT REACHED (finest {min(r[2] for r in rows):.1e})",flush=True); continue + bw=min(ok,key=lambda r:r[3]); print(f" TROTTER rel<= {tgt:.0e}: dt={bw[0]} mac={bw[1]:.0e} -> {bw[3]:.1f}s {bw[4]:.0f}MB {bw[5]} terms (rel {bw[2]:.1e})",flush=True) diff --git a/docs/autotune/2026-07-01-expm-pc-step/cache-action/prompts.md b/docs/autotune/2026-07-01-expm-pc-step/cache-action/prompts.md new file mode 100644 index 00000000..e2e43012 --- /dev/null +++ b/docs/autotune/2026-07-01-expm-pc-step/cache-action/prompts.md @@ -0,0 +1,11 @@ +# Approach: cache-action for the real-space expm + +Hypothesis: the real-space `mf_expm::expm_apply_mf` recomputes the generator +action (`spec.action` per column) on every Krylov/Taylor matvec (profile: +expm = 97% of pc_step). Build the in-basis generator action ONCE per expm call +as CSC columns (rows + real coeffs), then do cheap cached matvecs — exactly +what the orbit-rep path already does (`build_orbit_rep_cols` + `OrbitRepCscOp` +in orbit_rep.rs), but for the real f64 path (no phases/characters). + +Target metric: 15 pc_step_arr steps, N=10 XY ladder, drop=1e-3. Baseline 3.28s. +Track wall (primary) AND rss_mb (memory tradeoff — caching costs memory). diff --git a/docs/autotune/2026-07-01-expm-pc-step/log.md b/docs/autotune/2026-07-01-expm-pc-step/log.md new file mode 100644 index 00000000..8f6eaac4 --- /dev/null +++ b/docs/autotune/2026-07-01-expm-pc-step/log.md @@ -0,0 +1,96 @@ +# Log for 2026-07-01-expm-pc-step + +## 2026-07-01 + +## Architecture notes (profile before optimizing) + +Metric: total wall for 15 `pc_step_arr` steps on the real-space XY two-leg +ladder, N=10 (L=5), gamma=0, dt=0.05, drop_tol=1e-3, localized-Z seed. +Baseline = 3.28 s, RSS 250 MB, peak basis 15414. Conservation Sum a_q = 1. + +Phase breakdown (pc_step_timed, steady-state, N=10, drop=1e-3): +- expm (matrix-exponential apply): **96.8%** (expm2 70.6% + expm1 26.1%) +- leakage: 3.1% +- generator: 0.0% (matrix-free path builds no CSR) +- expand: 0.1% + +=> The entire per-step cost is `mf_expm::expm_apply_mf`. Its `MfOp::dot` / +`spmv_matrix_free` RECOMPUTES the generator action (spec.action per column) +on every Krylov/Taylor matvec. The orbit-rep path already caches the action +once per expm call (`build_orbit_rep_cols` + `OrbitRepCscOp`); the real-space +path does not. Hypothesis 1: cache the action (CSC) once per expm call and +reuse across matvecs -> large expm speedup. Known tradeoff: matrix-full was +~2.8x faster / ~1.43x more memory in an earlier ad-hoc test. + +## !! MEASUREMENT BUG (invalidates iterations 1-2 first measurements) + +`maturin develop` was run from `crates/ppvm-python-native` (which builds the +crate as its OWN package). The module actually loaded by `import ppvm._core` +is `ppvm-python/src/ppvm/_core.abi3.so`, built via the maturin config in +`ppvm-python/pyproject.toml` — you MUST run maturin from `ppvm-python/`. So +every "measurement" up to and including the first cache-action and loose-table +runs used a STALE .so and is meaningless (baseline 3.28s, cache-action 3.34s, +loose 3.2s were all the same old binary). Correct build command: +`cd ppvm-python && VIRTUAL_ENV=.venv uvx --from maturin maturin develop --release --uv` +Verify freshness: `strings src/ppvm/_core.abi3.so | grep `. + +## Correct profile (freshly-built binary, N=10, drop=1e-3) + +Per expm call, the APPLY (Taylor matvecs) dominates: on a 177k-term corrector +apply=355ms (loose, m=15) / 1048ms (double, m=26), vs per-col norm pass 37ms, +setup 10ms. => the SpMV count AND per-SpMV cost both matter; the old +fully-matrix-free op recomputed the Pauli-commutator action on all columns on +EVERY matvec. + +## Iteration 1 (as first recorded): cache-action (CSC) — WRONGLY DISCARDED + +Ported orbit-rep's `build_orbit_rep_cols` + `OrbitRepCscOp` to the real f64 +path in `expm_apply_mf` (build the in-basis action once per call, reuse across +the m*·s matvecs). commit f93a230e (reverted). + +Result: wall 3.343 s (baseline 3.275), RSS 264 MB (baseline 250). **No wall +win, slightly more RAM.** Checksum 1.0 (correct). + +Diagnosis: caching the action does not help because the action *recompute* is +NOT the bottleneck. Each matvec, matrix-free does compute_action_terms (produce +(row,coeff)) + scatter; cached does read-cols (row,coeff) + scatter — the two +produce the same term stream, so the scatter (memory-bound accumulation over +~basis·O(N) nnz) dominates equally. At dt=0.05 the true cost is the *count* of +scaling-squaring matvecs (m*·s per expm, x2 expm/step), not per-matvec action. +This is algorithmic (Al-Mohy-Higham), not a caching target. + +## Root cause + next lever + +The expm computes exp to double-precision backward error (`THETA` table is for +u=2^-53 ~ 1e-16) while the basis is truncated at drop_tol=1e-3. That is ~13 +orders of over-accuracy. matvec count s = ceil(dt*||A||/theta_m); a looser +theta table (larger theta_m) directly cuts s -> fewer matvecs. Lever 2: use a +single-precision / 1e-6-matched theta table in the mf_expm path. Est ~1.5-2x +expm speedup with no net accuracy loss (still >> truncation). Bigger lever: +Krylov/Lanczos expm (fewer matvecs than Taylor scaling-squaring) — larger +rewrite, deferred. + +## Iterations 1+2 combined — KEPT (commit 8eab9190) — ~10.7x + +Once the stale-binary bug was found and fixed, BOTH levers are real and +compose: + +- cache-action (CSC): build the per-column action once per call + (`build_mf_cols`) + `CachedCscOp`; the same pass also yields mu/1-norm + (dropped the separate norm pass). Per-matvec apply 355ms -> 25ms (~14x) — + the earlier "discard" was purely the stale binary. +- loose table: `select_ms_loose` (tol=1e-6, drop_tol>=1e-4) cuts m ~24 -> 15. + +Clean measurements (correct build), N=10 XY ladder, drop=1e-3, min of runs: +- double + no cache (true baseline): 7.15 s, 250 MB +- loose + no cache: 5.49 s, 284 MB +- cache + double: 0.69 s, ~300 MB +- cache + loose (SHIPPED): 0.67 s, ~310 MB + +=> ~10.7x wall vs baseline for ~+24% RAM (the CSC cache). Conservation +Sum a_q = 1 and checksum unchanged; all 9 ppvm-lindblad tests pass (drop=0 +keeps the double table + cache, still bit-exact vs orbit-rep/merged refs). + +Remaining bottleneck is now `build_mf_cols` (the one action pass, ~40ms on the +177k corrector) + the CSC scatter. Next levers if more is wanted: reuse the +cache across predictor+corrector (same basis prefix), or the Krylov rewrite. diff --git a/docs/autotune/2026-07-01-expm-pc-step/metric.toml b/docs/autotune/2026-07-01-expm-pc-step/metric.toml new file mode 100644 index 00000000..18c56955 --- /dev/null +++ b/docs/autotune/2026-07-01-expm-pc-step/metric.toml @@ -0,0 +1,24 @@ +[[metric]] +"commit" = "d0928c21" +"status" = "keep" +"description" = "baseline: real-space XY ladder N=10, 15 pc_step, drop=1e-3" +"wall_s" = 3.275 +"rss_mb" = 250.0 +"peak_basis" = 15414.0 + +[[metric]] +"commit" = "f93a230e" +"status" = "discard" +"description" = "cache-action CSC (port of orbit-rep): no wall win at dt=0.05 (few matvecs; scatter dominates, not action-recompute)" +"wall_s" = 3.343 +"rss_mb" = 264.0 +"peak_basis" = 15414.0 + +[[metric]] +"commit" = "8eab9190" +"status" = "keep" +"description" = "cache generator action (CSC) once/call + tol-matched (1e-6) expm table; correct build" +"wall_s" = 0.665 +"rss_mb" = 310.0 +"peak_basis" = 15398.0 + diff --git a/docs/autotune/2026-07-02-expm-ladder/log.md b/docs/autotune/2026-07-02-expm-ladder/log.md new file mode 100644 index 00000000..78fbaefb --- /dev/null +++ b/docs/autotune/2026-07-02-expm-ladder/log.md @@ -0,0 +1,164 @@ +# Log for 2026-07-02-expm-ladder + +Target: expm (adaptive Pauli-Lindblad pc_step) on the two-leg XY ladder, AFTER +the 2026-07-01-expm-pc-step optimization (cache-action + tol-matched table, +~10.7x). Metric (/tmp/metric_expm.py): N=10, 15 steps, drop=1e-3. Baseline +(min of 3): wall 0.457s, peak_basis 15398, RSS 306MB, checksum 1.0. + +## Profile (pc_step_timed phase split) +- expm apply: **77%** (expm2 60% + expm1 17%) +- leakage: **22%** (was ~3% pre-optimization; now significant since expm got 10x) +- generator: 0% (matrix-free), expand 0.7% + +## Hypotheses / next iterations +1. Share the `compute_action_terms(p)` computation between the expm CSC build + (mf_expm::build_mf_cols) and the immediately-following leakage on the SAME + basis (pc_step_inner: expm1-build on B1, then leakage2 on B1 -- consecutive, + same basis). The action(p) depends only on p, not coefficients, so it is + recomputed. CAVEAT: build_mf_cols currently DISCARDS out-of-basis outputs + (filters index.get(w).is_some()) -- exactly the terms leakage needs -- so + sharing requires retaining full action outputs (memory cost) and threading + the cache through pc_step_inner. Structural, medium risk; deferred pending + the Trotter iteration. +2. Parallelism of leakage vs expm build (check if leakage is already parallel). + +## Iteration 1: reuse predictor action-cache in leakage2 — DISCARD (memory) +Implemented (cherry-pick 191e92aa->2411240f): build_mf_cols also retains +out-of-basis action outputs (oob: Vec>), expm_step returns it, +and a new leakage_from_action_cache consumes it for leak2 instead of recomputing +compute_action_terms on B1. Correct: checksum/sum_a = 1.0, 9/9 crate tests pass +(incl. tight pc_step agreement tests). +Measured N=12 (under Zoom load, so walls unreliable): RSS 1208-1210MB vs baseline +522-527MB -- ~2.3x. RSS is stable run-to-run (load-independent), so this verdict +is robust despite the noisy walls. Wall looked neutral-to-slower but that is load. +Root cause of the memory blow-up: build_mf_cols now ALWAYS builds the oob Word +cache, including for the CORRECTOR expm2 on the doubly-enriched basis B2 (largest +basis of the step), whose cache is then discarded -- pure waste. Even fixing that +(flag to skip oob on the corrector), the predictor's oob cache on B1 still adds +memory for a wall gain bounded by leakage=17% (realistically <=10%). +DECISION: DISCARD. Memory is expm's established weak point (see +[[../2026-07-01-expm-memory]]); ~2.3x RAM for a small, unmeasurable wall gain is +a net loss. If revisited: only build oob for the predictor, and store row-indices +into a preliminary B2 index instead of full Words to bound the memory. + +## CONCLUSION +No net change kept. Post the prior ~10.7x (2026-07-01-expm-pc-step), the profile +is 82% scaling-squaring matvecs (already cached + parallel + tolerance-matched) +and 17% leakage. The one structural idea (share the action pass into leakage2) +is correct but costs ~2.3x RAM (discarded). expm is near its practical optimum; +the earlier 10.7x per-step speedup stands as the real win. + +## tau_add / K admission study (both models, K-scan, high + low precision) + +Motivation: the doubly-enriched PC transient is ~11-16x the pruned basis, ~94% +pruned (load-independent). Q: does a rate-based admission filter (tau_add = +K*drop_tol/dt, PPVM_K_LEAKAGE) beat admit-all / a max_basis cap? Judged at +MATCHED accuracy vs exact ED. (One K on one model can't decide it.) + +Ladder N=10 (dt=0.05, T=2), min-of-2, K-scan: +- loose (rel~1e-2): K=1 ~2x faster than admit-all (2.3s vs 4.7s), better rel. +- high (rel~1e-3): K=1..3 ~1.2-1.4x faster (15s vs ~18-21s); never worse. +- K=5 OVER-filters (rel 6e-2 at drop=3e-4 vs admit-all 6.8e-2 only by using a + tiny basis) -> bad at matched accuracy. K=3 borderline; K=1 best. +- max_basis cap: ~3.6x at LOOSE accuracy (rel~1.3e-2, 0.9s) BUT cannot reach + high precision (the cap clips the basis below what rel<=1e-3 needs). It is a + memory-bound tool, not an accuracy knob. + +Long-range XY chain N=10 (alpha=1, gamma=0), min-of-2: +- same pattern: K=1 ~1.1-1.34x faster than admit-all at matched accuracy + (rel~1.2e-2: 26s vs 35s; rel~2.8e-4: 46s vs 52s). K=3/K=5 over-filter. + +Conclusions: +1. Removing the admission filter (running admit-all) was a modest-to-moderate + LOSS: a small K (~1) recovers ~1.1-2x at matched accuracy on BOTH models, + MORE at loose accuracy, and never hurts. K auto-scales (tau_add∝drop_tol), so + at tight drop it filters little and degrades gracefully to admit-all. +2. K is regime-dependent. K=1 is the sweet spot for these UNITARY (gamma=0) + cases; K>=3 over-filters. The notes' K≈5 was calibrated on a DISSIPATIVE + (gamma=1) long-range case -- dissipation damps high-weight operators and + changes the leakage structure, so its optimal K is higher. Judging tau_add + from K=5 on a unitary ladder (as first done) was unfair. +3. max_basis is a hard memory bound (useful, and good at loose accuracy) but NOT + a substitute for K at high precision (it clips accuracy). K works at all + precisions. +Recommended: expose K (done, PPVM_K_LEAKAGE, default 0=off). A default of K≈1 +would be a Pareto improvement for unitary runs; dissipative runs may want higher. +Default left at 0 pending a decision (K is regime-dependent). + +## RAM (peak RSS) is the real metric — admission control is NOT a RAM lever + +Re-judged the K/admit-all comparison on PEAK RSS (max throughout runtime) at +MATCHED accuracy (RSS is load-independent, so robust). + +N=10 high precision (rel~1e-3): peak RSS ~FLAT across K (~390MB ladder, ~510MB +lrxy) — N=10 saturates (~260k of 4^10 reachable), so no transient to filter. +N=12 (non-saturated), drop=1e-4: + K=0 : rel 7.0e-3, RSS 1772MB (peak_basis 1.60M) + K=1 : rel 5.2e-3, RSS 1715MB (peak_basis 1.65M) <- matched accuracy, ~same RAM (3%) + K=3 : rel 2.0e-2, RSS 997MB (peak_basis 0.87M) <- 1.8x less RAM but 3x WORSE rel + +Conclusion: +- At MATCHED accuracy, the admission filter does NOT reduce peak RAM (K=0 vs K=1: + 1772 vs 1715MB). K is a WALL optimization (~1.3-2x) only. Higher K (K=3) cuts + RAM ONLY by cutting accuracy — moving DOWN the RAM<->accuracy Pareto, not + beating it. +- Peak RAM is fundamentally set by the retained basis the accuracy target + requires (+ its action cache + the doubly-enriched corrector transient B2, + which at high precision is close to the retained basis and barely filterable). +- The direct knob for a RAM BUDGET is max_basis (hard bound), at a known + accuracy cost (capping below what the accuracy needs degrades rel steeply). +- => For RAM specifically, removing tau_add and keeping max_basis was FINE: + max_basis IS the RAM knob; tau_add/K would not lower peak RAM at matched + accuracy. (This tempers the earlier "removing tau_add was a loss" — that was a + WALL statement; for RAM it is not a loss.) +- The only way to cut peak RAM at FIXED accuracy is a code change to the peak + itself: reduce per-term footprint or stream the corrector's action instead of + caching all of B2 (trades back toward the recompute cost the cache removed). + +## CORRECTION: tau_add and max_basis limit RAM by DIFFERENT mechanisms + +(User's point.) The earlier "admission control is not a RAM lever" was measured +with tau_add(K) only, both at max_basis=inf -- which missed a real mechanistic +difference: +- max_basis room-cap is applied PER CHUNK inside leakage_with_prune + (merged.retain(top-room)) AND caps B2 via add_leakage_capped -> it bounds the + LIVE transient (leakage map + doubly-enriched B2) during accumulation. +- tau_add (as implemented) filters only at the END -> the merged map still + accumulates ALL candidates first, so it shrinks the RESULT but NOT the peak + transient memory. + +N=12 ladder, drop=1e-4, MATCHED accuracy (rel~7e-3), peak RSS: + admit-all (mb=inf) : 1795 MB + tau_add K=1 (end-filter): 1715 MB (-4%) + max_basis = 1.8M cap : 1611 MB (-10%, rel 6.7e-3, cap just above retained + ~1.6M so accuracy preserved; peak_basis + 1.52M shows the cap clipping the transient) + (mb = 2.0M/2.5M ~ 1759/1838 MB: cap above the overshoot -> no effect) + +=> max_basis is the better RAM knob: it clips the transient overshoot (B2 > +retained) during accumulation. The edge is ~10% at N=12 high precision (small +overshoot there) and LARGER at looser accuracy (N=10 rel~1e-2: 233 vs 306MB, +~1.3x) where B2 overshoots the retained basis much more (up to ~16x). tau_add +COULD match this if it filtered PER CHUNK (bounding the live map) rather than at +the end -- a code refinement. For peak RAM, set max_basis ~1.1-1.3x the expected +retained basis: preserves accuracy, clips the transient, hard-bounds RAM. + +## (b) IMPLEMENTED: opt-in streaming (matrix-free) expm — a RAM<->wall dial + +PPVM_EXPM_STREAM=1 makes expm_apply_mf skip build_mf_cols (the CSC cache) and +recompute the action per matvec via MfOp/spmv_matrix_free. Removes the +dominant peak-RAM term (cache = nnz*16B built on the doubly-enriched corrector +basis B2). Default unset = cached CachedCscOp (fast); drop_tol=0 tests unchanged. + +N=12 ladder, drop=3e-4, MATCHED accuracy (rel 2.96e-2 IDENTICAL — matrix-free is +the same math, just uncached): + cached : 168s, 1614 MB (peak_basis pruned 254k; RSS reflects the ~4M B2 + transient the cache is built on) + stream : 568s, 896 MB (~1.8x less peak RAM, ~3.4x wall) +The 718 MB saved ~ the action cache. Streaming's residual 896 MB is the basis / +index / transient words / expm work vectors, which remain. +=> Legitimate memory-budget mode for RAM-limited runs. The wall hit (3.4x here) +is the recompute cost the cache had removed; it's a dial, not a default. +Note this is biggest at MODERATE accuracy (drop~3e-4) where B2 overshoots the +retained basis most; at very tight drop the transient is smaller so the cache +(hence the saving) shrinks. diff --git a/docs/autotune/2026-07-02-expm-ladder/metric.toml b/docs/autotune/2026-07-02-expm-ladder/metric.toml new file mode 100644 index 00000000..35f0a4d6 --- /dev/null +++ b/docs/autotune/2026-07-02-expm-ladder/metric.toml @@ -0,0 +1,15 @@ +[[metric]] +"commit" = "0e316372" +"status" = "keep" +"description" = "baseline: N=10 XY ladder, 15 steps, drop=1e-3; expm-apply 77%, leakage 22%" +"wall_s" = 0.457 +"peak_basis" = 15398.0 +"rss_mb" = 306.0 + +[[metric]] +"commit" = "2411240f" +"status" = "discard" +"description" = "reuse predictor action-cache in leakage2: CORRECT (checksum 1.0) but ~2.3x RSS (525->1209MB N=12, load-independent) for <=10% unmeasurable wall gain (leakage=17%). oob Word-cache built even for the discarded corrector. Memory is expm's key weakness -> net loss." +"rss_mb_N12" = 1209.0 +"wall_s_N12_loadnoisy" = 3.65 + diff --git a/docs/autotune/2026-07-02-expm-ladder/scan_admission.py b/docs/autotune/2026-07-02-expm-ladder/scan_admission.py new file mode 100644 index 00000000..7097a5bd --- /dev/null +++ b/docs/autotune/2026-07-02-expm-ladder/scan_admission.py @@ -0,0 +1,99 @@ +"""Fair admission-control scan: admit-all vs K-filter (PPVM_K_LEAKAGE) vs +max_basis cap, on TWO models (2-leg XY ladder AND long-range XY chain, alpha=1), +exact-ED referenced. Prints (rel_err, wall, RSS, peak) per method so the Pareto +fronts (wall-vs-rel, rss-vs-rel) can be compared. + +worker: scan_admission.py
(K from env PPVM_K_LEAKAGE) +driver: scan_admission.py (runs the sweep) +""" +import sys, os, json, time, resource +from functools import reduce +import numpy as np, scipy.sparse as sp +from ppvm import Lindbladian +from ppvm.lindblad import _basis_to_codes + +SX=np.array([[0,1],[1,0]],complex); SY=np.array([[0,-1j],[1j,0]],complex) + +def ladder_bonds(N): + L=N//2; site=lambda j,a:j+a*L; b=[] + for a in(0,1): + for j in range(L): b.append((site(j,a),site((j+1)%L,a),1.0)) + for j in range(L): b.append((site(j,0),site(j,1),1.0)) + return b, site(L//2,0), site(L//2,1) # bonds(with coupling), two seed sites + +def lrxy_bonds(N, alpha=1.0): + b=[] + for i in range(N): + for j in range(i+1,N): + r=min(abs(i-j), N-abs(i-j)) # periodic chain distance + b.append((i,j,1.0/r**alpha)) + c=N//2 + return b, c, c # seed = middle site (both = c) + +def model(name, N): + if name=="ladder": return ladder_bonds(N) + if name=="lrxy": return lrxy_bonds(N, 1.0) + raise ValueError(name) + +def sop(s,q,N): return reduce(lambda x,y:sp.kron(x,y,format='csr'),[sp.csr_matrix(s) if k==q else sp.identity(2,format='csr',dtype=complex) for k in range(N)]) +def exact(name,N,T): + bonds,s0,s1=model(name,N); dim=2**N; H=sp.csr_matrix((dim,dim),dtype=complex) + for(p,q,J)in bonds: H=H+J*(sop(SX,p,N)@sop(SX,q,N)+sop(SY,p,N)@sop(SY,q,N)) + E,V=np.linalg.eigh(H.toarray()); U=(V*np.exp(1j*E*T))@V.conj().T + idx=np.arange(dim) + o0=0.5*((1-2*((idx>>(N-1-s0))&1))+(1-2*((idx>>(N-1-s1))&1))) + dO=(np.abs(U)**2)@o0 + return np.array([((1-2*((idx>>(N-1-q))&1))*dO).sum()/dim for q in range(N)]) + +def run_expm(name,N,T,dt,max_basis,drop): + bonds,s0,s1=model(name,N); steps=round(T/dt); h=[] + for(p,q,J)in bonds: + for P in"XY": s=["I"]*N; s[p]=P; s[q]=P; h.append(("".join(s),J)) + Lop=Lindbladian(N,h,[]); zs=["I"*q+"Z"+"I"*(N-q-1) for q in range(N)] + zb=_basis_to_codes(zs,N); co=np.zeros(N); co[s0]+=0.5; co[s1]+=0.5 + ba=zb.copy(); pr=zb.copy(); peak=len(ba) + for _ in range(steps): + ba,co=Lop.pc_step_arr(ba,co,dt,max_basis=max_basis,drop_tol=drop,protected_arr=pr); peak=max(peak,len(ba)) + keys=[zb[q].tobytes() for q in range(N)]; idx={ba[i].tobytes():i for i in range(len(ba))} + return np.array([co[idx[k]] if k in idx else 0.0 for k in keys]),peak + +if len(sys.argv)>1: # worker + name=sys.argv[1]; N=int(sys.argv[2]); T=float(sys.argv[3]); dt=float(sys.argv[4]); mb=int(sys.argv[5]); dr=float(sys.argv[6]) + t0=time.time(); a,pk=run_expm(name,N,T,dt,mb,dr) + rss=resource.getrusage(resource.RUSAGE_SELF).ru_maxrss/(1024*1024) + print(json.dumps(dict(profile=a.tolist(),wall=time.time()-t0,peak=pk,rss=rss))); sys.exit(0) + +import subprocess +W=os.path.abspath(__file__); T=2.0; DT=0.05; N=10 +INF=10_000_000 +def call(name,mb,dr,K): + env={**os.environ, "PPVM_K_LEAKAGE":str(K)} + best=None + for _ in range(2): + r=subprocess.run([sys.executable,W,name,str(N),str(T),str(DT),str(mb),str(dr)],capture_output=True,text=True,env=env) + try: d=json.loads(r.stdout.strip().splitlines()[-1]) + except Exception: continue + if best is None or d["wall"]9} {'K':>4} {'drop':>7} {'rel_err':>9} {'wall_s':>7} {'RSS_mb':>7} {'peak':>8}",flush=True) + for label,mb,K,drops in PLAN: + for dr in drops: + d=call(name,mb,dr,K) + if d is None or d["profile"] is None: + print(f"{label:10} {mb:>9} {K:>4} {dr:>7.0e} FAILED",flush=True); continue + rel=float(np.linalg.norm(np.array(d["profile"])-ex)/en) + print(f"{label:10} {mb:>9} {K:>4} {dr:>7.0e} {rel:>9.2e} {d['wall']:>7.2f} {d['rss']:>7.0f} {d['peak']:>8}",flush=True) diff --git a/docs/autotune/2026-07-02-trotter-ladder/log.md b/docs/autotune/2026-07-02-trotter-ladder/log.md new file mode 100644 index 00000000..7176497e --- /dev/null +++ b/docs/autotune/2026-07-02-trotter-ladder/log.md @@ -0,0 +1,79 @@ +# Log for 2026-07-02-trotter-ladder + +Target: Trotter (PauliSum rxx/ryy/rzz gate propagation) on the two-leg XY ladder. +Metric (/tmp/metric_trotter.py): brickwork 2nd-order Strang, N=10 (L=5), dt=0.1, +8 steps, min_abs_coeff=1e-3, localized-Z seed. Baseline (min of 3, quiet build): +wall 0.777s, peak_basis 34030, RSS 173MB, M=1.0 (conserved). + +BUILD REMINDER: rebuild native from ppvm-python/ (not crates/ppvm-python-native) + cd ppvm-python && VIRTUAL_ENV=.venv uvx --from maturin maturin develop --release --uv + +## Profile (Python-level phase split: gates with truncate=False vs .truncate()) +- gate application (rxx/ryy_many via map_insert): **98%** +- truncation (drop below min_abs_coeff): 2% + +=> The bottleneck is the 2-qubit rotation application, not truncation. Each gate +calls PauliSum::map_insert -> ACMapInsert::map_insert, whose HashMap impl +(ppvm-traits/src/map/hashmap.rs:157) is a SINGLE-THREADED `for (k,v) in +self.iter_mut()`. The closure is already `Fn + Sync + Send`. The expm path +parallelises the analogous action pass (mf_expm::build_mf_cols) with rayon. + +## Iteration 1 (running): parallelise map_insert gate application +Hypothesis: rayon-parallelise the per-entry closure (independent `*v *= cos` +mutation + collected branch terms), merge branch terms into dest. Expected +~cores x on the gate phase (98% of step). Small-map sequential fallback. + +## Iteration 1: parallelize map_insert — DISCARD (~30% slower) +Cherry-picked 3d5f0409 (subagent parallelized map_insert_vec/map_insert via +collect-mut-refs + par_iter + par_extend, threshold 1024). Result: wall 1.0s +(min) vs 0.777s baseline, RSS 230 vs 173MB. Correct (M=1.0), but SLOWER. +Root cause: a Trotter step applies ~120 gates (rxx+ryy over ~60 bonds x2 +directions), and EACH gate calls map_insert over the full ~34k-term map. The +per-call `iter_mut().collect::>()` (~0.5MB alloc) + rayon +dispatch is paid 120x/step; the per-term work (a few-ns commutation check) is +memory-bandwidth-bound, so parallel gain is small and the per-call overhead +dominates. Opposite of the expm path (ONE big par pass per call over the basis). +Anti-pattern: don't rayon-parallelize a cheap loop that's invoked many times per +step. Better directions (next): (a) skip non-overlapping terms via a +qubit-support index so a gate on bond (a,b) visits only terms touching a/b +instead of all 34k (algorithmic, cuts the O(gates x basis) work); (b) if +parallelizing, do it at a coarser grain with no per-gate collect (hashbrown +native par_iter_mut), only for very large maps. + +## Iteration 1 REVISITED (per user: "regression an artifact of too-small a problem?") — KEEP +Re-tested parallel vs sequential at larger N, clean/quiet, min of 3: +- N=10 (L=5, 8 steps, peak 34k): seq 0.777s vs par 0.702-0.77s -> NEUTRAL. + (The earlier "1.0s / +30%" was measured under machine load — seq was also + inflated to 1.17s then; on a quiet machine parallel N=10 matches sequential.) +- N=12 (L=6, 6 steps): seq 18.4s (min) vs par 7.5s (min) -> ~2.5x FASTER. +The user was right: the regression was a small-problem + load artifact. At +N=12 each gate operates on a large intra-step map (gates deferred with +truncate=False grow the map before the single per-step truncate), so the +per-term parallel work amortizes the rayon dispatch overhead. Correct (M=1.0). +Cost: RSS +60% at N=12 (627 vs 396MB — per-thread accumulation buffers). +DECISION: KEEP (helps where Trotter is slow; neutral where it's fast). +Lesson: benchmark the autotune metric at a size representative of the SLOW +regime, not the fast one — a small metric hid a real 2.5x structural win. + +## Iteration 1 FINAL VERDICT (clean quiet machine) — DISCARD +The "KEEP 2.5x" above was ALSO a measurement artifact. Re-measured parallel vs +sequential BOTH freshly-built on a quiet machine (min of 3-5): + N=12 sequential 8.06s / 396MB vs parallel 7.25s / ~600MB +=> only ~1.1x faster for +50% RAM. The earlier 18.4s "sequential" was +load-inflated (~2.3x); the earlier 30%-slower "parallel" was also load. Both +prior verdicts were corrupted by single-run walls under variable machine load. +Clean physics: the gate loop calls map_insert ~120x/step (short parallel +sections) on memory-bandwidth-bound work, so parallel speedup is small and the +per-gate collect overhead nearly cancels it; the per-thread buffers cost +50% +RAM. DISCARD (reverted to sequential). +PROCESS LESSON (critical): NEVER decide keep/discard from single-run walls under +uncontrolled load. Require: quiet machine, both variants freshly built, min-of-N, +ideally back-to-back. Two flip-flops here came from ignoring this. + +## CONCLUSION +No net change kept. Trotter gate application (98%) is memory-bandwidth-bound and +invoked ~120x/step; naive rayon parallelization gives only ~1.1x for +50% RAM +(discarded). The only real remaining lever is architectural — columnar Pauli +bit-plane storage to make the per-gate pass cache-friendly + SIMD-able before +parallelizing — deferred (major rewrite, uncertain payoff). Trotter is near its +practical optimum for the current HashMap-of-Paulis representation. diff --git a/docs/autotune/2026-07-02-trotter-ladder/metric.toml b/docs/autotune/2026-07-02-trotter-ladder/metric.toml new file mode 100644 index 00000000..2d5d8105 --- /dev/null +++ b/docs/autotune/2026-07-02-trotter-ladder/metric.toml @@ -0,0 +1,32 @@ +[[metric]] +"commit" = "0e316372" +"status" = "keep" +"description" = "baseline: N=10 XY ladder, dt=0.1, 8 steps, mac=1e-3; gate-apply 98%, trunc 2%" +"wall_s" = 0.777 +"peak_basis" = 34030.0 +"rss_mb" = 173.0 + +[[metric]] +"commit" = "15f96554" +"status" = "discard" +"description" = "parallelize map_insert (rayon): ~30% SLOWER (1.0s vs 0.777s), +60MB. map_insert called ~120x/step over full map; per-call Vec-collect+rayon dispatch overhead swamps gain on memory-bound cheap-per-term loop" +"wall_s" = 1.003 +"peak_basis" = 34030.0 +"rss_mb" = 230.0 + +[[metric]] +"commit" = "fb5d5881" +"status" = "keep" +"description" = "parallelize map_insert (rayon) \u2014 KEEP after larger-N retest (per user): N=12 18.4s->7.5s (~2.5x), N=10 neutral (0.70-0.77 vs 0.78). Earlier discard was small-problem + machine-load artifact. RSS +60% at N=12 (per-thread buffers)." +"wall_s_N12" = 7.49 +"wall_s_N10" = 0.702 +"rss_mb_N12" = 627.0 + +[[metric]] +"commit" = "9ee8b3ba" +"status" = "discard" +"description" = "CORRECTION (clean quiet machine): parallelize map_insert is only ~1.1x (seq 8.06s vs par 7.25s N=12) for +50% RAM (396->600MB). The earlier '2.5x KEEP' was a load artifact (seq measured at 18.4s under load). ~120 short par sections/step + memory-bandwidth-bound work => limited parallel gain; per-gate Vec-collect overhead ~cancels it. Net loss given memory. Now reverted (sequential)." +"wall_s_N12_seq" = 8.06 +"wall_s_N12_par" = 7.25 +"rss_mb_N12_par" = 600.0 + diff --git a/docs/autotune/2026-07-03-orbit-compare/diffusion_L41.png b/docs/autotune/2026-07-03-orbit-compare/diffusion_L41.png new file mode 100644 index 00000000..ce83e3f0 Binary files /dev/null and b/docs/autotune/2026-07-03-orbit-compare/diffusion_L41.png differ diff --git a/docs/autotune/2026-07-03-orbit-compare/diffusion_sliding_L41.png b/docs/autotune/2026-07-03-orbit-compare/diffusion_sliding_L41.png new file mode 100644 index 00000000..a0fb0b2e Binary files /dev/null and b/docs/autotune/2026-07-03-orbit-compare/diffusion_sliding_L41.png differ diff --git a/docs/autotune/2026-07-03-orbit-compare/exact_msd_L7.py b/docs/autotune/2026-07-03-orbit-compare/exact_msd_L7.py new file mode 100644 index 00000000..a3724761 --- /dev/null +++ b/docs/autotune/2026-07-03-orbit-compare/exact_msd_L7.py @@ -0,0 +1,75 @@ +"""Exact reference for the XY-ladder REAL-SPACE profile + MSD, exploiting +total-magnetization conservation (the quspin-style sector reduction). + +a_x(t) = (1/D) Tr[Z_x Z_seed(t)], seed = (Z_{j0,0}+Z_{j0,1})/2, j0 = L//2. +H = XX+YY on all ladder bonds conserves total Z, so H, U(t), and the diagonal +Z observables are block-diagonal in the magnetization sectors (dim <= C(N,N/2), +e.g. 3432 at N=14 instead of 16384). Per sector: dense eigh (trivial at these +dims), U_m = V e^{iEt} V^T, and + a_x(t) += (1/D) d_x[m]^T |U_m(t)|^2 d_seed[m]. +Validated against the full-space |U|^2 bilinear at L=5 (which itself was +validated against kron ED). + +Usage: exact_msd_L7.py L T M out.npz (times t_m = m*T/M) +""" +import sys, time +import numpy as np + +def ladder(L): + N = 2 * L + site = lambda j, leg: leg * L + j + bonds = [] + for leg in (0, 1): + for j in range(L): + bonds.append((site(j, leg), site((j + 1) % L, leg))) + for j in range(L): + bonds.append((site(j, 0), site(j, 1))) + return N, bonds + +L = int(sys.argv[1]) +T, M = float(sys.argv[2]), int(sys.argv[3]) +out = sys.argv[4] +N, bonds = ladder(L) +dim = 2 ** N +j0 = L // 2 + +t0 = time.time() +idx = np.arange(dim) +pop = np.array([bin(s).count("1") for s in range(dim)]) +d = np.array([1 - 2 * ((idx >> (N - 1 - q)) & 1) for q in range(N)], dtype=float) # (N, dim) +dseed = 0.5 * d[j0] + 0.5 * d[L + j0] + +ts = np.array([m * T / M for m in range(M + 1)]) +a = np.zeros((M + 1, N)) +for nup in range(N + 1): + sec = np.flatnonzero(pop == nup) + dm = len(sec) + pos = {int(s): i for i, s in enumerate(sec)} + Hm = np.zeros((dm, dm)) + for (p, q) in bonds: + bp = (sec >> (N - 1 - p)) & 1 + bq = (sec >> (N - 1 - q)) & 1 + differ = np.flatnonzero(bp != bq) + for i in differ: + dst = int(sec[i]) ^ (1 << (N - 1 - p)) ^ (1 << (N - 1 - q)) + Hm[pos[dst], i] += 2.0 + E, V = np.linalg.eigh(Hm) + Vt = np.ascontiguousarray(V.T) + ds_m = dseed[sec] + dx_m = d[:, sec] # (N, dm) + for m, t in enumerate(ts): + ph = np.exp(1j * E * t) + P = (V * ph.real) @ Vt + P = P ** 2 + P += ((V * ph.imag) @ Vt) ** 2 + a[m] += dx_m @ (P @ ds_m) / dim + print(f"sector Nup={nup} dim={dm} done ({time.time()-t0:.0f}s)", flush=True) + +jcoord = np.arange(N) % L +dj = (jcoord - j0) % L +dj = np.where(dj > L // 2, dj - L, dj).astype(float) +msd = (a * dj[None, :] ** 2).sum(axis=1) / a.sum(axis=1) +np.savez(out, ts=ts, a=a, msd=msd, j0=j0, L=L) +print("sum_x a_x(T) =", a[-1].sum(), "(should be 1)", flush=True) +print("MSD(t):", np.round(msd, 5), flush=True) +print(f"total {time.time()-t0:.0f}s", flush=True) diff --git a/docs/autotune/2026-07-03-orbit-compare/exact_msd_L7_T10.npz b/docs/autotune/2026-07-03-orbit-compare/exact_msd_L7_T10.npz new file mode 100644 index 00000000..624722c0 Binary files /dev/null and b/docs/autotune/2026-07-03-orbit-compare/exact_msd_L7_T10.npz differ diff --git a/docs/autotune/2026-07-03-orbit-compare/exact_msd_L7_T2.npz b/docs/autotune/2026-07-03-orbit-compare/exact_msd_L7_T2.npz new file mode 100644 index 00000000..3c8c6fb9 Binary files /dev/null and b/docs/autotune/2026-07-03-orbit-compare/exact_msd_L7_T2.npz differ diff --git a/docs/autotune/2026-07-03-orbit-compare/exact_ref_L7.py b/docs/autotune/2026-07-03-orbit-compare/exact_ref_L7.py new file mode 100644 index 00000000..0f2f465d --- /dev/null +++ b/docs/autotune/2026-07-03-orbit-compare/exact_ref_L7.py @@ -0,0 +1,77 @@ +"""Exact ED reference for the L=7 (N=14) XY ladder momentum-k Z autocorrelation. + +C_k(t_m) = / , O_k = sum_j e^{-i phi (j%L)} Z_j, +phi = 2 pi k / L, t_m = m*T/M. Same |U|^2 bilinear form as the L=5 driver in +orbit_bench.py (verified there vs both methods), with two speed tricks: +- H for the XY ladder is REAL symmetric -> real dsyevd (fast, multithreaded), +- U_block = (V_b * e^{iEt}) @ V^T computed as two real dgemms (re/im parts), + blocked over rows so peak RAM stays ~ 2|V| + one block. + +Usage: exact_ref_L7.py k T M out.npz +""" +import sys, time +import numpy as np + + +def ladder(L): + N = 2 * L + site = lambda j, leg: leg * L + j + bonds = [] + for leg in (0, 1): + for j in range(L): + bonds.append((site(j, leg), site((j + 1) % L, leg))) + for j in range(L): + bonds.append((site(j, 0), site(j, 1))) + return N, bonds + + +L = 7 +k, T, M = int(sys.argv[1]), float(sys.argv[2]), int(sys.argv[3]) +out = sys.argv[4] +N, bonds = ladder(L) +phi = 2 * np.pi * k / L +dim = 2 ** N + +t0 = time.time() +# XX+YY on a bond: flips the two bits where they differ, matrix element +2 (real). +H = np.zeros((dim, dim)) +idx = np.arange(dim) +for (p, q) in bonds: + bp = (idx >> (N - 1 - p)) & 1 + bq = (idx >> (N - 1 - q)) & 1 + src = idx[bp != bq] + dst = src ^ (1 << (N - 1 - p)) ^ (1 << (N - 1 - q)) + H[dst, src] += 2.0 +print(f"H built ({time.time()-t0:.0f}s), symmetric: {np.allclose(H, H.T)}", flush=True) + +t1 = time.time() +E, V = np.linalg.eigh(H) # real symmetric -> dsyevd +del H +print(f"eigh done ({time.time()-t1:.0f}s)", flush=True) + +d = np.zeros(dim, complex) +for q in range(N): + d += np.exp(-1j * phi * (q % L)) * (1 - 2 * ((idx >> (N - 1 - q)) & 1)) +den = (np.conj(d) @ d).real + +ts = np.array([m * T / M for m in range(M + 1)]) +res = np.zeros(len(ts), complex) +Vt = np.ascontiguousarray(V.T) +BLK = 2048 +t2 = time.time() +for lo in range(0, dim, BLK): + hi = min(lo + BLK, dim) + Vb = V[lo:hi, :] + for m, t in enumerate(ts): + ph = np.exp(1j * E * t) + Wre = Vb * ph.real + Wim = Vb * ph.imag + P = (Wre @ Vt) ** 2 + P += (Wim @ Vt) ** 2 # P = |U_block|^2, real + res[m] += np.conj(d[lo:hi]) @ (P @ d) + print(f"rows {hi}/{dim} ({time.time()-t2:.0f}s)", flush=True) +res /= den +np.savez(out, ts=ts, ref=res, k=k, T=T, L=L) +print("REF real:", np.round(res.real, 6), flush=True) +print("REF |imag| max:", np.abs(res.imag).max(), flush=True) +print(f"total {time.time()-t0:.0f}s", flush=True) diff --git a/docs/autotune/2026-07-03-orbit-compare/exact_ref_L7_k1_T2.npz b/docs/autotune/2026-07-03-orbit-compare/exact_ref_L7_k1_T2.npz new file mode 100644 index 00000000..d3ae62bd Binary files /dev/null and b/docs/autotune/2026-07-03-orbit-compare/exact_ref_L7_k1_T2.npz differ diff --git a/docs/autotune/2026-07-03-orbit-compare/exact_ref_L7_k3_T2.npz b/docs/autotune/2026-07-03-orbit-compare/exact_ref_L7_k3_T2.npz new file mode 100644 index 00000000..6f146754 Binary files /dev/null and b/docs/autotune/2026-07-03-orbit-compare/exact_ref_L7_k3_T2.npz differ diff --git a/docs/autotune/2026-07-03-orbit-compare/growth_probe.py b/docs/autotune/2026-07-03-orbit-compare/growth_probe.py new file mode 100644 index 00000000..c5fdc471 --- /dev/null +++ b/docs/autotune/2026-07-03-orbit-compare/growth_probe.py @@ -0,0 +1,58 @@ +"""Basis-growth feasibility probe for the orbit-rep expm path at scale. + +Prints one line per step: step, t, basis size, step wall, cumulative wall, RSS. +Usage: growth_probe.py L k T dt drop max_basis +""" +import sys, time, resource +import numpy as np +from ppvm import Lindbladian +from ppvm.lindblad import _basis_to_codes +from ppvm._core import TranslationGroup, canonicalize_basis_arr_complex + + +def ladder(L): + N = 2 * L + site = lambda j, leg: leg * L + j + bonds = [] + for leg in (0, 1): + for j in range(L): + bonds.append((site(j, leg), site((j + 1) % L, leg))) + for j in range(L): + bonds.append((site(j, 0), site(j, 1))) + return N, bonds + + +def rss_mb(): + return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / (1024 * 1024) + + +L, k, T, dt, drop, mb = (int(sys.argv[1]), int(sys.argv[2]), float(sys.argv[3]), + float(sys.argv[4]), float(sys.argv[5]), int(sys.argv[6])) +N, bonds = ladder(L) +phi = 2 * np.pi * k / L +steps = round(T / dt) + +h = [] +for (p, q) in bonds: + for P in "XY": + s = ["I"] * N + s[p] = P + s[q] = P + h.append(("".join(s), 1.0)) +Lop = Lindbladian(N, h, []) +g = TranslationGroup.ladder(L, 2) +mom = np.array([k], dtype=np.int32) +zs = ["I" * q + "Z" + "I" * (N - q - 1) for q in range(N)] +zb = _basis_to_codes(zs, N) +seed = np.array([np.exp(-1j * phi * (q % L)) for q in range(N)], dtype=np.complex128) +bo, coo = canonicalize_basis_arr_complex(zb, seed, g, mom) +pr = bo.copy() + +print(f"# probe: ladder L={L} (N={N}) k={k} T={T} dt={dt} drop={drop:.0e} mb={mb}", flush=True) +print(f"{'step':>4} {'t':>6} {'basis':>9} {'dwall_s':>8} {'wall_s':>8} {'RSS_mb':>8}", flush=True) +t0 = time.time() +for st in range(steps): + ts = time.time() + bo, coo = Lop.pc_step_orbit_rep(bo, coo, dt, mb, g, mom, drop, pr) + now = time.time() + print(f"{st+1:>4} {(st+1)*dt:>6.2f} {len(bo):>9} {now-ts:>8.1f} {now-t0:>8.1f} {rss_mb():>8.0f}", flush=True) diff --git a/docs/autotune/2026-07-03-orbit-compare/highest_precision_curves_k1.png b/docs/autotune/2026-07-03-orbit-compare/highest_precision_curves_k1.png new file mode 100644 index 00000000..479d93a6 Binary files /dev/null and b/docs/autotune/2026-07-03-orbit-compare/highest_precision_curves_k1.png differ diff --git a/docs/autotune/2026-07-03-orbit-compare/log.md b/docs/autotune/2026-07-03-orbit-compare/log.md new file mode 100644 index 00000000..befdd3c6 --- /dev/null +++ b/docs/autotune/2026-07-03-orbit-compare/log.md @@ -0,0 +1,1709 @@ +# Orbit-preserving Trotter vs expm comparison (2026-07-03) + +Goal: compare Trotter vs expm in the ORBIT-PRESERVING (translation-symmetry / +momentum-k) representation, precision-matched at rel~1e-3, on an example with +~1M final basis. For expm, compare stream vs cache (leakage) with a max_basis cut. +Scan dt and drop_tol for both. + +## Stage 0 (DONE): foundation verified vs exact ED (N=8 XY chain, k=1) +Both orbit-preserving methods reproduce the exact momentum-k autocorrelation +C_k(t) = /, O_k = sum_j e^{-2pi i k j/N} Z_j: +- expm orbit-rep (pc_step_orbit_rep, complex coeffs) == exact (to ~1e-4). +- Trotter symmetry-merged (real-pair PauliSum + momentum_merge) == exact + (real part ~1e-4; tiny O(dt^2) equivariance drift in imag). +KEY API notes learned: +- expm: seed = k-mode Z (basis Z_j, coeffs e^{-i phi j}); project via + canonicalize_basis_arr_complex(basis,coeffs,group,mom); evolve + pc_step_orbit_rep(basis,coeffs,dt,max_basis,group,mom,drop,protected). + TranslationGroup.chain_1d(N), mom=[k]. C_k via overlap with the seed reps. +- Trotter: keep O_k as a REAL PAIR (R=sum cos(phi j)Z_j, Im=sum -sin(phi j)Z_j); + apply the SAME real gates to both; R.momentum_merge(Im,group,mom) folds to + orbit-rep. GATE ANGLE = 2*c*dt (Rxx=e^{-i th/2 XX}, H coeff c=1) -- using dt + (not 2dt) decays ~4x too slow. C_k via PauliSum.overlap: + = (+) + i(-), norm = + = N. +Harnesses: orbit_verify.py (expm), trotter_orbit_verify.py (Trotter). + +## Remaining stages +1. DONE (858bbef7): orbit-rep streaming via PPVM_EXPM_STREAM (StreamOrbitOp). +2. DONE (2026-07-04): scaled to 1.40M orbit-rep peak at L=7 -- and got an EXACT + ED reference there instead of a converged one (see below). +3. DONE (2026-07-04): dt x knob scan both methods at L=7; see the at-scale + section below. NOTE: the L=5 Trotter numbers above used the legacy + end-of-step truncation scheme; see the scheme-fix note below. + +## L=5 (N=10) orbit k-resolved: Trotter vs expm, exact-ED referenced + +Ladder L=5, k=2, T=2, momentum-k Z autocorrelation C_k(t). 2nd-order Strang +Trotter (palindrome, matches the verified real-space scheme) vs expm orbit-rep +(pc_step_orbit_rep). drop/mac=1e-4 (fine -> dt-floor-limited); dt scanned. + +dt-convergence (drop=1e-4): + expm: dt0.1->2.0e-2 dt0.05->1.9e-3 dt0.025->7.8e-4 (~O(dt^3)) + trotter: dt0.1->1.15e-2 dt0.05->2.9e-3 dt0.025->1.26e-3 (O(dt^2), confirms 2nd order) + +Matched rel~1e-3 (interp): + expm: ~47s, peak_basis 52k, RSS ~262 MB + trotter: ~44s, peak_basis 256k, RSS ~270 MB +=> WALL ~TIED (~1.05x, Trotter marginally faster). RSS ~TIED. But expm keeps + ~5x FEWER terms (52k vs 256k) -- the k-resolved orbit reduction is real and + large. It does NOT convert to a net wall/RAM win here because expm's per-term + cost is ~higher (complex CSC action cache + doubly-enriched transient + + complex coeffs), which cancels the 5x term-count advantage. +Caveat: at L=5 the RSS (~250-270MB) is baseline-dominated (python/numpy ~200MB), +which masks the per-term memory difference; at larger basis the expm-heavier- +per-term effect would be more visible in RSS. + +Stream vs cache with a max_basis=15k CUT (from the first pass, dt0.1 drop1e-4): + cache: 5.6s, 217 MB ; stream: 73.4s (~13x), 198 MB (~9% less), identical rel. +=> streaming is a LARGE-basis RAM tool; at 15k it costs ~13x wall for ~9% RAM + (the cache is a small fraction of the ~200MB baseline there). Not worth it + at this size. + +## At scale: L=7 (N=14) ladder, k=3, T=2, EXACT-ED referenced (2026-07-04) + +Design: instead of a self-converged reference at L=8+ (probes showed drop<=3e-4 +grows unboundedly there: 904k terms at t=0.7, still x1.8/step), we chose L=7 -- +big enough to be truncation-limited everywhere probed (orbit ceiling 4^14/7 = +38M, bases reached 5.5M), small enough that the EXACT reference is computable: +dim 2^14 dense ED. exact_ref_L7.py computes C_k(t) via the |U|^2 bilinear form +(same formula as the verified L=5 driver), with two speedups: H(XY) is REAL +symmetric -> dsyevd, and U-blocks via two real dgemms. 44 min one-time, saved +to exact_ref_L7_k3_T2.npz. Validated at L=5 against the known REF (5e-7). + +### TROTTER SCHEME FIX (important) +orbit_bench.py ran the WHOLE Strang palindrome with truncate=False and one +truncate() per step ("legacy"). The VERIFIED real-space baseline +(demo/trotter_ladder.py) truncates per bond (rxx untruncated, each ryy +truncates). At N=10 the difference is mild (L=5, dt.05/mac1e-4: legacy rel +2.9e-3/44s, per-bond 3.7e-3/35s, same 256k peak). At N=14 it is fatal for the +legacy scheme: mac=3e-3 dt=0.1 ran >2.5h at 10.7GB RSS (killed; intra-palindrome +transient explodes over 84 untruncated gates) where per-bond takes 1.0s. All +L=7 Trotter rows below use per-bond ("trotter-pb", the honest baseline). +=> The L=5 "wall ~tied" conclusion above is an artifact of the weak legacy + baseline. The at-scale picture replaces it. + +### Scan (harness orbit_bench_L7.py; uncontended M-machine, 10 cores, 32GB) +method dt knob rel_err wall_s RSS_mb peak +expm-cache 0.1 3e-3 1.87e-2 79 965 23.5k +expm-cache 0.1 1e-3 2.21e-2 709 5334 212k +expm-cache 0.05 3e-3 7.41e-3 107 625 12.3k +expm-cache 0.05 1e-3 9.17e-3 961 3510 116k +expm-cache 0.05 3e-4 2.08e-3 5397 9240 1.40M <- best rel achieved +expm-cache 0.025 1e-3 6.51e-3 951 1802 48.5k +trotter-pb 0.1 3e-3 3.23e-2 1 164 18.9k +trotter-pb 0.1 1e-3 1.45e-2 9 296 160k +trotter-pb 0.05 1e-3 3.01e-2 4 183 58.6k +trotter-pb 0.05 3e-4 4.13e-3 103 722 637k +trotter-pb 0.05 1e-4 3.31e-3 1412 4155 5.46M <- trotter frontier +trotter-pb 0.025 3e-4 5.79e-3 58 308 208k +trotter-pb 0.025 1e-4 5.61e-3 761 1369 1.82M + +### Findings +1. BOTH methods have a dt<->knob coupling: at fixed knob, SMALLER dt can be + WORSE (trotter mac=3e-4: dt.05 4.1e-3 -> dt.025 5.8e-3; expm drop=1e-3: + dt.05 9.2e-3 -> dt.025 6.5e-3 barely helps). Trotter: smaller rotations + put more new terms under mac, error compounds PER GATE (84 gates/step). + expm: tau_add = K*drop/dt stiffens the end-filter as dt shrinks. Tuning + must move dt and knob together. +2. PRECISION FRONTIER: expm reaches rel 2.08e-3 (1.40M terms, 90 min, 9.2GB). + Trotter saturates at ~3.3e-3: tightening mac 3e-4 -> 1e-4 bought x1.25 in + rel for x8.6 terms (637k -> 5.46M) and x14 wall -- extrapolating to 2e-3 + needs >>20M terms, RAM-infeasible here. Below rel ~3e-3, expm is the only + method that runs at all on this machine. +3. CROSSOVER at rel ~3e-3: looser than that, trotter-pb is far faster on wall + (its per-gate PauliSum engine is much cheaper per term than expm's complex + CSC/Krylov step: at rel~4e-3 trotter costs ~100s vs expm ~2-3000s interp). + Tighter than that, trotter's cost curve goes vertical (finding 2) while + expm still converges (dt^3 floor + per-STEP error accumulation). +4. TERM EFFICIENCY unchanged from L=5: at the frontier expm holds 3.9x fewer + terms (1.40M vs 5.46M) at BETTER rel -- but expm's per-term RAM is ~5x + (9.2GB vs 4.2GB at those sizes: complex coeffs + doubly-enriched transient + + CSC cache), so the term advantage does not convert to an RSS win. +5. STREAM vs CACHE at mb=100k cut (dt.1/3e-4): identical rel 6.13e-2 (good), + stream 649s vs cache 27s (24x) for 273 vs 288MB (-5%). Streaming's RAM + savings only matter at multi-M bases where its wall (24x on 90min) is + impractical -> cache is the right default on the orbit path; max_basis is + the RAM lever but the hard cap costs a lot of accuracy (6.1e-2 capped at + 100k vs 2.2e-2 uncapped 212k at same dt) -- prefer picking drop to fit the + RAM budget over capping (consistent with real-space scan_admission). +6. Growth probes (growth_probe.py): L=7 drop 1e-3 peaks ~212k at t=1.0 then + declines; L=7 drop 3e-4 crosses 1M around t~1.0 (peak 1.40M full run); + L=8 drop 1e-4 was 904k at t=0.7 still x1.8/step (unbounded within reach). + +### Paper implication (momentum section) +Punchline shifts from "wall ~tied, 5x fewer terms" (L=5, weak baseline) to: +k-resolved CTPP owns the high-precision regime -- exact-in-dt (O(dt^3) two-hop +floor) + per-step truncation beats 2nd-order Strang whose per-gate truncation +error accumulation makes rel <~ 3e-3 unreachable at N=14 regardless of budget; +at loose precision gate-based propagation is the faster tool. Both statements +are exact-ED-referenced at N=14 with bases to 5.5M terms. + +## k=1 (hydrodynamic mode) -- THE CANONICAL ROW (2026-07-04, user: "k=1 is enough") + +Same L=7/T=2 setup, exact ED ref exact_ref_L7_k1_T2.npz (17 min uncontended; +C_k1 decays slowly through zero at t~1: 1, .886, .611, .307, .077, -.049, ...). +Driver takes PPVM_BENCH_K to select the reference. + +method dt knob rel_err wall_s RSS_mb peak +expm-cache 0.1 3e-3 4.63e-3 30 681 19.0k +expm-cache 0.05 3e-3 1.62e-2 32 418 9.7k <- coupling: worse than dt=.1 +expm-cache 0.05 1e-3 1.81e-3 432 2828 84.8k <- beats trotter's ceiling, 20x fewer terms +expm-cache 0.05 3e-4 5.46e-4 2752 9274 1.01M <- SUB-1e-3, frontier +expm-cache 0.025 1e-3 6.02e-3 357 1224 35.9k +trotter-pb 0.1 3e-3 1.75e-2 1 162 17.5k +trotter-pb 0.1 1e-3 1.29e-2 5 261 142k +trotter-pb 0.05 1e-3 1.89e-2 3 179 55.5k +trotter-pb 0.05 3e-4 5.82e-3 44 706 567k +trotter-pb 0.025 3e-4 2.80e-3 20 274 190k +trotter-pb 0.025 1e-4 2.73e-3 280 1310 1.65M <- ceiling: x8.7 terms for -2.5% + +k=1 findings (sharper than k=3, same structure): +1. Trotter ceiling ~2.7e-3: mac 3e-4 -> 1e-4 bought 2.80 -> 2.73e-3 (x8.7 terms, + x14 wall). Same saturation as k=3, now at a lower level (slow mode = smaller + effective Trotter error), still a dead end. +2. expm goes SUB-1e-3: 5.46e-4 at 1.01M terms / 46 min / 9.3GB -- 5x more + accurate than trotter's ceiling at ~0.6x the terms. +3. At trotter's ceiling precision (~2.7e-3): expm dt.05/drop1e-3 gives 1.81e-3 + at 432s vs trotter 280s -- wall comparable, terms 85k vs 1.65M (20x fewer), + so at matched precision the orbit advantage DOES convert at k=1. +4. dt<->knob coupling confirmed on the expm side too: drop 3e-3, dt .1 -> .05 + WORSENS rel 4.6e-3 -> 1.6e-2 (tau_add = K*drop/dt stiffens the end-filter). +5. Paper row: k=1, exact-ED referenced, N=14 -- "CTPP reaches 5e-4 where + 2nd-order Strang saturates at 3e-3; at Strang's own best precision CTPP + holds 20x fewer terms at equal wall." + +## RAM comparison, best-of-each-method at matched precision (2026-07-04) + +(All RSS from ru_maxrss in the worker; ~150-200 MB of it is python/numpy +baseline, which matters only for the small-basis cells.) + +k=1, picking each method's CHEAPEST cell at a given rel: + rel ~1.3-1.9e-2 (loose): trotter .1/1e-3 13 e-3 4.5s 261 MB + expm .05/3e-3 16 e-3 32s 418 MB + -> trotter ~7x wall, ~1.6x RAM cheaper + rel ~5e-3 (mid): trotter .05/3e-4 5.8e-3 44s 706 MB + expm .1/3e-3 4.6e-3 30s 681 MB + -> genuinely TIED on both wall and RAM + (but dominated: see next row) + rel ~2.8e-3 (trotter's trotter .025/3e-4 2.8e-3 20s 274 MB + ceiling): expm .05/1e-3 1.8e-3 432s 2828 MB + -> trotter ~22x wall, ~10x RAM cheaper + rel <= 1e-3: expm .05/3e-4 5.5e-4 46min 9274 MB + trotter: unreachable at any budget tried + (1e-4: 2.73e-3 at 280s/1310 MB, saturated) + +k=3, same exercise: trotter's cheapest at its ~4e-3 class is .05/3e-4 +(4.1e-3, 103s, 722 MB); expm needs .05/3e-4 (2.1e-3, 90min, 9240 MB) to beat +it on rel -- again trotter is cheaper in wall AND RAM everywhere it can reach. + +CORRECTION to k=1 finding 3 above: "20x fewer terms at comparable wall" compared +expm against trotter's SATURATED 1e-4 cell (280s/1310 MB). Trotter's efficient +cell at the same rel (.025/3e-4: 20s/274 MB) is ~22x faster and ~10x lighter +than expm's 1.81e-3 cell. Fewer terms (85k vs 190k) does NOT convert to less +RAM: expm's per-term footprint (complex coeffs + CSC cache + enriched +transient) eats the entire term advantage. + +HONEST HEADLINE (both k): within the precision range Trotter can reach at all, +gate-based propagation is cheaper in BOTH wall and RAM (up to ~20x/~10x at its +ceiling). CTPP's momentum-space value is exclusively the extended precision +range (5x lower error at k=1: 5.5e-4 vs 2.7e-3 ceiling, at 46min/9.3GB) plus +what gate methods cannot do at all (Lindbladians, exact-in-dt). The paper +must state the trade this way and not claim a term-count win as a memory win. + +## Harness migration to xy-experiments + dt x drop x K mid-precision scan (2026-07-04) + +User preference: use the clean xy-experiments harnesses (k_pec_run.py / +k_trotter_run.py + main_k_{pec,xy}_ladder.py) instead of orbit_bench_L7.py. +Differences that matter: +- TROTTER: k_trotter_run evolves the momentum-MERGED pair (momentum_merge + every step). orbit_bench evolved the full real-space pair (merge only at + readout). Merging redistributes weight onto reps (coefficient = sum over L + translates), so the SAME min_abs_coeff is effectively ~L x looser: better + rel per knob, but far bigger live basis. Neither variant dominates -- see + below. Both validated vs exact ED at L=5 (pec 1.86e-3 == ledger; trot + 3.20e-3 vs 2.9e-3 unmerged). +- PEC: identical pc_step_orbit_rep kernel (k_pec_run passes no protected_arr; + negligible). Records C_k every step; supports dephasing jumps. +- Fixed stale `from ppvm_python_native import ...` (module no longer + installed; symbols live in ppvm._core) -- committed in xy-experiments + (a044a27). Peak RSS measured externally via /usr/bin/time -l (the h5 + peak_rss_mb attr is psutil END-rss, not peak). +Driver: scan_xy_mid.py (this folder). L=7, k=1, T=2, exact-ED referenced. + +Scan (K = PPVM_K_LEAKAGE, tau_add = K*drop/dt; K=0 no end-filter): +method dt drop K rel_err wall_s peakRSS peak_reps +trot(m) 0.1 1e-3 - 8.12e-3 70 910MB 906k +trot(m) 0.1 3e-4 - 7.42e-3 718 7.0GB 6.90M <- dt-floor, wasted +trot(m) 0.05 3e-3 - 1.49e-2 7 171MB 61k +trot(m) 0.05 1e-3 - 7.60e-3 65 586MB 522k +trot(m) 0.05 3e-4 - 2.55e-3 728 3.6GB 4.75M <- below unmerged ceiling! +trot(m) 0.025 3e-3 - 3.09e-2 4 133MB 22k <- coupling +trot(m) 0.025 1e-3 - 7.51e-3 38 247MB 189k +trot(m) 0.0125 1e-3 - 1.86e-2 21 168MB 62k <- coupling +pec 0.1 3e-3 0 5.10e-3 32 647MB 19k +pec 0.1 3e-3 1 7.26e-3 8 316MB 40k +pec 0.1 3e-3 5 2.36e-2 1 167MB 4.5k <- over-filtered +pec 0.05 3e-3 1 9.45e-3 7 249MB 21k +pec 0.05 3e-3 5 7.88e-3 1 136MB 1.3k <- absurdly cheap ~8e-3 +pec 0.1 1e-3 0 3.40e-3 341 3.8GB 159k +pec 0.1 1e-3 1 3.17e-3 93 1.5GB 354k +pec 0.1 1e-3 5 1.01e-2 8 273MB 43k +pec 0.05 1e-3 1 1.30e-3 82 808MB 211k <- STAR CELL +pec 0.05 1e-3 5 7.26e-3 4 219MB 11k +pec 0.025 1e-3 1 5.59e-3 45 329MB 70k +(NOTE trot(m) 0.1/1e-4 was killed pre-emptively: extrapolated ~30M reps / + ~30GB. merged 0.025/3e-4 not run -- gap, likely dominated anyway.) + +FINDINGS: +1. K=1 IS A STRICT PARETO WIN for pec at drop=1e-3: vs K=0 at dt=0.05 + (old-harness K0: 1.81e-3/432s/2.8GB) K=1 gives 1.30e-3 / 82s / 808MB -- + better rel, 5.3x wall, 3.5x RAM. Same at dt=0.1 (3.17e-3/93s/1.5GB vs + 3.40e-3/341s/3.8GB). K=5 over-filters at drop<=1e-3 but is a bargain + dial at loose drop (7.9e-3 at 1s/136MB!). K=1 default recommended for + momentum runs; the old K-leakage guidance ("K~1 helps 1.1-2x") strongly + UNDERSTATED the k=1 benefit. +2. Merged vs unmerged Trotter: NEITHER dominates. Merged reaches 2.55e-3 + (unmerged saturated at 2.7e-3) but at 728s/3.6GB/4.75M reps; unmerged + 0.025/3e-4 hit 2.80e-3 at 19.5s/274MB. Effective-knob loosening from the + merge is the cause. "Trotter" below = best variant per point. +3. dt<->knob coupling is now confirmed in BOTH methods and BOTH trotter + variants (trot(m) 0.025->0.0125 at 1e-3: 7.5e-3 -> 1.9e-2). + +MID-ACCURACY (~5e-3) OPTIMAL POINTS -- the head-to-head requested: + trotter (unmerged, dt=.025, mac=3e-4): rel 2.80e-3, 19.5s, 274 MB + pec (dt=.1, drop=3e-3, K=0): rel 5.10e-3, 32 s, 647 MB + pec (dt=.025, drop=1e-3, K=1): rel 5.59e-3, 45 s, 329 MB + -> AT the mid class Trotter's best point is still the cheapest (1.6-2.3x + wall, 1.2-2.4x RAM) and overshoots accuracy. + BUT one small step up in budget flips it: pec .05/1e-3/K1 = 1.30e-3 at + 82s/808MB -- 2.1x better rel than Trotter's best-ever (2.55e-3 at + 728s/3.6GB), at 8.9x less wall and 4.5x less RAM than that cell. +REVISED CLASS BOUNDARY (k=1): Trotter owns rel >~ 2.7e-3 (cheap, saturating); +pec+K1 owns rel <~ 2.5e-3 outright (wall AND RAM). The pec frontier costs +from the earlier k=1 section (K=0) are superseded by ~5x cheaper K=1 cells. + +## Merged vs unmerged Trotter: knob-calibrated dissection (2026-07-04, user challenge) + +User objection: merging is just a re-representation, the Lx storage win should +be unconditional. Test: merged cells at tau' = L*tau (folded coefficients are +~Lx larger, so same tau ==> ~Lx weaker truncation). dt=0.025, k=1: + + merged 7e-3 (=unmerged 1e-3): 2.23e-2 1.4s 113MB 4.0k reps + unmerged 1e-3: 3.27e-2 1.8s 161MB 18.6k terms + -> LOOSE REGIME: user is right. Merged wins on rel AND storage (~L x + compression) AND RAM at calibrated knobs. + + merged 2.1e-3 (=unmerged 3e-4): 2.75e-2 8.7s 145MB 43.8k reps + merged 1e-3: 7.51e-3 38s 247MB 189k reps + merged 5e-4: 3.49e-3 166s 620MB 717k reps + unmerged 3e-4: 2.80e-3 19.5s 274MB 190k terms + -> TIGHT REGIME: merged converges but needs ~3.8x more STORED reps (each + rep = 7 translates, so ~26x more represented weight) for slightly worse + rel; unmerged wins ~8x wall / ~2.3x RAM at the ~3e-3 class. Merged rel + is NON-MONOTONIC in drop (2.2e-2 -> 2.75e-2 -> 7.5e-3 -> 3.5e-3), a + coherent-error signature. + +Interpretation (mechanism hypothesis, not fully dissected): in merged form +every per-gate truncation decision on the mid-layer transient drops ALL L +translates coherently (error amplitude ~L per decision); in unmerged form +drops are translate-level with quasi-random phases (partial cancellation), +and the readout sector-projection filters out-of-sector truncation noise for +free. Loose truncation: few decisions -> storage win dominates. Tight: the +coherent amplification dominates. + +NOTE the pec orbit-rep path is the *proper* re-representation the objection +assumes: it never unfolds (whole evolution in canonical rep space, truncation +once per step on the invariant |c_orbit|), so it gets the Lx compression +without the per-gate coherent-drop pathology. Paper angle: symmetry-compressed +truncation is natural in the CTPP orbit basis; grafting it onto gate-based +propagation via per-step fold/unfold injects coherent truncation error. + +## Extended K=5 grid (2026-07-04, user: "K=5 got very close to Trotter") + +pec, k=1, all dt x drop with K=5 (tau_add = 5*drop/dt): + dt drop rel_err wall_s peakRSS peak_reps + 0.025 3e-3 1.04e-1 0.8 121MB 384 <- over-filtered (drop/dt big) + 0.025 1e-3 5.92e-2 1.9 156MB 3.0k <- over-filtered + 0.0125 1e-3 3.82e-2 1.4 131MB 0.8k <- over-filtered + 0.1 3e-4 2.86e-3 69.0 1.4GB 537k + 0.05 3e-4 2.78e-3 35.2 524MB 134k <- AT Trotter's best rel + 0.025 3e-4 4.82e-3 17.6 277MB 34k <- mid class, Trotter-like cost + 0.0125 3e-4 6.18e-3 9.5 196MB 8.4k + 0.1 1e-4 3.89e-3 869 5.9GB 4.75M <- dominated (dt floor + huge basis) + 0.05 1e-4 1.04e-3 500 2.1GB 1.30M + 0.025 1e-4 1.22e-3 225 923MB 325k + +FINDINGS: +1. USER HUNCH CONFIRMED: pec 0.05/3e-4/K5 = 2.78e-3 at 35s/524MB sits at + Trotter's best-ever rel (2.80e-3 at 19.5s/274MB) within 1.8x wall / 1.9x + RAM. With K=0 the same rel class cost 432s/2.8GB -- the end-filter buys + ~12x wall / ~5x RAM at Trotter-ceiling precision. +2. K=5 over-filters iff drop/dt is large (tau_add >~ 0.1); rule of thumb: + pick K*drop/dt ~ 0.01-0.03. +3. Combined pec Pareto frontier (k=1) now TRACKS Trotter down to its ceiling + and extends beyond: + 7.9e-3: 1s/136MB (3e-3,K5) | 4.8e-3: 18s/277MB (0.025/3e-4,K5) + 2.8e-3: 35s/524MB (0.05/3e-4,K5) | 1.3e-3: 82s/808MB (0.05/1e-3,K1) + 1.0e-3: 500s/2.1GB (0.05/1e-4,K5) | 5.5e-4: 46min/9.3GB (0.05/3e-4,K0) + Trotter's remaining edge at its own best point is <2x wall/RAM; below + ~2.5e-3 pec owns everything. +REVISED HEADLINE: with the K end-filter tuned (K*drop/dt ~ 0.01-0.03), CTPP +matches gate-based propagation within <2x across Trotter's entire reachable +range and is the only method below ~2.5e-3. + +## CORRECTION (2026-07-04, user): "within 2x" is NOT competitive + +The bar for "competitive" at Trotter's frontier point (2.80e-3 at 19.5s/274MB) +is ~20s wall. pec's best there (0.05/3e-4/K5: 2.78e-3, 35s, 524MB) MISSES it +(1.8x wall, 1.9x RAM). Retract the "tracks within <2x = competitive" framing. +Standing summary: at rel >~ 2.5e-3 Trotter remains the cheaper method; pec is +the only method below ~2.5e-3, and K-tuning cut its cost there by ~5-12x vs +K=0. Untested knobs if the ~20s target is pursued later: interior K (3-7) at +0.05/3e-4, drop between 3e-4 and 1e-3 with K rescaled to keep K*drop/dt ~ +0.01-0.03, dt ~ 0.03-0.04. + +## METRIC CHANGE: median pointwise relative error (2026-07-04, user directive) + +New standard metric: median over the 11 sample times of |C(t)-REF(t)|/|REF(t)| +(pointwise rel first, then aggregate; NOT norm-ratio, NOT avg-abs/avg-ref). +Rationale: L2 norm-ratio is dominated by early times where |REF| is O(1); +the mean of pointwise rel is dominated by the zero crossing at t=1.8 +(REF=-0.0026); the median measures typical relative accuracy across the +decay. Adopted after user review. + +All cells with stored curves, recomputed (k=1; wall/RSS from original runs; +old-harness cells re-run ONLY if wall < 30s per user rule): + + method K drop dt median_rel wall RSS + pec 1 1e-3 0.05 1.02e-3 81s 808MB <- best overall + pec 5 1e-4 0.05 1.36e-3 499s 2.1GB + pec 5 1e-4 0.025 2.29e-3 224s 923MB + pec 1 3e-3 0.05 5.39e-3 6.1s 249MB <- mid winner + pec 5 1e-3 0.05 7.21e-3 3.5s 219MB <- loose winner + pec 5 3e-4 0.0125 6.94e-3 8.8s 196MB + pec 5 3e-4 0.05 2.23e-2 34s 524MB <- ex-"star cell"! + pec 0 3e-3 0.1 1.65e-2 32s 647MB + trot(merged) - 5e-4 0.025 5.42e-3 165s 620MB <- trot best + trot(merged) - 1e-3 0.05 5.68e-3 64s 586MB + trot(merged) - 3e-4 0.05 5.86e-3 728s 3.6GB + trot(unmerged) - 3e-4 0.025 1.16e-2 21s 274MB + trot(unmerged) - 1e-3 0.1 5.37e-2 7s 261MB + (trot(unmerged) 0.025/1e-4 and pec K0 0.05/3e-4 NOT re-measured: >30s rule.) + +FINDINGS UNDER THE MEDIAN METRIC: +1. THE STORY FLIPS: pec wins EVERY precision class, by large margins. + ~7e-3: pec 3.5s/219MB vs trot 21s/274MB (unmerged, 1.16e-2). + ~5.5e-3: pec 6.1s/249MB vs trot(merged) 64s/586MB -- 10x wall, 2.4x RAM. + ~1e-3: pec 81s/808MB; NO measured Trotter cell below 5.4e-3. + Trotter saturates at median ~5.4e-3 among measured cells: its tight-knob + cells improve early-time (L2) accuracy but not late-time relative accuracy. +2. METRIC SENSITIVITY IS LARGE. The L2 "star cell" (pec 3e-4/0.05/K5: + L2 2.78e-3) has median 2.23e-2 -- excellent early, mediocre late. + Conversely pec K1 1e-3/0.05 is uniform (L2 1.30e-3, median 1.02e-3). + Same for trot(unmerged) 0.025/3e-4: L2 2.80e-3 but median 1.16e-2. + The L2-based conclusion "Trotter cheaper at rel >~2.5e-3" was an artifact + of early-time weighting; for k-resolved DECAY-RATE physics (late-time + relative accuracy), the median verdict is the physically relevant one. +3. Best-known settings under median: pec dt=0.05 with (drop=3e-3,K=1) for + fast/mid, (drop=1e-3,K=1) for precision. dt=0.05 is consistently the pec + sweet spot at T=2. + +## Merged vs unmerged Trotter, revisited under the MEDIAN metric (2026-07-04) + +Calibrated pairs (tau' = L*tau = 7*tau), dt=0.025, median pointwise rel: + merged 7e-3: 1.35e-1 0.7s 4.0k reps | unmerged 1e-3: 5.94e-2 1.8s 18.6k + merged 2e-3: 3.33e-2 8.0s 43.8k reps | unmerged 3e-4: 1.16e-2 21.2s 190k +-> UNMERGED is ~2.3-2.9x BETTER rel at BOTH calibrated pairs. The earlier L2 + result "merged wins the loose pair" was an early-time-weighting artifact; + under the median (late-time-sensitive) metric the coherent-drop penalty of + merged evolution shows at every knob setting. Unmerged costs ~2.6x wall and + ~4.3x stored terms at the tight pair (its per-translate storage), roughly + ~1.5x RSS. + +Frontier (best measured, any knob): + merged: 5.42e-3 @ 165s/620MB (0.025/5e-4); 5.68e-3 @ 64s/586MB (0.05/1e-3) + unmerged: 1.16e-2 @ 21s/274MB (0.025/3e-4) [tighter cells >30s: unmeasured] +-> merged reaches ~2x lower median than unmerged's best MEASURED point, but + only because it was run at tighter effective knobs (tau'=5e-4 = unmerged + mac 7e-5, whose unmerged run would be a multi-M-term/,>400s cell). At + matched effective truncation unmerged wins rel; at matched rel ~1.2-1.5e-2 + they cost about the same (unmerged 1.16e-2@21s/274MB vs merged + 1.46e-2@38s/247MB). + +Merged saturation: mac 1e-3 -> 3e-4 at dt=0.05 gives 5.68e-3 -> 5.86e-3 (11x +wall for nothing), and dt=0.025/5e-4 gives 5.42e-3 -- a dt-INDEPENDENT floor +~5.4e-3, consistent with coherent whole-orbit truncation noise rather than +Trotter dt error. + +Both variants remain dominated by pec under the median metric: pec K1 +3e-3/0.05 = 5.39e-3 @ 6.1s/249MB matches merged Trotter's best at ~27x less +wall and ~2.5x less RAM. + +## CORRECTION: merged vs unmerged at FIXED PRECISION, 1% median target (2026-07-04) + +User (correctly) rejected the calibrated-knob verdict: compare at fixed +precision, then wall+RAM. Target: median rel ~1e-2 ("good to go"). Filled the +two missing unmerged cells (curves re-measured, canonical uncontended +wall/RSS from the original runs): + unmerged 0.05/3e-4: median 1.75e-2 44s 706MB + unmerged 0.025/1e-4: median 8.06e-3 280s 1310MB + +BEST-OF-VARIANT AT median <= 1e-2 (k=1): + pec (1e-3/0.05/K5): 7.21e-3 3.5s 219MB <- overall winner + trot MERGED (1e-3/0.05): 5.68e-3 63.9s 586MB + trot UNMERGED (1e-4/0.025):8.06e-3 279.7s 1310MB +-> AT THE 1% TARGET MERGED BEATS UNMERGED: 4.4x wall, 2.2x RAM. The + calibrated-knob analysis (unmerged 2-3x better rel at same effective tau) + measured truncation efficiency per threshold -- NOT the practical + criterion. On the Pareto frontier the picture is: + >= 1.5e-2: unmerged cheapest (0.05/1e-3: 1.44e-2 at 3.0s/179MB) + ~1e-2 and below: merged wins among Trotter variants (crossover ~1.2e-2) + Both variants saturate, but merged saturates LOWER (~5.4e-3) and reaches + its level far cheaper than unmerged approaches its (~8e-3: x13 wall for + -30% from 1.16e-2 -> 8.06e-3). +-> So the Lx compression of merged evolution IS the practically better + scheme at the paper's working precision -- the user's original + re-representation intuition holds at fixed precision. The coherent-drop + effect is real (it sets merged's ~5.4e-3 floor and its loose-regime + disadvantage) but does not decide the 1% comparison. +pec context at 1%: 18x faster and 2.7x lighter than the best Trotter variant. +BENCHMARK TARGET going forward: median pointwise rel ~1e-2 (user directive). + +## Per-STEP truncation for merged Trotter: NEGATIVE result (2026-07-04) + +Test of "would truncating only once per step (post-merge, canonical reps) fix +merged Trotter's coherent-noise floor?" — k_trotter_run gained a +per_gate_trunc kwarg (main_k_xy_ladder: --gate_trunc 0). Cell: L=7, k=1, +dt=0.05, drop=1e-3, T=2. +RESULT: the run DIED without completing (~1.5h+, peak RSS 16.8 GB, no h5 +written) — the unpruned 84-gate intra-step transient explodes, the same +end-of-step-truncation blowup seen unmerged at N=14, only ~L x smaller and +still fatal. Per-step truncation is NOT a viable fix for merged Trotter at +this size; the per-gate/per-bond truncation that causes the coherent-noise +floor is also what keeps the transient bounded. (pec avoids the dilemma +structurally: its generator action never unfolds the orbit basis, so there +is no intra-step transient to prune.) + +## REAL-SPACE MSD comparison, L=7, gamma=0, T=2 (2026-07-04) + +Observable: MSD(t) from the localized center-rung seed (Z_{j0,0}+Z_{j0,1})/2, +via main_realspace_ladder.py (--mode trotter|adaptive) + msd.py. Metric: +median pointwise rel of MSD(t) over t=0.2..2.0 (t=0 excluded, MSD=0) vs the +sector-reduced exact reference (exact_msd_L7.py: magnetization-block eigh + +|U_m|^2 bilinear -- 18 s total, ~80x faster than the naive full-space |U|^2; +validated at L=5 to machine precision). K = PPVM_K_LEAKAGE reaches pc_step_arr +directly. Driver: scan_realspace_msd.py. + +mode dt knob K median_rel wall_s peakRSS peak_terms +trotter 0.1 1e-3 - 2.23e-2 0.3 112MB 10.1k +trotter 0.05 1e-3 - 3.22e-2 0.2 111MB 3.7k +trotter 0.05 3e-4 - 1.34e-2 2.4 120MB 39.2k +trotter 0.025 3e-4 - 1.34e-2 1.5 113MB 12.9k +trotter 0.05 1e-4 - 5.88e-3 25.8 190MB 337k +trotter 0.025 1e-4 - 7.91e-3 13.7 134MB 111k +trotter 0.025 3e-5 - 2.07e-3 209.4 415MB 1.20M +adaptive 0.1 1e-3 0 5.56e-3 8.9 1135MB 40.2k +adaptive 0.1 1e-3 1 5.19e-3 2.2 324MB 77.8k +adaptive 0.05 1e-3 1 8.87e-3 1.9 314MB 42.0k +adaptive 0.05 1e-3 5 2.70e-2 0.1 154MB 2.8k +adaptive 0.05 3e-3 1 2.89e-2 0.2 171MB 4.7k +adaptive 0.05 3e-3 5 1.72e-1 0.0 116MB 314 +adaptive 0.05 3e-4 1 3.97e-3 43.3 1472MB 491k +adaptive 0.05 3e-4 5 8.90e-3 1.6 313MB 34.3k +adaptive 0.025 1e-3 1 1.57e-2 1.9 226MB 16.8k +adaptive 0.1 3e-4 1 2.32e-3 50.5 2977MB 838k +adaptive 0.1 1e-3 5 2.95e-2 0.3 191MB 10.4k + +FIXED-PRECISION VERDICT (1% median target): + pec/adaptive (0.1/1e-3/K1): 5.19e-3 2.2s 324MB + trotter (0.025/1e-4): 7.91e-3 13.7s 134MB + -> pec ~6x FASTER; trotter ~2.4x LIGHTER. Split verdict, unlike momentum + space (where pec won both axes). +Deeper class (~2e-3): + pec (0.1/3e-4/K1): 2.32e-3 50.5s 3.0GB + trotter (0.025/3e-5): 2.07e-3 209.4s 415MB + -> same split, sharper: pec 4x wall advantage, trotter 7x RAM advantage. +FINDINGS: +1. Real space reverses the RAM story: without the orbit compression, pec's + per-term footprint (CSC cache + transient) makes it RAM-heavy while + per-bond Trotter stays lean. Matches the standing handoff note ("expm's + real-space weakness is memory"). Wall: pec wins every class (4-6x). +2. K=1 again a strict Pareto win over K=0 (0.1/1e-3: 4x wall, 3.5x RAM, + better rel). K=5 over-filters below drop 3e-4 at these dt. +3. pec's dt optimum here is dt=0.1 (coupling: at fixed drop/K, dt 0.05 and + 0.025 are WORSE - tau_add and per-step pruning tighten with 1/dt). +4. Both methods are dramatically cheaper in real space than momentum space + at L=7 (seconds, not minutes): the local seed spreads only ~half the ring + by T=2, whereas the k-mode is delocalized from t=0. + +## Real-space MSD: K=5 completion + streaming test (2026-07-04) + +adaptive 0.1 3e-4 5 4.58e-3 2.7s 396MB 124k <- new 1%-class best +adaptive 0.1 1e-4 5 1.87e-3 33.5s 2917MB 1.25M <- new deep frontier +adaptive 0.05 1e-4 5 6.47e-3 12.2s 839MB 325k +adaptive 0.025 3e-4 5 3.43e-2 0.7s 168MB 9.6k <- over-filtered +adaptive 0.1 1e-3 K1+stream 5.19e-3 8.5s 344MB <- identical rel, 3.9x wall, NO RAM gain +adaptive 0.1 3e-4 K1+stream 2.32e-3 245.4s 2574MB <- identical rel, 4.9x wall, -13% RAM + +1. K=5 at dt=0.1 is the real-space sweet spot (tau_add = 5*drop/0.1 lands in + the ~5e-3..1.5e-2 zone): 0.1/1e-4/K5 dominates the previous deep cell + (1.87e-3 vs 2.32e-3, 33.5s vs 50.5s, same RAM class). +2. STREAMING DOES NOT RESCUE THE RAM AXIS here: -0..13% RSS for 4-5x wall. + At these basis shapes RSS is dominated by the leakage transient (basis + arrays/maps), not the CSC action cache. The earlier "1.8x less RAM" + characterization applied to larger cache-dominated shapes; for the paper, + pec's real-space RAM cost is structural at this scale, not a mode choice. + (All scan cells use the DEFAULT cached-CSC path = the in-basis generator + built once per step as a sparse matrix and reused across expm matvecs; + PPVM_EXPM_STREAM=1 recomputes the action per matvec instead.) +Updated 1% verdict: pec 0.1/3e-4/K5 = 4.58e-3 @ 2.7s/396MB vs trotter +0.025/1e-4 = 7.91e-3 @ 13.7s/134MB -> pec ~5x faster, trotter ~3x lighter. +Deep ~2e-3: pec 1.87e-3 @ 33.5s/2.9GB vs trotter 2.07e-3 @ 209s/415MB -> +pec ~6x faster, trotter ~7x lighter. The split verdict stands, sharpened. + +## Real-space 1% cell refinement: dt=0.1, drop 4e-4..7e-4 (2026-07-04, user suggestion) + +adaptive 0.1 4e-4 K5 5.10e-3 1.4s 324MB 68.7k +adaptive 0.1 5e-4 K5 4.06e-3 0.9s 296MB 43.8k +adaptive 0.1 7e-4 K5 6.00e-3 0.5s 221MB 23.3k <- RSS-minimal <=7.9e-3 +adaptive 0.1 4e-4 K1 3.43e-3 16.1s 1499MB 472k +adaptive 0.1 5e-4 K1 3.20e-3 10.0s 883MB 303k + +UPDATED 1% VERDICT (real space): pec 0.1/7e-4/K5 = 6.00e-3 @ 0.5s/221MB vs +trotter 0.025/1e-4 = 7.91e-3 @ 13.7s/134MB -> pec 27x FASTER at 1.65x RSS +(and ~110-150MB of both is python baseline, so basis-attributable RSS is +pec ~80MB vs trotter ~10MB). The interior-drop sweep turned the "split +verdict" into "pec wins wall massively, RAM near-parity" at the 1% target. +The deep-precision split (pec 6x wall / trotter 7x RAM at ~2e-3) stands. +Also: rel is non-monotonic in drop within the 4-6e-3 band (coherent +cancellations) - pick cells by measured rel, not knob interpolation. + +## K sweep 1..10 at the real-space sweet spot (dt=0.1, drop=5e-4) (2026-07-04) + +K tau_add median_rel wall_s RSS peak +1 5e-3 3.20e-3 9.6 881MB 303k +2 1e-2 4.64e-3 5.2 569MB 202k +3 1.5e-2 4.71e-3 2.4 358MB 105k +4 2e-2 5.09e-3 1.4 325MB 64k +5 2.5e-2 4.06e-3 0.9 317MB 44k +6 3e-2 5.00e-3 0.6 268MB 33k +7 3.5e-2 6.07e-3 0.5 237MB 24k +8 4e-2 1.97e-2 0.3 193MB 18k <- CLIFF +9 4.5e-2 2.03e-2 0.3 208MB 16k +10 5e-2 2.99e-2 0.2 182MB 12k + +Shape: a long PLATEAU (rel 3.2-6.1e-3, non-monotonic wiggles) from K=1..7 +while wall drops 19x and RSS 3.7x, then a sharp accuracy CLIFF between +tau_add 3.5e-2 and 4e-2 (K=7 -> 8). Practical rule: increase K until just +before the cliff (tau_add <~ 3.5e-2 here); accuracy is insensitive across +the plateau, so K is nearly a free 10-20x wall / 3-4x RAM dial. Refines the +earlier "K*drop/dt ~ 0.01-0.03" guidance: plateau extends to ~0.035. + +## 2D scan: K (1..10) x drop_tol at dt=0.1, real-space MSD (2026-07-04) + +median_rel (rows=drop, cols=K1..K10; dt=0.1, so tau_add = K*drop*10): + 2e-3: 1.8e-2 2.5e-2 2.3e-2 2.5e-2 3.5e-2 4.2e-2 4.2e-2 7.0e-2 7.2e-2 1.3e-1 + 1e-3: 5.2e-3 5.0e-3 5.2e-3 |1.8e-2 2.9e-2 2.9e-2 2.0e-2 2.5e-2 4.1e-2 3.0e-2 + 5e-4: 3.2e-3 4.6e-3 4.7e-3 5.1e-3 4.1e-3 5.0e-3 6.1e-3 |2.0e-2 2.0e-2 3.0e-2 + 2e-4: killed 3.0e-3 3.8e-3 4.0e-3 4.7e-3 4.6e-3 5.0e-3 5.8e-3 4.8e-3 5.1e-3 + (| marks the cliff; 2e-4 has no cliff through K=10; 2e-4/K1 aborted -- cell + >200s, user: abort 1e-4-class cells. 1e-4 row: only K5 = 1.9e-3 measured.) +wall_s ranges: 2e-3 row 0.0-0.5s; 1e-3 row 0.1-2.0s; 5e-4 row 0.2-9.6s; +2e-4 row 1.6-201s (K2). RSS: 120MB (2e-3/K10) to 4.1GB (2e-4/K1-2). + +FINDINGS: +1. THE CLIFF IS A PURE tau_add THRESHOLD: 1e-3 cliffs at K=4, 5e-4 at K=8 -- + both tau_add = K*drop/dt = 4e-2; 2e-4 through K=10 only reaches 2e-2 and + shows no cliff. K and drop enter the end-filter ONLY through tau_add; + the safe zone is tau_add <~ 3.5e-2 regardless of how it is composed. +2. PLATEAU ACCURACY IS SET BY drop ALONE (the per-step prune): ~5e-3 at + 1e-3, ~3-6e-3 at 5e-4, ~3-5e-3 at 2e-4 -- improving only weakly below + 5e-4 (deepest: 1e-4/K5 = 1.9e-3). +3. RECIPE: pick drop for the target accuracy, then set K ~ 0.03*dt/drop. + Multiple (drop,K) combos on the same tau_add diagonal give near-identical + cost -- a degeneracy ridge (e.g. ~5e-3 at ~0.5s/230-270MB via 1e-3/K3, + 7e-4/K5, or 5e-4/K7). + +## tau_add scaling study: cliff position vs dt and drop (2026-07-04) + +36 cells, real-space MSD, dt in {0.2, 0.05, 0.025} x drop in {1e-3, 5e-4}, +fractional K chosen so tau_add = K*drop/dt sweeps {.01,.02,.03,.04,.06,.09} +identically in every row (plus the dt=0.1 rows from the 2D scan). + +RESULT: the admission cliff sits at tau_add ~ 0.035-0.04 for EVERY +(dt, drop) combination with dt <= 0.1 -- rows at dt=0.05 and 0.025, both +drops, all depart between tau*=0.03 and 0.06, exactly where dt=0.1 cliffed. +At dt=0.2 the O(dt) plateau (~1.5e-2) masks the cliff (noisy row, bump at +.04 still visible). tau_add is an ABSOLUTE RATE THRESHOLD, independent of +both dt and drop_tol. + +=> K is the wrong parameterization: the correct invariant is tau_add itself, + and K should be DERIVED as K = tau_add * dt / drop_tol. Recommended + default tau_add ~ 0.02-0.03 (one octave below the cliff). Caveat: the + cliff value is observable/scale-dependent (momentum k=1 tolerated up to + ~0.1; here J=1, seed normalized to 1) -- expect tau_add* to scale with + the Hamiltonian coupling / seed norm, so expose it as a direct parameter + rather than hardcoding. +Secondary: plateau rel at fixed drop WORSENS as dt shrinks (dt=0.05: 8.8e-3 +-> dt=0.025: 1.1e-2 at drop 1e-3; dt=0.1: 5.2e-3) -- the per-step prune +frequency effect; dt~0.1 remains the real-space sweet spot at T=2. + +## CORRECTION: "plateau" was wrong; measured sensitivities (2026-07-04) + +Local log-log slopes over all measured adaptive pairs (dt<=0.1, real-space +MSD; pairs differ in exactly one knob): + d(ln rel)/d(ln tau_add), tau_add<0.035: median +0.24 IQR [+0.05,+0.37] n=68 + d(ln rel)/d(ln tau_add), tau_add>0.035: median +0.94 IQR [+0.55,+1.54] n=152 + d(ln rel)/d(ln drop) at fixed tau_add, below: median +0.03 IQR [-0.01,+0.25] n=32 + d(ln rel)/d(ln drop) at fixed tau_add, above: median -0.02 n=17 +=> 1. There is NO true plateau: rel rises ~ tau_add^0.24 below the cliff, + steepening to ~ tau_add^1 (with a sharp step) above ~0.04. "Slow rise + then steepening" is the correct description. + 2. USER OBSERVATION CONFIRMED: in the sampled regime the error depends + FAR more strongly on tau_add than on drop_tol -- at fixed tau_add, + drop has near-zero effect (median slope +0.03) except at the very + lowest tau_add where it re-emerges (~+0.3, e.g. 1e-4/tau=.005: + 1.9e-3 vs 5e-4/tau=.005: 3.2e-3). + 3. Model: rel ~ f(drop) + g(tau_add), f ~ drop^~0.3, g ~ tau_add^~0.24 + below / ^~1 above 0.04; whichever dominates sets the sensitivity. My + earlier "drop sets the plateau accuracy" conflated the two knobs + (drop was varied at fixed K, dragging tau_add along). + 4. Practical recipe (revised): lower tau_add and drop TOGETHER so + f ~ g (balanced); tau_add is the primary accuracy knob in the + commonly-sampled regime, drop the secondary. + +## Addendum: the efficient locus is ~fixed K at fixed dt (2026-07-04, user) + +Balance f(drop)~drop^0.3 against g(tau_add)~tau_add^0.24 => the efficient +descent path is tau_add ~ drop^1.25, i.e. K = tau_add*dt/drop ~ drop^0.25*dt: +at FIXED dt this is nearly constant K (data: good cells at K in [1,5] across +drop 1e-3..1e-4 at dt=0.1). So fixed-K tuning accidentally tracks the +efficient path within one dt; its failure modes are (a) dt changes rescale +the filter silently and (b) the hard cliff is absolute in tau_add (~0.04), +so K_cliff = 0.04*dt/drop moves 16x across our grid. The dimensionless knob +that is actually dt-invariant is K' = tau_add/drop (= K/dt); the cliff and +the efficient path are both naturally expressed in (tau_add, drop). + +## dt=0.025 verification of the tau_add / K' picture (2026-07-04) + +drop K tau_add K'=tau/drop median_rel wall_s RSS +2e-4 0.625 0.005 25 2.27e-3 34.0 881MB +2e-4 1.25 0.01 50 3.34e-3 23.5 882MB +2e-4 2.5 0.02 100 8.84e-3 5.5 327MB +2e-4 7.5 0.06 300 3.13e-2 0.7 177MB <- cliffed (>0.04) OK +1e-4 1.25 0.005 50 8.79e-4 151.6 3.0GB <- first sub-1e-3 real-space cell +1e-4 2.5 0.01 100 2.87e-3 36.6 837MB +1e-4 5 0.02 200 8.84e-3 8.4 357MB + +ALL CLAIMS CONFIRMED AT dt=0.025: +1. Cliff at fixed tau_add: 2e-4 row cliffs by tau=0.06, same as everywhere. +2. tau_add dominates at moderate tau: at tau=0.02, drop 2e-4 and 1e-4 give + IDENTICAL rel (8.84e-3 both); drop re-emerges at low tau (tau=0.005: + 2.27e-3 vs 8.79e-4 - f-dominated). +3. Efficient window is dt-INVARIANT in K' = tau_add/drop: Pareto cells at + K' 25-50, same as dt=0.1's ~10-50. In raw K the window shifted 4x down + (K 0.6-1.25 vs 1-5), exactly the dt ratio. +4. Cleanest single-pair demonstration of the K failure mode: SAME (K=5, + drop=1e-4) gives 1.9e-3 at dt=0.1 but 8.8e-3 at dt=0.025 - the fixed-K + filter silently quadrupled tau_add. +Bonus: 1e-4/K1.25 = 8.79e-4 is the deepest real-space cell so far; +2e-4/K0.625 (2.27e-3, 34s, 881MB) Pareto-improves the old deep cell +(1.87e-3, 33.5s, 2.9GB) on RAM by 3.3x at comparable rel/wall. + +## max_basis as the primary knob (2026-07-04, user hypothesis - CONFIRMED) + +1. COLLAPSE: below the cliff, rel ~ peak_basis^(-0.2..-0.5) with only + x1.1-1.35 residual scatter at fixed dt, across ALL (drop, tau_add) + compositions. Basis size is the first-order accuracy predictor and the + ~exact cost predictor (RSS and wall track peak). Second order: at equal + peak, low-tau_add compositions are slightly better. +2. RANK CAP BEATS THRESHOLDS AT MATCHED SIZE (dt=0.025, drop=1e-4, K=1.25): + M600k: 9.21e-4 127.9s 658MB peak 547k + M300k: 5.73e-4 58.3s 455MB peak 289k <- vs threshold cells at + ~300k: 2.3-3.3e-3 (4-5x better!) + M100k: 7.22e-4 18.6s 265MB peak 98.6k <- NEW FRONTIER + M30k: 9.90e-3 6.0s 218MB peak 29.9k <- cap below natural + weight distribution: back on the + size-rel curve + uncapped (1.3M): 8.79e-4 151.6s 3.0GB + M100k beats the uncapped run on EVERY axis (8x wall, 11x RAM, better + rel). keep-top-N is the optimal size-B selection; thresholds are not. + The cap also bounds the leakage transient => much lower RSS per kept term. + (M600k > M300k rel: non-monotone, cancellation noise ~1.5x - don't + over-read small rel differences.) +3. ONCE THE CAP BINDS, THRESHOLDS ARE SECONDARY: drop 1e-5 vs 1e-4 at M100k: + 1.10e-3 vs 7.22e-4, same wall/RSS. Keep mild thresholds only to cheapen + the pre-cap transient. +REVISED RECIPE (real space): set max_basis to the RAM/wall budget (the ONE +primary knob), keep tau_add ~ 0.005-0.02 and drop ~ 1e-4 as mild transient +hygiene. New best cells: 7.2e-4 @ 18.6s/265MB (M100k), 5.7e-4 @ 58s/455MB +(M300k) - both far beyond the old threshold-only frontier. + +## Head-to-head: tau_add vs max_basis as the size-setter, matched peak (2026-07-04) + +dt=0.025, drop=1e-4 unless noted; peak ~100k and ~300k classes: + + ~100k: tau-only (K5, no cap): 8.84e-3 8.4s 357MB 82k + cap-only (M100k, K0): 2.65e-3 18.8s 281MB 99k + cap-only, drop 1e-5 (pure): 9.79e-4 22.0s 266MB 100k + cap + tau (M100k, K1.25): 7.22e-4 18.6s 265MB 99k + ~300k: tau-only (K2.5, no cap): 2.87e-3 36.6s 837MB 325k + cap-only (M300k, K0): 1.40e-3 63.7s 459MB 289k + cap + tau (M300k, K1.25): 5.73e-4 58.3s 455MB 289k + +VERDICT AT MATCHED SIZE: +1. ACCURACY: cap > tau_add (2-3.3x better rel as the size-setter), and cap + composition + weak thresholds is best (7.2e-4 / 5.7e-4). +2. WALL: tau_add-only is ~2x cheaper than cap-only at the same size (K=0 + admits the full transient before the cap acts) - tau_add's remaining + value is transient hygiene, not selection. +3. RSS: cap wins (bounds the transient): 459 vs 837MB at 300k. +4. SUBTLE: with the cap binding, drop=1e-4 HURTS accuracy vs drop=1e-5 + (2.65e-3 vs 9.79e-4 at K0/M100k): the per-step prune pre-deletes strings + the rank cap would have ranked into the top-N. With a binding cap, keep + thresholds MINIMAL (tiny drop, mild tau_add for wall only). +FINAL KNOB HIERARCHY: max_basis = the accuracy/cost dial; tau_add = wall +hygiene (~2x); drop = keep tiny once the cap binds. + +## Framing note: fixed SIZE, not fixed SET (2026-07-04) + +The cap-primary mode is "adaptive basis of constant rank": leakage still +proposes candidates every step (the expansion machinery is the engine), and +the rank cap selects the top-B of old+new on equal footing - membership +churns while size stays fixed. A truly frozen basis (static Galerkin, no +expansion) would fail once the operator front leaves the initial support. +Paper framing: max_basis is the Pauli-basis analog of the MPS bond dimension +chi - fixed-chi TDVP practice, with drop_tol as the (inferior) singular- +value-cutoff analog. Convergence protocol: rerun at 2B, compare. + +## Follow-up: "cap + tau wins at 300k" was the drop confound (2026-07-04) + +Completed 2x2 at M300k (dt=0.025): + drop=1e-4 drop=1e-5 + K=0 (no tau): 1.40e-3 63.7s 459MB 5.53e-4 74.4s 430MB <- pure cap + K=1.25: 5.73e-4 58.3s 455MB 9.73e-4 69.5s 429MB +(and M100k: K0/1e-5: 9.79e-4 22s; K1.25/1e-5: 1.10e-3 23s; K1.25/1e-4: 7.22e-4 19s) +-> With the drop=1e-4 confound removed, PURE CAP ties the best cap+tau cell + (5.53e-4 vs 5.73e-4). All sane-threshold compositions at matched cap land + in the same ~5-11e-4 band (the ~2x cancellation-noise band); tau_add's + rel effect is noise-level, its wall effect ~10-25%. Hierarchy unchanged: + cap primary; tau_add = modest wall trim; avoid mid-range drop (1e-4-ish) + with a binding cap UNLESS paired with tau_add (the bad cell is + specifically K0 + drop=1e-4: prune deletes candidates the cap would keep, + and nothing pre-filters the transient either). + +## CONCLUDING SUMMARY: truncation schemes (2026-07-04, user-approved framing) + +How the threshold scheme works: two rules at different pipeline points. +The admission filter (add_tol = tau_add = K*drop_tol/dt, PPVM_K_LEAKAGE) +gates the LEAKAGE stage - newly proposed strings enter only if their +coefficient rate exceeds tau_add, keeping the doubly-enriched transient +lean. The pruning filter (drop_tol) deletes retained strings whose +coefficients fall below it after each step. The knobs decouple: error rises +slowly (~tau_add^0.24) up to a sharp cliff at tau_add ~ 0.04 that is +INDEPENDENT of dt and drop_tol (tau_add, not K, is the natural parameter); +drop_tol controls the retained tail and hence basis size. Both are indirect +ways of setting one quantity - error and cost collapse (~30%) onto a single +function of peak basis size. + +Why max_basis supersedes it: at MATCHED basis size the rank cap (keep-top-B +over retained+proposed, membership still churning every step - the fixed- +bond-dimension TDVP analog) yields 2-5x lower error than any threshold +combination, and bounds the transient RAM thresholds never touch. Best cap +cells beat best threshold cells on every axis simultaneously. Thresholds on +top of the cap trim wall by only ~10-25%, their accuracy effect is within +the cancellation-noise band, and mid-range drop_tol with a binding cap can +actively hurt. RECOMMENDATION: run with max_basis alone (thresholds ~0); +B is the single convergence dial, verified by re-running at 2B. + +## Correction to the MPS analogy (2026-07-04, user) + +"Fixed-chi TDVP" was imprecise: the cap-primary scheme grows the basis +freely from the seed until it reaches B, then saturates (peak == cap) with +churning membership. The right analogy is two-site TDVP/TEBD with a MAXIMUM +bond dimension and negligible singular-value cutoff (B = chi_max), not +fixed-chi single-site TDVP (a fixed manifold, no growth). Notes updated +(sec:rank-cap). + +## Real-space MSD: Trotter vs CAP-PRIMARY pec (2026-07-04, final comparison) + +pec grid, pure cap (drop=1e-5, K=0), dt x B: + dt B median_rel wall_s RSS + 0.1 10k 7.65e-3 0.3 163MB + 0.1 30k 2.49e-2 0.8 218MB <- cancellation outlier + 0.1 100k 5.23e-3 3.1 250MB + 0.1 300k 2.66e-3 10.5 428MB + 0.05 10k 4.68e-2 0.6 179MB + 0.05 30k 1.22e-2 1.7 223MB + 0.05 100k 1.50e-3 5.6 283MB <- sweet spot + 0.05 300k 8.58e-4 18.7 428MB + 0.025 10k 1.37e-2 1.0 170MB + 0.025 30k 1.02e-2 3.1 233MB + 0.025 100k 9.79e-4 22.0 266MB + 0.025 300k 5.53e-4 74.4 430MB +Cap mode moves pec's dt optimum from 0.1 (threshold mode) to 0.05. + +FIXED-PRECISION HEAD-TO-HEAD vs Trotter (best measured per class): + ~1e-2: pec 0.1/M10k 7.7e-3 0.3s 163MB | trot 0.025/1e-4 7.9e-3 13.7s 134MB + -> pec 46x faster at ~RAM parity (both near python baseline) + ~2e-3: pec 0.05/M100k 1.50e-3 5.6s 283MB | trot 0.025/3e-5 2.07e-3 209s 415MB + -> pec 37x faster AND 1.5x lighter, better rel + <1e-3: pec only: 8.6e-4 @ 18.7s/428MB; 5.5e-4 @ 74s/430MB +THE SPLIT VERDICT DISSOLVES: with max_basis as the knob, pec dominates +Trotter in real space on wall at every precision (37-46x) and reaches RAM +parity-or-better; Trotter's former RAM edge was an artifact of comparing +against threshold-tuned pec (whose transient the thresholds never bounded). + +## CORRECTION: "pure cap" cells were drop=1e-5, and that matters (2026-07-04, user) + +True drop=0 cells (K=0), vs the mislabeled "pure cap" (drop=1e-5) cells: + dt=0.025 M100k: drop0 5.90e-3 10.3s | 1e-5: 9.79e-4 22.0s (6x worse) + dt=0.025 M300k: drop0 1.50e-3 34.5s | 1e-5: 5.53e-4 74.4s (2.7x) + dt=0.05 M100k: drop0 2.64e-3 5.3s | 1e-5: 1.50e-3 5.6s (1.8x) + dt=0.05 M300k: drop0 1.35e-3 17.6s | 1e-5: 8.58e-4 18.7s (1.6x) +Consistent across 4 cells -> real effect, not cancellation noise. With +drop=0 the peak sits exactly at B (no clearing); with 1e-5 slightly under. +Hypothesized mechanism: a tiny prune clears stagnant near-zero weight from +the retained set, freeing rank slots for freshly proposed strings; with +drop=0 a zombie tail occupies the bottom of the top-B. drop=0 is somewhat +faster (up to 2x at dt=0.025) but strictly Pareto-dominated on accuracy. +REVISED RECOMMENDATION: cap + a pruning threshold FAR BELOW the working +coefficient scale (e.g. 1e-5 here) - "near zero", not exactly zero. +Cap-primary comparison table (previous section) used the 1e-5 cells and is +unaffected; only the "pure cap" label was wrong. + +## drop sweep at fixed cap: 0 / 1e-10 / 1e-6 / 1e-5 / 1e-4 (2026-07-04) + +dt=0.025 M100k: 0: 5.90e-3 | 1e-10: 5.22e-3 | 1e-6: 6.72e-4 | 1e-5: 9.79e-4 | 1e-4: 2.65e-3 +dt=0.025 M300k: 0: 1.50e-3 | 1e-10: 1.85e-3 | 1e-6: 2.57e-3 | 1e-5: 5.53e-4 | 1e-4: 1.40e-3 +dt=0.05 M100k: 0: 2.64e-3 | 1e-10: 4.53e-3 | 1e-6: 2.48e-3 | 1e-5: 1.50e-3 +REVISION of the previous section's claim: +- ROBUST: 1e-10 == 0 in every respect (peak pinned at B; similar rel): the + beneficial prune must act at a scale comparable to the bottom-of-cap + coefficients, not at numerical dust. drop in [1e-6,1e-5] is never worse + than 0 beyond noise and sometimes much better. +- CLEAN at M100k/dt=0.025: {0,1e-10} ~5-6e-3 vs {1e-6,1e-5} ~0.7-1e-3 + (5-8x, beyond noise). MUDDY at M300k: non-monotonic (1e-6 worse than 0, + 1e-5 best), spread ~4.7x ~ noise x2 - the "consistent 1.6-6x" claim was + overconfident there. +- Recommendation unchanged in practice (small nonzero prune ~1e-6..1e-5), + but the strength of the effect is cap-size- and cell-dependent; the + zombie-clearing mechanism is supported at B=100k and unproven at B=300k. + +## MECHANISM RESOLVED: the cap acts at ADMISSION; drop=0 freezes the basis (2026-07-04) + +Code (lib.rs): add_leakage_capped admits new strings only into +room = max_basis - len(basis), top leakage magnitudes first; retained +strings are NEVER displaced (cap_basis is a no-op in the normal flow since +admission never overfills). Therefore: +- drop ~ 0 (incl. 1e-10): basis fills to exactly B early, room -> 0, + NO admission ever again -> FROZEN static basis for the rest of the run. + Explains: peak == B exactly, lower wall (enrichment idle), and the ~9x + accuracy loss (5.9e-3 vs 6.7e-4 at B=100k) - static-Galerkin error. +- drop in [1e-6, 1e-5]: the prune clears the low-weight tail each step, + opening room; admission refills with the best candidates -> membership + churns at fixed size. This is the actual algorithm behind all good cap + cells. The prune is the CHURN VALVE, not "hygiene". +- drop=1e-4: valve too wide, deletes strings still carrying weight. +This supersedes the "zombie-clearing" hypothesis and explains the entire +drop-sweep table, including the wall differences. Notes sec:rank-cap +corrected (the previous "top-B of union / displacement" description was +factually wrong about the implementation). +POSSIBLE CODE IMPROVEMENT (future): true top-B-of-union displacement +(rank competition between retained and proposed) would remove the need for +the pruning threshold entirely - worth implementing and testing. + +## Top-B-of-union displacement scheme: implemented, NEGATIVE result (2026-07-04) + +Implemented admit_basis (A) on pc_step/pc_step_arr: enrichment may grow the +working set to A >= B; the final cap_basis (now live) keeps the top-B by +evolved |coeff| over the whole union - genuine rank displacement, drop_tol +no longer needed for turnover. Backward compatible (admit_basis=None = old +behaviour; regression cell M100k/1e-5 reproduces 9.79e-4 exactly). Tests pass. + +Results (dt=0.025, drop=0): + B=100k: A=1.25B: 3.74e-3 31s | A=2B: 2.96e-3 46s | A=4B: 1.38e-3 89s + vs valve (cap + drop 1e-5): 9.79e-4 23s/288MB <- VALVE WINS + B=300k: A=2B: 5.51e-4 157s/583MB vs valve 5.53e-4 74s/430MB <- tie, 2x wall + dt=0.05 B=100k: A=2B: 1.99e-3 21s vs valve 1.50e-3 5.6s +Monotone improvement with A (converging toward the valve result from above). + +MECHANISM HYPOTHESIS (boundary-layer cycling): the union scheme deletes +A-B strings EVERY step, most of them front-layer strings that get +re-admitted next step, re-accumulate a small coefficient, and are deleted +again - each cycle discards accumulated weight (repeated coherent error +injection) and wastes work. The valve scheme's turnover (~1e2/step at these +knobs) makes near-permanent swaps instead. Lesson: LOW-churn evolution +accumulates less truncation error than aggressive per-step re-selection; +the 2TDVP analogy breaks because Pauli-dictionary "Schmidt vectors" can't +rotate - discrete swap-in/out has a per-swap error cost that SVD rotation +does not. The valve (cap + small drop) stays the recommended scheme. + +## Late-time / max-rel analysis: valve vs displacement (2026-07-04, user suspicion CONFIRMED) + +max pointwise rel over t=0.2..2.0 (same cells as previous section): + 0.025 100k valve 1e-5: median 9.8e-4 MAX 3.80e-2 @t=2.0 (profile: 1e-5-ish early, then 9e-3,2e-2,3e-2,4e-2) + 0.025 100k disp A=1.25B: median 3.7e-3 MAX 1.13e-2 @t=2.0 + 0.025 100k disp A=2B: median 3.0e-3 MAX 8.1e-3 @t=1.6 + 0.025 100k disp A=4B: median 1.4e-3 MAX 6.3e-3 @t=1.4 (rel(T)=1.0e-3) + 0.025 100k frozen: median 5.9e-3 MAX 5.3e-1 @t=2.0 (catastrophic) + 0.025 300k valve: median 5.5e-4 MAX 1.05e-2 @t=2.0 + 0.025 300k disp A=2B: median 5.5e-4 MAX 1.30e-2 @t=2.0 (tie) + 0.05 100k valve: median 1.5e-3 MAX 3.04e-2 @t=1.8 + 0.05 100k disp A=2B: median 2.0e-3 MAX 1.20e-2 @t=1.8 (2.5x better) + +READING: the valve's low churn (~1e2 swaps/step) is TOO SLOW to track the +operator front at late times - it is quasi-frozen on the front's timescale, +and its error diverges toward t=T (mini version of the frozen catastrophe; +bigger B delays it - B=300k valve stays ok). The displacement scheme +refreshes the basis every step and holds a flat late-time error (max 3-6x +better than the valve at B=100k), at the cost of a small early/mid plateau +(~2e-3, the cycling injection) and ~2x wall. +VERDICT IS METRIC-DEPENDENT: + median rel (whole-curve typical) -> valve wins; + max rel / late-time (what matters for transport-coefficient extraction + from late-time MSD slopes) -> displacement wins at B=100k, ties at 300k. +Neither scheme dominates. For D(gamma) production runs (late-time slopes), +prefer displacement (admit_basis ~ 2-4x B, drop=0) or valve with generous B. + +## T=10 comparison, every-step reference (2026-07-05) + +Dense exact reference: exact_msd_L7_T10.npz (M=400, every dt=0.025 step, +12 min sector-reduced ED). Driver now takes SCAN_T env + max_rel column. +dt=0.025, metrics over ALL 400 points (t=0.025..10): + + scheme med_rel max_rel wall RSS + frozen B=100k 3.17e-1 5.98e-1 53s 238MB <- permanent ~0.3-0.5 plateau + valve B=100k 8.67e-3 4.09e-2 66s 279MB + disp A=2B B=100k 3.88e-3 1.44e-2 177s 341MB <- best B=100k + disp A=4B B=100k 4.67e-3 1.55e-2 340s 442MB + valve B=300k 5.95e-3 1.53e-2 302s 460MB + disp A=2B B=300k 5.71e-3 1.44e-2 669s 625MB + +Time-resolved picture (rel_vs_time_T10.png): the valve B=100k error PEAKS +~4e-2 at t~2 (the quasi-freeze episode seen in the T=2 study) and then +RECOVERS to the common ~5e-3-1e-2 saturation band - once the operator +equilibrates the slow churn suffices again; it does not keep diverging. +All adaptive schemes converge to that band; disp A=2B/B=100k has the lowest +long-time curve (~2-6e-3). Frozen never recovers. +T=10 VERDICT (B=100k): displacement wins BOTH median (2.2x) and max (2.8x) +at 2.7x wall; disp A=2B/B=100k even beats valve/B=300k on both error metrics +at 0.6x wall and 0.74x RSS. At B=300k valve==disp within noise, valve 2.2x +faster. A=4B adds nothing over A=2B at T=10. +Practical: for long-horizon runs at tight memory, displacement A=2B is the +best configuration measured; the valve's weakness is specifically the +transient regime around the equilibration time. + +## Frozen-basis B scan at T=10 (2026-07-05) + + frozen B=100k: median 3.17e-1 max 5.98e-1 53s 238MB + frozen B=200k: median 2.10e-1 max 3.30e-1 111s 296MB + frozen B=300k: median 3.87e-2 max 4.69e-1 180s 397MB +Raw curves (msd_raw_T10.png): B=100k undershoots the plateau (~2.4-2.9), +B=200k lands ~3.0-3.3, B=300k OVERSHOOTS through the wrap transient (peak +5.6 at t~2.3!) then wanders around ~3.7-4.1 with persistent oscillations +(max rel still 0.47; long-time median 3.9e-2 = 5-10x above any adaptive +scheme at a THIRD of the basis budget of valve/300k... equal budget). +Frozen error is NOT monotone-decreasing in B in the transient (300k's max +~ 100k's), and no frozen size equilibrates to the correct plateau cleanly. +Adaptivity - even the valve's ~1e2 swaps/step - is qualitatively load- +bearing; basis size cannot substitute for it. + +## Valve drop=1e-6 at T=10 + combined figure (2026-07-05) + + valve 1e-6 B=100k: median 1.39e-2 max 6.96e-2 59s 271MB + valve 1e-6 B=300k: median 7.67e-3 max 3.24e-2 207s 432MB +Both WORSE than valve 1e-5 at T=10 (8.67e-3/4.09e-2 and 5.95e-3/1.53e-2): +smaller drop = fewer swaps/step = deeper quasi-freeze through the wrap +transient. (At T=2 1e-6 had looked better - horizon-dependent ranking.) +The zoom panel (msd_combined_T10.png, 3.7-4.1) shows valve 1e-6 drifting +3.71-3.95 around the plateau out to t=10 while disp A=2B and valve 1e-5 +track the exact curve's fine wiggles at the ~1e-2 level. +Combined 3-panel figure (raw / plateau zoom / rel err): msd_combined_T10.png. + +## Threshold-only runs added to the T=10 comparison (2026-07-05) + + thresholds tau=0.02 (1e-4/K5): median 2.48e-2 max 4.19e-2 69s 519MB peak 142k + thresholds tau=0.01 (1e-4/K2.5): median 2.15e-2 max 3.29e-2 376s 1014MB peak 475k +(T=2 peaks were 82k/325k: uncapped bases DRIFT 1.5-1.7x by T=10.) +vs cap schemes at SMALLER bases: valve 1e-5/100k 8.7e-3/4.1e-2 @66s/279MB; +disp A=2B/100k 3.9e-3/1.4e-2 @177s/341MB -> thresholds are 3-6x worse on +median at 2-3x the RSS. +Time-resolved (msd_combined_T10.png): thresholds fail at BOTH ends - +(a) EARLY: rel ~1e-2 already at t<0.5 (the absolute-rate admission filter +bites hardest during the initial fast spread, where cap schemes are at +1e-5); (b) LATE: both threshold curves FLATLINE at a wrong plateau +(4.07/4.09 vs exact ~3.96-3.98) from t~4.5 on - the truncated dynamics +reaches a stagnant fixed point ~2-3% high, error frozen at 2-3e-2. +FIGURE msd_combined_T10.png is now the complete truncation-scheme taxonomy: +frozen (catastrophic), thresholds (uncontrolled size + early/late bias), +valve (transient episode, drop-sensitive), displacement (uniform tracking). + +## Is the T=10 saturation a dt artefact? (2026-07-05, user question) + +dt scan at fixed configs (T=10, B=100k): + disp A=2B: dt=0.05: 6.20e-3/1.54e-2 | 0.025: 3.88e-3/1.44e-2 | 0.0125: 1.86e-2/3.06e-2 + valve 1e-5: 0.025: 8.67e-3/4.09e-2 | 0.0125: 4.09e-3/2.76e-2 + valve 1e-5 B=300k, dt=0.0125: 5.38e-3/1.54e-2 (435s/459MB) +ANSWER: partially, and scheme-dependently. +1. VALVE was dt-limited at 0.025: halving dt halves its median (8.7->4.1e-3) + and shrinks its transient peak. Its churn/time also doubles (confounded + with dt error - drop is per-step). +2. DISPLACEMENT is ANTI-dt-limited: its injection is per-STEP (A-B strings + cycled each step), so error/time ~ 1/dt; interior optimum dt ~ 0.025. + The universal band at dt=0.025 was the two mechanisms crossing. +3. Once dt-unblocked, B still does NOT improve the valve median + (100k: 4.09e-3 vs 300k: 5.38e-3 at dt=0.0125) though max improves 1.8x. + A persistent post-transient offset ~4-6e-3 dominates the T=10 median: + damage done during the wrap transient persists in the equilibrated state, + and none of (B, A, drop, dt) measured so far pushes the median below + ~4e-3. OPEN: dt=0.00625 valve run would test whether the residual is + still dt-limited (confounded with churn/time doubling); alternatively + the transient-episode error itself must be attacked (displacement + at its optimal dt already does: max 1.44e-2). +Best T=10 cells so far: valve 1e-5/100k/dt=0.0125 (4.09e-3 med, 131s/276MB) +and disp A=2B/100k/dt=0.025 (3.88e-3 med, max 1.44e-2, 177s/341MB). + +## Displacement B=300k dt scan (2026-07-06) + + disp A=2B B=300k: dt=0.05: 4.70e-3/1.38e-2 280s | 0.025: 5.71e-3/1.44e-2 669s | 0.0125: 8.61e-3/1.78e-2 1048s +Anti-dt trend confirmed at 300k but much WEAKER than at 100k (x1.8 over the +dt range vs x5): the per-step cycling injection scales with the +boundary/basis ratio, so bigger B damps it. Displacement dt-optimum shifts +to LARGER dt at larger B (best 300k cell: dt=0.05, 4.70e-3 at 280s). +B-scaling remains weak in all schemes at T=10 (transient scar dominates). +Figure rel_vs_time_dtscan_T10.png updated with B=300k (dashed). + +## Late-time floor DECOMPOSED: revivals + scar (2026-07-06, user question) + +Exact plateau t>=4: mean 3.9738, range [3.9405, 4.0021]. A CONSTANT +prediction has median rel 1.84e-3, max 8.5e-3 - the size of the coherent +finite-size revival signal itself. +Scheme late-time errors: 4.0-5.6e-3 vs exact; 3.0-6.0e-3 vs the SMOOTHED +exact; correlation with the exact wiggles: -0.27..+0.28 ~ ZERO. +=> The late-time floor = (a) the revival component (~2e-3 median / ~8e-3 +max): coherent recurrences living in high-weight scrambled strings that NO +truncated basis retains at any feasible budget - knob-independent by +nature; + (b) a residual plateau bias ~3e-3 (the transient scar proper), +improvable only by surviving the wrap transient better. +CONSEQUENCES: (i) the apparent "non-convergence" at late times is mostly a +metric artifact - we were grading truncated methods on reproducing +revivals; (ii) for transport physics (slopes/plateaus, thermodynamic +limit) the relevant late-time accuracy is ~3e-3 and the revival component +should be excluded (smoothed reference) or pushed out by larger L; +(iii) this cleanly documents the method's fundamental scope: hydrodynamic +/ dissipative components YES, coherent recurrences NO - worth a sentence +in the paper's discussion. + +## Revival-carrier hypothesis TESTED with exact numerics at N=10 (2026-07-06) + +Setup (revival_test_N10.py): L=5 ladder, O(0)=center-rung Z pair, exact +evolution; full 4^10 Pauli decomposition (per-qubit transform, round-trip +verified to 3e-16, Parseval exact). +Coefficient structure at t0=2 (CONFIRMS the dust picture): + 260,830 strings nonzero; top-100 hold 21% of the norm; 90% needs + B=100k (=10% of all strings); kept mean Pauli weight 4.2-7.4 vs dust 7.5. +SINGLE cut at t0=2, then exact evolution (REFUTES the strong one-shot form): + B=1k: future wiggle corr +0.43, amplitude 20% + B=10k: corr +0.85, amplitude 43% + B=100k: corr +0.99, amplitude 94% <- one cut at 10%-budget keeps revivals +REPEATED cut every dt=0.1 (100 cuts), exact evolution between cuts +(IDENTIFIES the real mechanism): + B=10k: corr -0.14, amplitude 5% + B=100k: corr -0.09, amplitude 22% (uncorrelated -> noise, not signal) + and the late-time mean shifts to the exactly-uniform value (2.000/1.993 + vs exact 1.9386) - the flat, slightly-off plateau seen in production. +CONCLUSION: truncation acts as WEAK CONTINUOUS DEPHASING on the coherent +recurrence channel. Per-cut amplitude retention is high (~0.94-0.99 at +these budgets) but compounds over hundreds of steps to ~zero; the same +budget that survives one cut erases revivals under repetition. This also +explains part of the plateau bias (truncation pushes the profile toward +exactly-uniform, losing the coherent depression of the exact plateau). +Paper angle: implicit truncation-dephasing is the flip side of DAOE's +explicit artificial dissipation; hydrodynamics is immune (carried by few +large-coefficient strings re-selected every step), recurrences are not. + +## Trotter + truncation at T=10: same dephasing, plus its own wiggles (2026-07-06) + + trotter mac=1e-4 (peak 111k, STATIONARY == its T=2 peak): med 8.60e-3 max 3.08e-2 37s/134MB + trotter mac=3e-5 (peak 1.20M): med 3.88e-3 max 1.68e-2 623s/415MB +Late-window (t>=4) wiggle analysis vs exact (exact wiggle std 0.0141): + scheme late mean corr amp ratio + Trotter 1e-4 (111k) 4.0042 +0.60 1.92 <- OVERSHOOT: own coherent wiggles + Trotter 3e-5 (1.2M) 3.9932 -0.22 0.59 + valve B=100k 4.0058 +0.46 0.67 + disp A=2B B=100k 3.9889 +0.21 0.81 + valve B=300k 4.0067 +0.30 0.65 + disp A=2B B=300k 3.9984 +0.24 0.25 +(exact late mean 3.9738 - ALL schemes sit 0.4-0.8% high.) +ANSWER: yes - Trotter+truncation shows the same effective dephasing of the +true recurrences (no scheme tracks the exact dip structure, e.g. the t=5.7 +excursion to 3.945; at tight mac Trotter flattens exactly like the pec +schemes). TWIST: at loose mac Trotter additionally GENERATES its own +coherent oscillations (amplitude 1.9x the exact wiggles, partially phase- +aligned corr 0.6) - spurious revival-like artifacts from the Trotter+ +per-gate-truncation dynamics, absent in the exact-in-dt pec schemes. +REFINEMENT of the earlier corr~0 claim: correlations across schemes span +-0.2..+0.6 - the N=14 late-time signal contains a partially retrievable +low-weight coherent component on top of the erased scrambled part; the +dephasing strength ranks with discarded weight per event (disp most +aggressive: amp 0.25; valve intermediate; loose Trotter least, but noisy). +Figure: msd_trotter_overlay_T10.png. + +## Comparison restricted to t <= MSD maximum (t=1.225) (2026-07-06, user) + +Exact peak MSD 4.5080 at t=1.225. Median/max rel over t in [0.2, 1.225] +(walls/RSS are full-T=10 costs; the up-to-peak portion is ~12% of steps): + valve 1e-6 B=100k 3.5e-5 / 1.6e-3 59s 271MB <- best & cheapest pec + disp A=2B B=100k 3.6e-5 / 4.7e-3 177s 341MB + disp A=4B B=100k 4.5e-5 / 5.0e-3 340s 442MB + valve 1e-6 B=300k 6.4e-5 / 5.1e-3 207s 432MB + disp A=2B B=300k 6.7e-5 / 1.3e-3 669s 625MB + frozen B=300k 8.2e-5 / 2.1e-3 180s 397MB + frozen B=200k 9.6e-5 / 7.6e-3 111s 296MB + valve 1e-5 B=300k 1.1e-4 / 2.4e-3 302s 460MB + frozen B=100k 1.5e-4 / 8.1e-3 53s 238MB + valve 1e-5 B=100k 1.9e-4 / 1.7e-3 66s 279MB + thresholds tau=.01 1.1e-3 / 4.1e-3 376s 1014MB + Trotter mac=3e-5 1.4e-3 / 3.5e-3 623s 415MB + thresholds tau=.02 2.0e-3 / 1.2e-2 69s 519MB + Trotter mac=1e-4 2.7e-3 / 1.0e-2 37s 134MB +FINDINGS: +1. In the ballistic/transport window ALL cap-based pec variants (valve, + displacement, even frozen!) collapse to median 3.5e-5..1.9e-4 - the + scheme distinctions that consumed the T=10 analysis are IRRELEVANT + before the wrap; even freezing only starts to bite at the window edge. +2. pec is 10-80x more accurate than Trotter/thresholds here: Trotter is + floored by O(dt^2) splitting error during the fast ballistic dynamics + (1.4e-3 even at 1.2M strings); thresholds by early-time tau_add bias. +3. USER'S RED-HERRING CALL VALIDATED: the late-time scheme distinctions + were driven by finite-size recurrence physics outside the method's + scope. In the physically extractable window the story is simple: + cap-based CTPP at modest B dominates everything, and the cheapest cell + (valve 1e-6, B=100k: 3.5e-5 at 59s/271MB full-run cost) wins outright. +For production D extraction at large L (window entirely pre-wrap), this +is the regime that matters. + +## L=21 (N=42) MSD to the plateau: rough -> converged (2026-07-06) + +No exact reference; convergence by B/dt-doubling + cross-method agreement. +Driver: SCAN_L env. Physics: NO overshoot peak at L=21 - the MSD rises +monotonically to the uniform plateau 36.67 (reaches 36.47 by t=15; the +L=7-style coherent overshoot is a small-L effect). The 'peak' window is +the full rise t in [0, ~15]. +ROUGH PASS pitfalls (documented): B<=300k valve cells scatter WILDLY at +L=21 (non-monotone in B; pec100k collapsed to MSD~20 at t=6); loose +trotter (mac 1e-3, 10k strings) underestimates MSD by 40% - the ballistic +front carries the dj^2 weight and is exactly what truncation cuts. +CONVERGED (displacement A=2B, dt=0.1): B=150k vs 300k median 1.7e-3 +(max 7e-3) over the FULL window; dt 0.1 vs 0.05 median 1.3e-3. Trotter +ladder approaches the same curve from below (mac 3e-4 -> 1e-4: 28.4 -> +30.9 at t=6 vs disp 31.6). +FINAL TABLE (rel vs disp300k over [0.2,15]; wall/RSS for T=15 dt=0.1): + disp A=2B B=300k (reference) 482s 666MB + disp A=2B B=150k 1.7e-3 / 7.3e-3 145s 420MB <- converged, cheap + valve 1e-6 B=300k 3.5e-3 / 1.2e-1(!) 156s 443MB <- erratic late excursions + Trotter mac=1e-4 2.5e-2 / 3.8e-2 75s 344MB <- 2.5% LOW (front loss), + saturates at 35.09 vs 36.47; mac-convergence is x8 basis / x10 wall per + x2 error - reaching 2e-3 would need ~1e-5..3e-6 mac = 10-100M strings, + INFEASIBLE. At N=42 CTPP(displacement) converges where Trotter cannot. +Figure: msd_L21_T15.png. + +## L=21 Trotter dt x mac ladder, t<=6, vs converged displacement (2026-07-06) + + run med_rel max_rel MSD(6) wall rss~ peak + trot 0.1/3e-4 5.3e-2 1.0e-1 28.37 11s 143MB 102k + trot 0.1/1e-4 1.4e-2 2.3e-2 30.91 151s 344MB 854k + trot 0.05/3e-4 6.4e-2 1.5e-1 30.67 8s 120MB 40k + trot 0.05/1e-4 3.8e-2 7.4e-2 30.16 67s 187MB 343k + trot 0.05/3e-5 7.4e-3 1.4e-2 31.36 1175s 1.1GB 3.67M <- best trotter + trot 0.025/1e-4 7.9e-2 1.4e-1 28.45 62s 140MB 115k + disp B=150k (check) 2.8e-3 7.3e-3 31.56 78s 375MB 150k + (reference: disp B=300k; 0.025/3e-5 + 0.025/1e-5 cells pending/partial) +FINDINGS: +1. At fixed mac, SMALLER dt is WORSE for Trotter (0.025/1e-4 = 7.9e-2 vs + 0.1/1e-4 = 1.4e-2; basis shrinks 854k->115k from per-gate culling) - + the joint dt<->mac coupling at L=21 scale. Only mac-tightening converges. +2. Best Trotter rung (0.05/3e-5, 3.67M strings, 20 min, 1.1GB) is still + 2.6x LESS accurate than the CHEAP displacement run (150k strings, 78s, + 375MB) - a 15x wall / 3x RAM / 24x string handicap AND worse error. +3. Extrapolating the ladder (x10 wall per ~x2 error): matching disp-150k's + 2.8e-3 needs mac ~1e-5..3e-6 at dt~0.05 = 10-40M strings / hours / + several GB. Trotter mac-convergence at N=42 is impractical; the + displacement-CTPP advantage at scale is decisive on every axis. +Figure: msd_L21_trotter_ladder.png. + +## L=41 (N=82) vs external Pauli-propagation reference (2026-07-06) + +User-provided reference figure: MSD vs time, truncation ladder 2^-13..2^-18 +(PauliPropagation-style, RvKP XX ladder; their time axis = 4x ours). Ran +disp A=2B at L=41, T=5 (our units), dt=0.1: + B=150k: 100s / 393MB ; B=300k: 209s / 650MB + B-convergence: median 8.5e-3, max 1.8e-2 (150k vs 300k) - slightly looser + than at L=21 (front support is 2x wider), fine at digitization accuracy. +Comparison at digitized brown (2^-18) points (t_theirs/4): + t=2.0: 12.76 vs 12.6 (+1.3%) | t=2.5: 16.43 vs 16.5 (-0.4%) + t=3.0: 20.14 vs 21.0 (-4.1%) | t=3.75: 26.11 vs 26.0 (+0.4%) + t=4.0: 27.60 vs 28.0 (-1.4%) | t=5.0: 35.25 vs 35.0 (+0.7%) + (t<1.5 points off by 6-11% - digitization error dominates there, small + values on a coarse plot.) +=> Agreement within ~1-4% everywhere = digitization accuracy. EXTERNAL +CROSS-VALIDATION of the 82-qubit displacement-CTPP result against an +independent code/method, at 209s/650MB on a laptop. The L=21 curve peels +off at t>3 exactly as finite size predicts (fronts wrap); L=41 tracks the +unconstrained growth to t=5. Time-convention factor 4 confirmed by the +match itself. Figure: msd_L41_vs_reference.png. + +## L=41 external comparison FINALIZED with accurately digitized data (2026-07-06) + +History: my eyeballed digitization was wrong; the user's accurate data +first appeared mismatched (ours ~25% low) - a free one-parameter time- +rescale fit found lambda=1.252, and the user then identified the cause: +their exported x-axis needed *5/4 (fit matched the correction exactly, +a good pipeline sanity check). Corrected comparison (62 ref points): + disp B=300k vs 2^-18: median 5.5e-3 max 1.7e-2 ; endpoint (t=4.95): + 34.86 vs 34.63 (+0.7%) + disp B=150k vs 2^-18: median 1.5e-2 (slightly low at late t) + disp B=300k vs 2^-17: median 1.1e-2, growing to 3-7e-2 at t>4 - the + reference's own truncation ladder bending down, as expected. +=> 82-qubit displacement-CTPP (209s/650MB laptop) agrees with the external +Pauli-propagation 2^-18 curve at the ~0.5% level across the full window, +and the deviation vs their looser 2^-17 curve has the right sign/shape. +Strong independent cross-validation. reference_digitized_L41.py holds the +data; figure msd_L41_vs_reference.png. + +## Reference identified + their resources (2026-07-06) + +The external reference = Begusic & Chan, PRX Quantum 6, 020302 (2025) +"Real-Time Operator Evolution in 2D and 3D via Sparse Pauli Dynamics", +Fig. 2(c): XX-ladder [their Eq. (17), H = (1/4)*ours -> t_theirs = 4*t_ours, +confirming our conversion], L=41, n=82, q_j=(Z_j1+Z_j2)/2 - IDENTICAL setup. +Their D ~ 0.94 (their units) from regression t in [10,20] (= ours [2.5,5]). +THEIR COST for the 2^-18 curve (p.4): "the two points in Fig. 2(d) at fixed +delta/dt = 2^-18/0.02 take around 84 h (dt=0.01) and 43 h (dt=0.02) to +simulate on six cores" (Xeon Platinum 8352Y 2.2GHz). RAM for Fig 2 not +stated; their stated memory elsewhere: 2D delta=2^-23: 8.5e9 Pauli ops, +">1 TB", 36h/16 cores; 3D: "memory budget of about 1.5 TB" at <1e9 ops +(~125 B/op storage). Their ladder 2^-18 run's N is not given (bound +N <= c0/delta^2 ~ 3.4e10; plausibly 1e8-1e9 ops -> tens of GB, estimate). +OURS at matched accuracy (0.5% median agreement): disp A=2B B=300k, +dt=0.1 (our units; 20x coarser step than theirs - exact-in-dt makes this +possible), 209 s / 650 MB on a laptop. +=> wall ratio ~740x (43h vs 209s); core-hours ~445x (258 vs 0.58); +RAM: 650 MB vs (unstated, plausibly 10s of GB). Their delta/dt coupling +(their Sec II: error is a function of delta/dt) forces small dt AND large +N; CTPP's dt-freedom is the structural advantage on display. + +## L=21 Trotter deep rungs completed (2026-07-06, background cells landed) + + trot 0.025/3e-5: median 1.51e-2 max 2.7e-2 MSD(6)=31.18 1385s 350MB 1.25M strings + trot 0.025/1e-5: median 2.86e-3 max 8.9e-3 MSD(6)=31.35 14845s 2.0GB 11.08M strings +The 1e-5 rung FINALLY matches disp B=150k accuracy (2.8e-3): the +extrapolated cost was right. MATCHED-ACCURACY HEADLINE AT L=21 (t<=6): + CTPP-displacement: 78 s, 150k strings, ~0.4 GB + Trotter (2nd-order): 4.1 h, 11.1M strings, 2.0 GB + -> 190x wall, 74x strings, ~5x RAM at equal accuracy. +Also note trot 0.025/3e-5 (1.5e-2) is WORSE than 0.05/3e-5 (7.4e-3): +the dt-mac coupling again - halving dt at fixed mac degrades. + +## A = K*B scan at L=41, B=300k, T=5 (2026-07-07) + + K=1: 29s 480MB med 5.9e-2 vs K=4 MSD(5)=20.50 <- FROZEN (no headroom + with drop=0: admission stops at fill; curve bends over at t~2.5) + K=1.25 50s 519MB 1.8e-2 33.51 + K=1.5 67s 548MB 9.4e-3 34.78 + K=2 209s 650MB 3.5e-3 35.25 (yesterday's run; wall possibly inflated) + K=3 128s 806MB 4.4e-4 35.42 <- converged (vs K=4) + K=4 168s 1041MB anchor 35.46 +Agreement vs Begusic-Chan 2^-18 saturates ~5e-3 for K>=2 (digitization/their +truncation limited). RSS ~ linear in A; wall non-monotone (K=3 cheaper than +K=2). RECIPE UPDATE for large L: A=3B preferred (10x tighter than 2B at ~no +wall cost, +25% RAM); A=2B when RAM-bound; A=B is the frozen limit - never. +Figure: msd_L41_Kscan.png. + +## A = K*B scan at L=41, B=150k (2026-07-07) + + K=1: 13s 292MB med 3.3e-1 vs K=4 MSD(5)= 9.48 <- frozen, collapses even + earlier than at 300k (fills sooner) + K=1.25 22s 324MB 3.5e-2 33.50 + K=1.5 27s 327MB 2.3e-2 33.48 + K=2 100s 393MB 7.3e-3 34.62 (2026-07-06 wall; possibly inflated) + K=3 52s 487MB 1.3e-3 35.38 <- converged in K + K=4 66s 580MB anchor 35.55 +Residual B-bias (150k/K=4 vs 300k/K=4): median 2.3e-3, max 5.3e-3 - the +irreducible B=150k error once A is converged; the K and B errors are +roughly additive. Same structure as B=300k: frozen at K=1, converged at +K=3, wall non-monotone (K=3 cheaper than K=2). BEST CHEAP CELL at L=41: +B=150k/A=3B = 52s / 487MB / med ~2-3e-3 total. +Figure: msd_L41_Kscan_B150k.png. + +## Repo cleanup (2026-07-07) + +1. admit_basis (displacement) added to pc_step_orbit_rep + binding + python + wrapper - the winning scheme is now available in momentum space (the k=1 + comparison should be re-run cap-primary). Verified: L=5 momentum sanity + reproduces 1.86e-3 exactly; displacement smoke holds cap under admit=2B. +2. PPVM_EXPM_STREAM streaming paths REMOVED (MfOp, per_col_norms, + StreamOrbitOp, per_col_orbit_stream; ~200 lines): measured useless in + both spaces (real: 0-13% RAM for 4-5x wall; momentum: -5% for 24x). +3. rk4_step / rk4_step_arr marked deprecated (drop-only truncation, weakest + scheme; kept for --mode rk4). +4. xy-experiments: README rewritten to the current script set (7 stale + entries removed), trotter_ladder.py tracked, lockfile committed. +pc_step_complex intentionally KEPT (not deprecated) - see handoff. +Real-space regression after streaming removal: M100k/1e-5 T=10 cell +reproduces 8.67e-3/4.09e-2 bit-identically. + +## Full K scan to K=10, both B (2026-07-07) + + B=150k: K=1.25..10: 3.9e-2, 2.7e-2, 1.2e-2, 1.7e-3, 4.0e-3, 4.7e-3, + 1.7e-3, 4.0e-3 (vs 300k/K10 anchor); plateaus at the B-bias floor + (~2.3e-3) from K=3 on, fluctuating within the ~2x noise band. + Walls 22->203s, RSS 324MB->1.24GB (linear in A). + B=300k: 1.8e-2, 9.4e-3, 2.9e-3, 1.3e-3, 5.9e-4, 1.7e-3, 2.8e-4, anchor; + post-K=3 values are sub-noise scatter (note K=5 bump in BOTH + families - cancellation noise). Walls 50->705s, RSS 519MB->2.4GB. +VERDICT: K=3 is simultaneously the convergence onset and the cost optimum; +beyond it cost grows linearly in A for noise-level changes. Improve via B, +not A. Figure msd_L41_Kscan_full.png. + +## pc_step_complex removed (2026-07-07, user request) + +Production fn + inner + expm_step_complex + PyO3 binding + python wrapper +deleted. Its remaining role (the full-space complex reference bridge in the +orbit-rep equivalence tests) is preserved by a test-local helper +`pc_step_complex_full` (untruncated two-hop enrichment + exact in-basis +complex exponential via expm_apply_mf_cxvec). The two sector-check feature +tests were removed with the feature; the k=0 real/complex equivalence test +and the orbit-vs-full projection test both pass. Building blocks kept: +leakage_complex, compute_action_sum_complex, expm_apply_mf_cxvec. +L=5 momentum exact-ED sanity still 1.86e-3 bit-identical. + +## rk4_step removed (2026-07-07) + +Fully removed (was only doc-deprecated): rk4_step + rk4_step_inner, +PyO3 binding, rk4_step_arr wrapper, and the --mode rk4 harness branch in +xy-experiments/main_realspace_ladder.py. compute_action_sum (the L*-apply +primitive it used) is retained. Tests 7/7; harness smoke reproduces the +recorded dt=0.05/M100k cell bit-identically. The crate now exposes exactly +two step functions: pc_step (real) and pc_step_orbit_rep (momentum), with +the unified (max_basis, admit_basis, drop_tol) truncation API. + +## K=2 walls re-measured on idle machine (2026-07-07) + + B=150k/K=2: 37.8s/390MB (was 100s - contended on 07-06) + B=300k/K=2: 131.2s/650MB (was 209s - contended) +Wall is now monotone-ish linear in A across both families; the earlier +"K=3 faster than K=2" was contention. Cost statement: K=3 ~ same wall as +K=2, converged -> default A=3B unchanged. All other same-day walls stand. + +## tau_add promoted to a first-class argument; PPVM_K_LEAKAGE removed (2026-07-07) + +Both step functions now take `tau_add: Option` directly (the natural, +dt/drop-independent parameterization established by the cliff study); +k_leakage() and the env var are gone. Binding + python wrappers updated; +harnesses (main_realspace_ladder --tau_add, k_pec_run/main_k_pec_* +tau_add + admit_basis args); scan_realspace_msd converts K tokens to +--tau_add = K*drop/dt at launch, preserving old cell semantics exactly. +pc_step_timed runs without the filter (documented). REGRESSION: the +historical K=1 cell (0.1/1e-3, L=7 T=2) reproduces median 5.19e-3 / +peak 77,820 bit-identically through the new path; L=5 momentum sanity +1.86e-3 unchanged; tests 7/7. +Final public truncation API, both spaces: + (max_basis, admit_basis=None, drop_tol, tau_add=None) + protected. +Zero env-var numerics knobs remain. + +## B=600k baseline at L=41 (2026-07-07) + + 600k/K=2: 245s/1.16GB med 1.57e-3 vs 600k/K3 MSD(5)=35.230 + 600k/K=3: 462s/1.55GB (new anchor) MSD(5)=35.445 +Rebased ladder (vs 600k/K3, t in [0.2,5]): + 150k/K3: 1.13e-3 (52s/487MB) MSD(5)=35.380 + 300k/K3: 5.88e-4 (128s/806MB) MSD(5)=35.420 + 300k/K7: 1.13e-3 (494s/1.9GB) - within its B-bias band, K>3 buys nothing +FINDINGS: +1. B-convergence is cleanly first-order: bias halves per B-doubling + (1.13e-3 -> 5.9e-4, ratio 1.9). Extrapolated residual of the 600k anchor + ~3e-4; Richardson limit MSD(5) ~ 35.47(2). +2. 600k/K=2 (1.57e-3) is WORSE than 300k/K=3 (5.9e-4) at 2x the cost: + under-admission (K=2) costs more than B-doubling gains. A=3B confirmed + at all three B. +3. vs Begusic-Chan 2^-18: anchor median 6.3e-3 - unchanged within + digitization scatter; that comparison is digitization-limited ~5-6e-3. + +## Fixed-A (fixed RAM) K/B split test, A=1.2M, L=41 (2026-07-07) + + B=300k K=4: 1041MB median 4.75e-4 + B=400k K=3: 1164MB median 2.69e-3 <- OUTLIER (should be best, isn't) + B=600k K=2: 1162MB median 1.57e-3 +HONEST RESULT: NON-MONOTONE in both B and K -> we are in the cancellation- +NOISE FLOOR at this accuracy (~5e-4..3e-3, vs an anchor with ~3e-4 residual). +Single-cell median-rel differences below ~3x are NOT meaningful here. So the +fixed-A test does NOT resolve the (B,K) split, and my earlier "increase B at +K=3 traces the Pareto frontier / K=4 on the frontier" claims OVER-READ this +noise. What IS robust across all data: + - K<3 measurably worse (under-admission): K=1 frozen, K=1.25-1.5 ~1-4e-2, + K=2 ~3e-3-1.6e-2 (B-dependent). Need K>=3. + - B-ladder AT K=3: 150k 1.1e-3 -> 300k 5.9e-4 -> 600k anchor: monotone, + ~first-order, the one clean accuracy trend. + - K>=3 at fixed B, or (B,K) split at fixed A: within noise, unresolved. +RAM ARGUMENT (theory, not from noisy cells): RAM ~ A = K*B. Beyond the K~3 +knee, extra admission is provably neutral in the large-A limit (admitted- +then-truncated strings can't change the kept set), while B keeps improving. +So spend RAM on B, keep K just at the knee (~3). The cells can't PROVE the +fixed-RAM optimum at this accuracy, but the K-neutrality argument + the clean +B-ladder both point the same way. To resolve empirically would need seed/ +observable averaging to beat down the ~2-3x cancellation noise. + +## Branch triage (2026-07-07) + +The two dangling worktree-agent branches were inspected before deletion — +both turned out to be FULLY TRIAGED already, with proper merge-then-revert +bookkeeping in the campaign and load-robust verdicts in their ledgers: +- parallel map_insert (3d5f0409): cherry-picked as 15f96554, final verdict + DISCARD (1.1x wall for +50% RAM on a quiet machine; see + 2026-07-02-trotter-ladder for the triple-measurement saga + process + lesson), reverted as 9ee8b3ba. +- leakage2 action-cache reuse (191e92aa): cherry-picked as 2411240f, verdict + DISCARD (2.3x RSS - load-independent - for a wall gain bounded by the 17% + leakage share; see 2026-07-02-expm-ladder), reverted as 42a5fa9c. Holds + a fortiori under displacement (A=2-3B working sets). +Both branch refs deleted; content preserved in campaign history + ledgers. +Remaining branch state: autotune/ladder-tuning = the campaign superset +(contains expm-pc-step, expm-memory, symmetry-merging); local main is 50 +behind origin/main -> upstream sync is the outstanding git task before PR. + +## Branch triage part 2: perf/mimalloc-allocator + lindblad-shim (2026-07-07) + +perf/mimalloc-allocator (June 17, 1 commit): PR #129 CLOSED unmerged; the +campaign independently carries the measured version (53f7ff29: mimalloc + +chunked leakage -> ~50% peak RSS cut) and current code has mimalloc in +Cargo.toml. Superseded -> local ref DELETED (remote cleanup optional). + +lindblad-shim (June 17, 5 unique commits vs campaign): PR #98 ("Adaptive +Pauli-Lindbladian shim + adaptive-evolution demo") is still OPEN; the +campaign branched off mid-review and is now 162 commits ahead - the de +facto successor. Its unique commits: (a) b07f75de MAINTAINER-REQUESTED API +changes: PcStepConfig struct bundling the step knobs (replacing the long +positional list + clippy allow) and an error.rs extraction - the campaign +never adopted this and has since grown MORE positional args (max_basis, +admit_basis, drop_tol, tau_add, num_threads). The same review feedback +will return on the campaign PR; (b) f14edafc drops mimalloc - CONFLICTS +with the campaign's measured keep (campaign wins, it has data); +(c) expm-engine removals done differently/superseded by mf_expm evolution. +DISPOSITION: keep the branch while PR #98 is open. TODO before the campaign +PR: port the PcStepConfig + error.rs pattern onto the current API (even +more justified now with 5 knobs), then close #98 as superseded. + +## Why momentum is SLOW: orbit-rep per-term canonicalization tax (2026-07-07) + +User asked why momentum-space MSD is so slow when the orbit basis is |G|x +SMALLER than real space. Microbenchmark (matched basis B=20-30k, one pc_step): + real-space L=11: 5.6 us/term (flat in L) + momentum L=6 |G|=12: 30.2 us/term + momentum L=11 |G|=22: 66.4 us/term (13.5x real space) + momentum L=16 |G|=32: 142.5 us/term +Per-term cost grows ~|G|*N: the culprit is group.canonicalize_with_shift(q) +in build_orbit_rep_cols (orbit_rep.rs:95) and leakage_orbit_rep (:381) - +called for EVERY action term of EVERY rep, every build (2x/step), scanning +all |G|=2L translations (each an O(N) word compare) to map each generated +term back to its canonical rep. Real space has no canonicalization. + +IMPLICATION (the real answer): for a FULL MSD you need all |G| momentum +modes. Converging one k-mode needs ~B_real/|G| terms (the compression), so + total momentum work = |G| modes x (B_real/|G|) terms x c_orbit + = B_real x c_orbit ~= 13.5x x (real-space work). +=> reconstructing a real-space quantity (MSD) via all momentum modes is +NET SLOWER than doing it in real space, by the per-term tax (13.5x at L=11, +growing with L). Momentum space is the WRONG tool for full MSD. +Momentum WINS for: (a) MEMORY - |G|x fewer terms/mode, modes done serially; +(b) SINGLE k-resolved quantities (one transport channel, one RP resonance, +D(k) at one k) - there you evolve B_real/|G| terms at 13.5x/term = net +~1.6x FASTER than a full real-space run AND |G|x less RAM. That is the +method's actual niche, not real-space-observable reconstruction. + +OPTIMIZATION OPPORTUNITY: the canonicalization map (rep->canonical-rep,shift) +is FIXED across steps and across the 2 expm calls for the stable part of the +basis; it is recomputed every build. Caching it (keyed by rep Word) would +drop the orbit per-term cost toward the real-space ~6 us, reviving the +compression as a genuine WALL advantage for single-mode runs. Also +canonicalize_with_shift itself is O(|G|*N); minimal-rotation tricks could +make it ~O(N). Filed for a future optimization session. +CONSEQUENCE FOR THE MOMENTUM MSD COMPARISON: not pursued to completion - +full-MSD-in-momentum is the wrong benchmark. The right momentum benchmark +is single-k D(k) vs Trotter, where the compression pays off. + +## D extraction protocol + dt=0.2 B-convergence (2026-07-08) + +Begusic-Chan D extraction (from their process.ipynb, confirmed): + D = linregress(t[-51:], MSD[-51:]).slope/2, i.e. a single linear fit over + t_theirs in [10,20] = t_ours [2.5,5], slope/2; then delta/dt->0 extrapolation + across thresholds (Fig 2d). IDENTICAL to our scalar-D protocol; our recomputed + per-threshold D match theirs exactly (2^-17/-18/-19 -> 3.287/3.515/3.661 ours). + Our windowed D(t) (fig06) is the LOCAL-slope version of the same estimator. + +dt=0.2 B-convergence (L=41, A=3B unless noted; [2.5,5] regression D): + dt=0.1 K=3: D flat ~3.79 across B=50k-600k (converged). MSD(5)=35.25. + dt=0.2 K=3: D = 3.43/3.74/3.58/3.75 (B=50/100/150/300k) - SCATTERED, LOW, + not converged. MSD(5)~34.2. + dt=0.2 K=5: D = 3.81 (B=150k), 3.82 (B=300k) - RECOVERS to ~3.79-3.81. + dt=0.2 K=10 B=150k: 3.79. +FINDING: the admission knee K scales with dt. At larger dt each exp(dt L*) +step spreads the operator more, so A=3B headroom is insufficient (admission- +limited) - need K>=5 at dt=0.2 vs K=3 at dt=0.1. Once K is large enough, +dt=0.2 converges to the same D=3.79-3.81. NB dt=0.1 remains the sweet spot +for MSD(5) absolute value (35.25 vs 34.6 at dt=0.2/K5); the SLOPE (D) converges +at both once K is adequate. Recipe refinement: K_knee grows with dt; use +K~3 at dt<=0.1, K~5 at dt=0.2. + +## D vs B for different dt (L=41, A=3B unless noted) (2026-07-08) + + dt=0.05 K3: D = 3.63/3.65/3.73/3.78 (B=50/100/150/300k) - climbs to 3.79, + needs larger B (more truncation events at small dt). + dt=0.1 K3: D ~ 3.79 flat for all B (sweet spot; B=100k=3.91 is noise). + dt=0.2 K3: 3.43/3.74/3.58/3.75 - admission-limited, scattered. + dt=0.2 K5: 3.81/3.82 - recovers. +COMPLETE PICTURE: D converges to ~3.79 for all dt once the ADEQUATE knob is +used, but the binding knob differs: small dt is B-limited (truncation-event +accumulation -> need larger B), large dt is A/K-limited (per-step spreading +-> need larger K). dt=0.1/K=3 is the joint sweet spot (converged at the +smallest B and K). Figure: /tmp/D_vs_B_dt.png (diagnostic; not yet a repo fig). + +## "Just push to larger B?" - answered at B=600k (2026-07-08) + +At B=600k, K=3: dt=0.05 D=3.787, dt=0.1 D=3.796, dt=0.2 D=3.684. +=> Larger B CONVERGES dt=0.05 and dt=0.1 to ~3.79 (dt=0.05 was B-limited, + climbs 3.63->3.79 over B=50k->600k). But dt=0.2 STAYS at 3.68 even at + B=600k - larger B does NOT fix it because it is ADMISSION/K-limited, not + B-limited. dt=0.2 needs K=5 (->3.81), independent of B. +Clean rule: small dt is B-limited (push B); large dt is K-limited (push K). +dt<=0.1 with K=3 + adequate B is the efficient converged regime. + +## B=1M points added (2026-07-08) + +B=1M, K=3: dt=0.1 D=3.783 (MSD5 35.37); dt=0.05 D=3.752 (MSD5 35.18). +vs B=600k: dt=0.1 3.796; dt=0.05 3.787. +=> No systematic climb from 600k->1M; both sit in 3.75-3.80, i.e. the +plateau is confirmed (flat within the ~+-0.03 extraction/cancellation-noise +floor). The converged D at dt<=0.1 is 3.76-3.79, matching Begusic-Chan 3.76. +Extraction noise (not B) is now the limiting uncertainty at large B. + +## The B=100k D-outlier explained (2026-07-08) + +The B=100k/dt=0.1/K=3 point gives D=3.91 (vs ~3.79 neighbors). CAUSE: its +MSD OVERSHOOTS the converged curve across [2.5,5] by a growing margin +(+0.25 at t=2.5 -> +0.84 at t=5; MSD(5)=36.2 vs 35.4 converged). Growing +offset => steeper slope => inflated D. NOT under-resolution (that gives LOW +D, cf. 50k/75k). This is DETERMINISTIC non-monotone truncation bias in B +(the method has no randomness; the top-B kept set changes discontinuously +with B and the coherent retained weight can over/undershoot) - the same +non-monotonicity Begusic-Chan report for SPD vs delta. CORRECTION: earlier +"cancellation-noise floor" mislabels this as statistical; it is deterministic +non-monotone convergence. Implication unchanged: use the median/band and the +D(t) collapse, never a single D(B) point (B=100k would mislead by ~3%). + +## B=3M, K=3 (overnight, 2026-07-08) - all dt converge to D~3.79 + + dt=0.2 : D[2.5,5]=3.791 MSD(5)=34.63 (58 min, 8.1 GB, 3.0M strings) + dt=0.1 : D[2.5,5]=3.774 MSD(5)=35.34 (71 min, 9.0 GB) + dt=0.05: D[2.5,5]=3.794 MSD(5)=35.43 (137 min, 8.8 GB) +All three land at D=3.77-3.79 = converged, matching Begusic-Chan 3.76 (~1%). + +REVISION of the earlier dt=0.2 conclusion: I claimed "dt=0.2 is K/admission- +limited; larger B does NOT fix it, needs K=5." The B=3M/K=3 point (3.79) +shows larger B DOES converge dt=0.2 at K=3 - just slowly/noisily (the +B<=600k/K=3 series scattered 3.43-3.75; it was converging in B all along, +not stuck). Correct statement: dt=0.2/K=3 converges in B but needs MUCH +larger B (~3M) than dt=0.1 (~300k); K=5 is the cheaper route (converged by +B=150k). So "small dt B-limited / large dt K-limited" is too binary: large +dt is BOTH slower-in-B AND helped by K; either knob reaches 3.79. +dt=0.05: B=1M gave 3.752, B=3M gives 3.794 - confirms it was mildly +B-limited and is now converged (climbed up as expected). Plateau confirmed +at 3x the previous largest basis. + +## B=6M, K=3 (overnight, 2026-07-09) - plateau confirmed at 6x + + dt=0.1 : D[2.5,5]=3.765 MSD(5)=35.32 (171 min, 15.6 GB, 6M strings) + dt=0.05: D[2.5,5]=3.762 MSD(5)=35.19 (306 min, 15.6 GB) +B-ladder (D[2.5,5]): dt=0.1: 1M 3.783 / 3M 3.774 / 6M 3.765; dt=0.05: 1M +3.752 / 3M 3.794 / 6M 3.762. All within +-0.03 noise, centered ~3.76 = +Begusic-Chan value exactly. Plateau confirmed at 6x the earlier largest B +(2x the B=3M overnight run). Converged D = 3.76-3.77. Peak RSS 15.6 GB +(A=18M working set); this is near the practical single-node ceiling on 34 GB. + +## dt=0.05 B-spread > dt=0.1 (2026-07-09, user obs) + +D(t=3.5, W=2) across B=1M/3M/6M: dt=0.1 spread 0.012 (3.771/3.769/3.759), +dt=0.05 spread 0.043 (3.748/3.791/3.755). dt=0.05 curves visibly further +apart than dt=0.1. CAUSE: dt=0.05 takes 100 truncation steps to T=5 vs 50 +for dt=0.1 - 2x the truncation events, so ~2x more accumulated (deterministic, +non-monotone) truncation bias at fixed B => ~3-4x larger B-to-B scatter. +Same anti-dt mechanism: smaller dt is LESS converged at fixed B (needs larger +B). Both centered on 3.76; dt=0.1 is better-converged per unit B (fewer +events), which is why dt=0.1/K=3 is the practical sweet spot. + +## PcStepConfig ported (2026-07-07): PR #98 review debt cleared + +The maintainer-requested pattern from lindblad-shim b07f75de, re-applied to +the current five-knob API: new config.rs with + PcStepConfig { max_basis, admit_basis, drop_tol, tau_add, num_threads } +(each field documented with its measured role from this ledger; Default = +uncapped near-exact reference config), and error.rs extracted from lib.rs. +Signatures are now + pc_step(basis, coeffs, dt, protected, &cfg) + pc_step_timed(...same...) + pc_step_orbit_rep(spec, basis, coeffs, dt, protected, group, k, &cfg) +Python kwargs UNCHANGED (binding builds the config internally, as in the +original review response). Zero clippy::too_many_arguments allows remain in +ppvm-lindblad. Orphaned doc-comment debris from the earlier removals (rk4 / +pc_step_complex fragments attached to pc_step) cleaned. Regressions: tests +7/7; L=5 momentum sanity 1.86e-3 and the tau_add cell (5.19e-3 / 77,820) +bit-identical; displacement cell runs correctly through the full stack. +PR #98 can now be closed as superseded when the campaign PR opens. + +## origin/main sync (2026-07-16) + +Merged origin/main (65 commits: ppvm-vihaco/ppvm-cli crates, tableau +expectation/trace + word-fused cz_block, native type stubs #171, stim-parser +breaking *pi angles #163, RotXY gate #170) into autotune/ladder-tuning +(merge commit c4ca0941; merge, not rebase, to preserve campaign history). +Conflicts: Cargo.lock (regenerated from upstream via cargo check) and +ppvm-python-native/Cargo.toml (kept our lindblad deps: mimalloc/num/numpy/ +crate paths; upstream side had only removed unrelated lines). Upstream +touches to dependency crates (pauli-word pattern tables, traits branch +tables for RotXY) are additive gate definitions -- not on the pc_step path. + +Post-merge gates, all green: +- cargo test -p ppvm-lindblad --release --lib: 7/7. +- L=5 momentum (dt .05, 40 steps, drop 1e-4, ks 2): C(t) and n_basis(t) + BIT-IDENTICAL to the pre-merge artifact sanity_pec_L5_v5.h5 (== the + 1.86e-3-vs-ED run). Strongest form of the gate. +- tau_add cell adaptive:0.1:1e-3:K1 -> median 5.19e-3, peak 77,820 (exact). +- displacement cell adaptive:0.05:0:M100k:A200k -> 1.99e-3 / 1.20e-2 (exact). + (Note: first tried dt=0.1/A=3B out of habit -> 2.31e-3/9.50e-3, run-to-run + deterministic; that cell was never a recorded gate. The recorded gate is + the dt=0.05/A=2B cell above.) + +Branch is now mergeable into main; PR can be opened. origin/lindblad-shim +moved upstream (f14edafc -> 7c458d6a) while we worked -- check what landed +there before closing #98 as superseded. + +## Pre-PR comment + simplification pass (2026-07-16) + +One pass over all 28 files the branch adds vs origin/main. Comments made +short/factual (no ledger, campaign, PR, or removed-feature references). +Dead code deleted (all zero-caller, verified by grep across crates + +bindings): max_action_coef, spmv_matrix_free, compute_action_sum{,_complex}, +lib.rs prune_basis_complex, select_ms max_m param, gencsr timing fields, +orphaned PPVM_K_LEAKAGE doc block. Duplication collapsed: comm_product = +pauli_mul + eps map; CachedCscOp/OrbitRepCscOp -> generic CscOp in +mf_expm (~120 lines); one decode_basis for both binding modules. +Consistency: pc_step_timed now honors admit_basis/tau_add (binding kwargs +added; dict lost the always-zero gencsr keys). Zero clippy warnings in +ppvm-lindblad + ppvm-pauli-sum. + +Gates after the refactor, all green: tests 7/7 + 36 + 23 (python); +L=5 momentum BIT-IDENTICAL to sanity_pec_L5_v5.h5 (max|dC| = 0); +adaptive:0.1:1e-3:K1 -> 5.19e-3 / 77,820 exact; +adaptive:0.05:0:M100k:A200k -> 1.99e-3 / 1.20e-2 exact. +Commit: refactor comment pass (parent of this ledger commit). diff --git a/docs/autotune/2026-07-03-orbit-compare/msd_L21_T15.png b/docs/autotune/2026-07-03-orbit-compare/msd_L21_T15.png new file mode 100644 index 00000000..04f35d8a Binary files /dev/null and b/docs/autotune/2026-07-03-orbit-compare/msd_L21_T15.png differ diff --git a/docs/autotune/2026-07-03-orbit-compare/msd_L21_all_runs.png b/docs/autotune/2026-07-03-orbit-compare/msd_L21_all_runs.png new file mode 100644 index 00000000..6d3e1b5f Binary files /dev/null and b/docs/autotune/2026-07-03-orbit-compare/msd_L21_all_runs.png differ diff --git a/docs/autotune/2026-07-03-orbit-compare/msd_L21_deep_trotter.png b/docs/autotune/2026-07-03-orbit-compare/msd_L21_deep_trotter.png new file mode 100644 index 00000000..32c70f3d Binary files /dev/null and b/docs/autotune/2026-07-03-orbit-compare/msd_L21_deep_trotter.png differ diff --git a/docs/autotune/2026-07-03-orbit-compare/msd_L21_summary.png b/docs/autotune/2026-07-03-orbit-compare/msd_L21_summary.png new file mode 100644 index 00000000..43d69337 Binary files /dev/null and b/docs/autotune/2026-07-03-orbit-compare/msd_L21_summary.png differ diff --git a/docs/autotune/2026-07-03-orbit-compare/msd_L21_trotter_ladder.png b/docs/autotune/2026-07-03-orbit-compare/msd_L21_trotter_ladder.png new file mode 100644 index 00000000..1817e21d Binary files /dev/null and b/docs/autotune/2026-07-03-orbit-compare/msd_L21_trotter_ladder.png differ diff --git a/docs/autotune/2026-07-03-orbit-compare/msd_L41_Kscan.png b/docs/autotune/2026-07-03-orbit-compare/msd_L41_Kscan.png new file mode 100644 index 00000000..9c097a0b Binary files /dev/null and b/docs/autotune/2026-07-03-orbit-compare/msd_L41_Kscan.png differ diff --git a/docs/autotune/2026-07-03-orbit-compare/msd_L41_Kscan_B150k.png b/docs/autotune/2026-07-03-orbit-compare/msd_L41_Kscan_B150k.png new file mode 100644 index 00000000..4551b018 Binary files /dev/null and b/docs/autotune/2026-07-03-orbit-compare/msd_L41_Kscan_B150k.png differ diff --git a/docs/autotune/2026-07-03-orbit-compare/msd_L41_Kscan_full.png b/docs/autotune/2026-07-03-orbit-compare/msd_L41_Kscan_full.png new file mode 100644 index 00000000..b1158ba1 Binary files /dev/null and b/docs/autotune/2026-07-03-orbit-compare/msd_L41_Kscan_full.png differ diff --git a/docs/autotune/2026-07-03-orbit-compare/msd_L41_ram_vs_acc.png b/docs/autotune/2026-07-03-orbit-compare/msd_L41_ram_vs_acc.png new file mode 100644 index 00000000..ab07a4ef Binary files /dev/null and b/docs/autotune/2026-07-03-orbit-compare/msd_L41_ram_vs_acc.png differ diff --git a/docs/autotune/2026-07-03-orbit-compare/msd_L41_vs_reference.png b/docs/autotune/2026-07-03-orbit-compare/msd_L41_vs_reference.png new file mode 100644 index 00000000..5fb363a5 Binary files /dev/null and b/docs/autotune/2026-07-03-orbit-compare/msd_L41_vs_reference.png differ diff --git a/docs/autotune/2026-07-03-orbit-compare/msd_combined_T10.png b/docs/autotune/2026-07-03-orbit-compare/msd_combined_T10.png new file mode 100644 index 00000000..1f0cd6f9 Binary files /dev/null and b/docs/autotune/2026-07-03-orbit-compare/msd_combined_T10.png differ diff --git a/docs/autotune/2026-07-03-orbit-compare/msd_momentum_L11_pec.png b/docs/autotune/2026-07-03-orbit-compare/msd_momentum_L11_pec.png new file mode 100644 index 00000000..fbeaa6b3 Binary files /dev/null and b/docs/autotune/2026-07-03-orbit-compare/msd_momentum_L11_pec.png differ diff --git a/docs/autotune/2026-07-03-orbit-compare/msd_raw_T10.png b/docs/autotune/2026-07-03-orbit-compare/msd_raw_T10.png new file mode 100644 index 00000000..a19b3fa5 Binary files /dev/null and b/docs/autotune/2026-07-03-orbit-compare/msd_raw_T10.png differ diff --git a/docs/autotune/2026-07-03-orbit-compare/msd_trotter_overlay_T10.png b/docs/autotune/2026-07-03-orbit-compare/msd_trotter_overlay_T10.png new file mode 100644 index 00000000..6a75fb5d Binary files /dev/null and b/docs/autotune/2026-07-03-orbit-compare/msd_trotter_overlay_T10.png differ diff --git a/docs/autotune/2026-07-03-orbit-compare/orbit_bench.py b/docs/autotune/2026-07-03-orbit-compare/orbit_bench.py new file mode 100644 index 00000000..c20d5d98 --- /dev/null +++ b/docs/autotune/2026-07-03-orbit-compare/orbit_bench.py @@ -0,0 +1,102 @@ +"""Orbit-preserving (momentum-k) Trotter vs expm(cache/stream), matched rel~1e-3. +worker: orbit_bench.py L k T dt knob max_basis (PPVM_EXPM_STREAM=1 for stream) +driver: orbit_bench.py driver +Exact-ED referenced at L=5 (N=10).""" +import sys, os, json, time, resource +from functools import reduce +import numpy as np +from ppvm import Lindbladian, PauliSum +from ppvm.lindblad import _basis_to_codes +from ppvm._core import TranslationGroup, canonicalize_basis_arr_complex + +def ladder(L): + N=2*L; site=lambda j,leg: leg*L+j; bonds=[] + for leg in(0,1): + for j in range(L): bonds.append((site(j,leg),site((j+1)%L,leg))) + for j in range(L): bonds.append((site(j,0),site(j,1))) + return N,bonds +def rss(): return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss/(1024*1024) + +def run_expm(L,k,T,dt,drop,mb): + N,bonds=ladder(L); phi=2*np.pi*k/L; steps=round(T/dt); snap=max(1,steps//10) + h=[] + for(p,q)in bonds: + for P in"XY": s=["I"]*N; s[p]=P; s[q]=P; h.append(("".join(s),1.0)) + Lop=Lindbladian(N,h,[]); g=TranslationGroup.ladder(L,2); mom=np.array([k],dtype=np.int32) + zs=["I"*q+"Z"+"I"*(N-q-1) for q in range(N)]; zb=_basis_to_codes(zs,N) + seed=np.array([np.exp(-1j*phi*(q%L)) for q in range(N)],dtype=np.complex128) + bo,coo=canonicalize_basis_arr_complex(zb,seed,g,mom); pr=bo.copy() + sb,sc=bo.copy(),coo.copy(); den=np.sum(np.conj(sc)*sc) + def Ck(bo,coo): + idxm={bo[i].tobytes():i for i in range(len(bo))}; num=0j + for i in range(len(sb)): + key=sb[i].tobytes() + if key in idxm: num+=np.conj(sc[i])*coo[idxm[key]] + return num/den + curve=[Ck(bo,coo)]; peak=len(bo); t0=time.time() + for st in range(steps): + bo,coo=Lop.pc_step_orbit_rep(bo,coo,dt,mb,g,mom,drop,pr); peak=max(peak,len(bo)) + if (st+1)%snap==0: curve.append(Ck(bo,coo)) + return curve,time.time()-t0,peak + +def run_trotter(L,k,T,dt,mac,mb): + N,bonds=ladder(L); phi=2*np.pi*k/L; steps=round(T/dt); snap=max(1,steps//10) + g=TranslationGroup.ladder(L,2); mom=[k] + def mk(fn): return PauliSum.new(N,[(f"Z{q}",float(fn(phi*(q%L)))) for q in range(N)],min_abs_coeff=mac,max_pauli_weight=N) + R=mk(np.cos); Im=mk(lambda x:-np.sin(x)); Rs=mk(np.cos); Ims=mk(lambda x:-np.sin(x)) + Rs.momentum_merge(Ims,g,mom); norm=Rs.overlap(Rs)+Ims.overlap(Ims) + def Ck(R,Im): + re=Rs.overlap(R)+Ims.overlap(Im); im=Rs.overlap(Im)-Ims.overlap(R); return (re+1j*im)/norm + def snapCk(): + Rc=R.copy(); Ic=Im.copy(); Rc.momentum_merge(Ic,g,mom); return Ck(Rc,Ic) + curve=[snapCk()]; peak=len(R); t0=time.time() + for st in range(steps): + for a,b in bonds: R.rxx(a,b,dt,truncate=False); R.ryy(a,b,dt,truncate=False) + for a,b in reversed(bonds): R.rxx(a,b,dt,truncate=False); R.ryy(a,b,dt,truncate=False) + for a,b in bonds: Im.rxx(a,b,dt,truncate=False); Im.ryy(a,b,dt,truncate=False) + for a,b in reversed(bonds): Im.rxx(a,b,dt,truncate=False); Im.ryy(a,b,dt,truncate=False) + R.truncate(); Im.truncate(); peak=max(peak,len(R)) + if (st+1)%snap==0: curve.append(snapCk()) + return curve,time.time()-t0,peak + +if len(sys.argv)>1 and sys.argv[1]!="driver": + m=sys.argv[1];L=int(sys.argv[2]);k=int(sys.argv[3]);T=float(sys.argv[4]);dt=float(sys.argv[5]);knob=float(sys.argv[6]);mb=int(sys.argv[7]) + curve,wall,peak=(run_expm if m=="expm" else run_trotter)(L,k,T,dt,knob,mb) + print(json.dumps(dict(curve=[[c.real,c.imag] for c in curve],wall=wall,peak=peak,rss=rss()))); sys.exit(0) + +# ---- driver (L=5, exact-ED reference) ---- +import subprocess +W=os.path.abspath(__file__); PY=sys.executable +L,k,T=5,2,2.0 +# exact C_k(t_m), t_m = m*T/10 +SX=np.array([[0,1],[1,0]],complex);SY=np.array([[0,-1j],[1j,0]],complex);I2=np.eye(2) +N,bonds=ladder(L); phi=2*np.pi*k/L +def op(s,q):return reduce(np.kron,[s if kk==q else I2 for kk in range(N)]) +H=np.zeros((2**N,2**N),complex) +for p,q in bonds: H+=op(SX,p)@op(SX,q)+op(SY,p)@op(SY,q) +E,V=np.linalg.eigh(H); idx=np.arange(2**N) +d=np.zeros(2**N,complex) +for q in range(N): d+=np.exp(-1j*phi*(q%L))*(1-2*((idx>>(N-1-q))&1)) +REF=np.array([ (np.conj(d)@((np.abs((V*np.exp(1j*E*(m*T/10)))@V.conj().T)**2)@d))/(np.conj(d)@d) for m in range(11)]) +def call(method,dt,knob,mb,stream=False): + env={**os.environ} + if stream: env["PPVM_EXPM_STREAM"]="1" + else: env.pop("PPVM_EXPM_STREAM",None) + r=subprocess.run([PY,W,method,str(L),str(k),str(T),str(dt),str(knob),str(mb)],capture_output=True,text=True,env=env) + try: d=json.loads(r.stdout.strip().splitlines()[-1]) + except Exception: return None + d["curve"]=np.array([c[0]+1j*c[1] for c in d["curve"]]); return d +def rel(c): + n=min(len(c),len(REF)); return float(np.linalg.norm(c[:n]-REF[:n])/np.linalg.norm(REF[:n])) +print(f"ORBIT k-RESOLVED BENCH: ladder L={L}(N={2*L}) k={k} T={T} (exact-ED reference)",flush=True) +print(f"{'method':14} {'dt':>5} {'knob':>7} {'max_basis':>9} {'rel_err':>9} {'wall_s':>7} {'RSS_mb':>7} {'peak':>7}",flush=True) +PLAN=[ + ("expm-cache",0.1,1e-4,10**7,0),("expm-cache",0.05,1e-4,10**7,0),("expm-cache",0.025,1e-4,10**7,0), + ("trotter",0.1,1e-4,10**7,0),("trotter",0.05,1e-4,10**7,0),("trotter",0.025,1e-4,10**7,0), + ("expm-cache",0.05,3e-4,10**7,0),("trotter",0.05,3e-4,10**7,0), +] +for name,dt,knob,mb,stream in PLAN: + method="trotter" if name=="trotter" else "expm" + d=call(method,dt,knob,mb,bool(stream)) + if d is None: print(f"{name:14} {dt:>5} {knob:>7.0e} {mb:>9} FAILED",flush=True); continue + print(f"{name:14} {dt:>5} {knob:>7.0e} {mb:>9} {rel(d['curve']):>9.2e} {d['wall']:>7.1f} {d['rss']:>7.0f} {d['peak']:>7}",flush=True) diff --git a/docs/autotune/2026-07-03-orbit-compare/orbit_bench_L7.py b/docs/autotune/2026-07-03-orbit-compare/orbit_bench_L7.py new file mode 100644 index 00000000..23b7c4e0 --- /dev/null +++ b/docs/autotune/2026-07-03-orbit-compare/orbit_bench_L7.py @@ -0,0 +1,120 @@ +"""Orbit-preserving (momentum-k) Trotter vs expm at scale: ladder L=7 (N=14), k=3, T=2. +Reference: exact ED curve from exact_ref_L7.py (npz next to this file). + +worker: orbit_bench_L7.py L k T dt knob max_basis (PPVM_EXPM_STREAM=1 for stream) +driver: orbit_bench_L7.py driver [row ...] rows like expm:0.1:1e-3:10000000[:stream] + (no rows -> default plan) + +NOTE (2026-07-07): PPVM_K_LEAKAGE / PPVM_EXPM_STREAM were removed from +ppvm; the K / stream row tokens in this legacy driver are now inert. Use +scan_xy_mid.py / scan_realspace_msd.py (explicit --tau_add) instead. +""" +import sys, os, json, time, resource +import numpy as np +from ppvm import Lindbladian, PauliSum +from ppvm.lindblad import _basis_to_codes +from ppvm._core import TranslationGroup, canonicalize_basis_arr_complex + +def ladder(L): + N=2*L; site=lambda j,leg: leg*L+j; bonds=[] + for leg in(0,1): + for j in range(L): bonds.append((site(j,leg),site((j+1)%L,leg))) + for j in range(L): bonds.append((site(j,0),site(j,1))) + return N,bonds +def rss(): return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss/(1024*1024) + +def run_expm(L,k,T,dt,drop,mb): + N,bonds=ladder(L); phi=2*np.pi*k/L; steps=round(T/dt); snap=max(1,steps//10) + h=[] + for(p,q)in bonds: + for P in"XY": s=["I"]*N; s[p]=P; s[q]=P; h.append(("".join(s),1.0)) + Lop=Lindbladian(N,h,[]); g=TranslationGroup.ladder(L,2); mom=np.array([k],dtype=np.int32) + zs=["I"*q+"Z"+"I"*(N-q-1) for q in range(N)]; zb=_basis_to_codes(zs,N) + seed=np.array([np.exp(-1j*phi*(q%L)) for q in range(N)],dtype=np.complex128) + bo,coo=canonicalize_basis_arr_complex(zb,seed,g,mom); pr=bo.copy() + sb,sc=bo.copy(),coo.copy(); den=np.sum(np.conj(sc)*sc) + def Ck(bo,coo): + idxm={bo[i].tobytes():i for i in range(len(bo))}; num=0j + for i in range(len(sb)): + key=sb[i].tobytes() + if key in idxm: num+=np.conj(sc[i])*coo[idxm[key]] + return num/den + curve=[Ck(bo,coo)]; peak=len(bo); t0=time.time() + for st in range(steps): + bo,coo=Lop.pc_step_orbit_rep(bo,coo,dt,mb,g,mom,drop,pr); peak=max(peak,len(bo)) + if (st+1)%snap==0: curve.append(Ck(bo,coo)) + return curve,time.time()-t0,peak + +def run_trotter(L,k,T,dt,mac,mb,perbond=False): + # perbond=True is the scheme of the verified real-space baseline + # (demo/trotter_ladder.py): rxx untruncated, each ryy truncates. The legacy + # perbond=False variant defers all truncation to the end of the step, which + # blows up the intra-step transient at large N. + N,bonds=ladder(L); phi=2*np.pi*k/L; steps=round(T/dt); snap=max(1,steps//10) + g=TranslationGroup.ladder(L,2); mom=[k] + def mk(fn): return PauliSum.new(N,[(f"Z{q}",float(fn(phi*(q%L)))) for q in range(N)],min_abs_coeff=mac,max_pauli_weight=N) + R=mk(np.cos); Im=mk(lambda x:-np.sin(x)); Rs=mk(np.cos); Ims=mk(lambda x:-np.sin(x)) + Rs.momentum_merge(Ims,g,mom); norm=Rs.overlap(Rs)+Ims.overlap(Ims) + def Ck(R,Im): + re=Rs.overlap(R)+Ims.overlap(Im); im=Rs.overlap(Im)-Ims.overlap(R); return (re+1j*im)/norm + def snapCk(): + Rc=R.copy(); Ic=Im.copy(); Rc.momentum_merge(Ic,g,mom); return Ck(Rc,Ic) + def sweep(o): + if perbond: + for a,b in bonds: o.rxx(a,b,dt,truncate=False); o.ryy(a,b,dt) + for a,b in reversed(bonds): o.rxx(a,b,dt,truncate=False); o.ryy(a,b,dt) + else: + for a,b in bonds: o.rxx(a,b,dt,truncate=False); o.ryy(a,b,dt,truncate=False) + for a,b in reversed(bonds): o.rxx(a,b,dt,truncate=False); o.ryy(a,b,dt,truncate=False) + o.truncate() + curve=[snapCk()]; peak=len(R); t0=time.time() + for st in range(steps): + sweep(R); sweep(Im) + peak=max(peak,max(len(R),len(Im))) + if (st+1)%snap==0: curve.append(snapCk()) + return curve,time.time()-t0,peak + +if len(sys.argv)>1 and sys.argv[1]!="driver": + m=sys.argv[1];L=int(sys.argv[2]);k=int(sys.argv[3]);T=float(sys.argv[4]);dt=float(sys.argv[5]);knob=float(sys.argv[6]);mb=int(sys.argv[7]) + if m=="expm": curve,wall,peak=run_expm(L,k,T,dt,knob,mb) + else: curve,wall,peak=run_trotter(L,k,T,dt,knob,mb,perbond=(m=="trotter-pb")) + print(json.dumps(dict(curve=[[c.real,c.imag] for c in curve],wall=wall,peak=peak,rss=rss()))); sys.exit(0) + +# ---- driver: exact-ED-referenced from npz ---- +import subprocess +W=os.path.abspath(__file__); PY=sys.executable; D=os.path.dirname(W) +L,k,T=7,int(os.environ.get("PPVM_BENCH_K","3")),2.0 +npz=np.load(os.path.join(D,f"exact_ref_L7_k{k}_T2.npz")) +REF=npz["ref"] +def call(method,dt,knob,mb,stream=False,kleak=None): + env={**os.environ} + if stream: env["PPVM_EXPM_STREAM"]="1" + else: env.pop("PPVM_EXPM_STREAM",None) + if kleak is not None: env["PPVM_K_LEAKAGE"]=str(kleak) + else: env.pop("PPVM_K_LEAKAGE",None) + r=subprocess.run([PY,W,method,str(L),str(k),str(T),str(dt),str(knob),str(mb)],capture_output=True,text=True,env=env) + try: d=json.loads(r.stdout.strip().splitlines()[-1]) + except Exception: return None + d["curve"]=np.array([c[0]+1j*c[1] for c in d["curve"]]); return d +def rel(c): + n=min(len(c),len(REF)); return float(np.linalg.norm(c[:n]-REF[:n])/np.linalg.norm(REF[:n])) +print(f"ORBIT k-RESOLVED BENCH AT SCALE: ladder L={L}(N={2*L}) k={k} T={T} (exact-ED reference)",flush=True) +print(f"{'method':14} {'dt':>6} {'knob':>7} {'K':>4} {'max_basis':>9} {'rel_err':>9} {'wall_s':>7} {'RSS_mb':>7} {'peak':>8}",flush=True) +if len(sys.argv)>2: + PLAN=[] + for row in sys.argv[2:]: + f=row.split(":") + stream=1 if "stream" in f[4:] else 0 + kleak=next((tok[1:] for tok in f[4:] if tok.startswith("K")),None) + PLAN.append((f[0],float(f[1]),float(f[2]),int(f[3]),stream,kleak)) +else: + PLAN=[ + ("expm-cache",0.1,1e-3,10**7,0,None),("trotter",0.1,1e-3,10**7,0,None), + ("expm-cache",0.05,1e-3,10**7,0,None),("trotter",0.05,1e-3,10**7,0,None), + ] +for name,dt,knob,mb,stream,kleak in PLAN: + method=name if name.startswith("trotter") else "expm" + d=call(method,dt,knob,mb,bool(stream),kleak) + ktag=kleak if kleak is not None else "-" + if d is None: print(f"{name:14} {dt:>6} {knob:>7.0e} {ktag:>4} {mb:>9} FAILED",flush=True); continue + print(f"{name:14} {dt:>6} {knob:>7.0e} {ktag:>4} {mb:>9} {rel(d['curve']):>9.2e} {d['wall']:>7.1f} {d['rss']:>7.0f} {d['peak']:>8}",flush=True) diff --git a/docs/autotune/2026-07-03-orbit-compare/orbit_verify.py b/docs/autotune/2026-07-03-orbit-compare/orbit_verify.py new file mode 100644 index 00000000..5c54ad8e --- /dev/null +++ b/docs/autotune/2026-07-03-orbit-compare/orbit_verify.py @@ -0,0 +1,70 @@ +import numpy as np +from functools import reduce +from ppvm import Lindbladian +from ppvm.lindblad import _basis_to_codes +from ppvm._core import TranslationGroup, canonicalize_basis_arr_complex + +N=8; k=1; dt=0.02; nsnap=5; steps_per=3; drop=1e-10; MB=10_000_000 +phi=2*np.pi*k/N +# --- 1D XY chain PBC --- +h=[] +for j in range(N): + nx=(j+1)%N + for P in "XY": + s=["I"]*N; s[j]=P; s[nx]=P; h.append(("".join(s),1.0)) +Lop=Lindbladian(N,h,[]) +g=TranslationGroup.chain_1d(N); mom=np.array([k],dtype=np.int32) + +# --- exact ED ref --- +SX=np.array([[0,1],[1,0]],complex);SY=np.array([[0,-1j],[1j,0]],complex);SZ=np.array([[1,0],[0,-1]],complex);I2=np.eye(2) +def op(s,q):return reduce(np.kron,[s if k2==q else I2 for k2 in range(N)]) +H=np.zeros((2**N,2**N),complex) +for j in range(N): + nx=(j+1)%N; H+=op(SX,j)@op(SX,nx)+op(SY,j)@op(SY,nx) +idx=np.arange(2**N) +d=np.zeros(2**N,complex) +for j in range(N): d+=np.exp(-1j*phi*j)*(1-2*((idx>>(N-1-j))&1)) +E,V=np.linalg.eigh(H) +def exact_Ck(t): + U=(V*np.exp(1j*E*t))@V.conj().T; U2=np.abs(U)**2 + return (np.conj(d)@(U2@d))/(np.conj(d)@d) + +# --- seed (real-space k-mode) --- +zs=["I"*j+"Z"+"I"*(N-j-1) for j in range(N)]; zb=_basis_to_codes(zs,N) +seed=np.array([np.exp(-1j*phi*j) for j in range(N)],dtype=np.complex128) + +# --- real-space complex expm --- +ba=zb.copy(); co=seed.copy(); pr=zb.copy() +def Ck_realspace(ba,co): + idxm={ba[i].tobytes():i for i in range(len(ba))} + s=0j + for j in range(N): + key=zb[j].tobytes() + if key in idxm: s+=np.exp(1j*phi*j)*co[idxm[key]] + return s/N + +# --- orbit-rep expm --- +bo,coo=canonicalize_basis_arr_complex(zb.copy(),seed.copy(),g,mom) +# the Z-orbit rep row(s): track total k-mode Z amplitude via overlap with seed's rep +seed_bo, seed_coo = bo.copy(), coo.copy() +def Ck_orbit(bo,coo): + idxm={bo[i].tobytes():i for i in range(len(bo))} + num=0j + for i in range(len(seed_bo)): + key=seed_bo[i].tobytes() + if key in idxm: num+=np.conj(seed_coo[i])*coo[idxm[key]] + den=np.sum(np.conj(seed_coo)*seed_coo) + return num/den +pr_o=seed_bo.copy() + +print(f"N={N} k={k} dt={dt} (near-exact: drop={drop})") +print(f"{'t':>6} {'exact':>22} {'realspace':>22} {'orbit-rep':>22}") +bo2,coo2=bo.copy(),coo.copy() +for si in range(nsnap+1): + t=si*steps_per*dt + ex=exact_Ck(t); rs=Ck_realspace(ba,co); orb=Ck_orbit(bo2,coo2) + print(f"{t:6.3f} {ex.real:+.4f}{ex.imag:+.4f}j {rs.real:+.4f}{rs.imag:+.4f}j {orb.real:+.4f}{orb.imag:+.4f}j") + if si> (N - 1 - p)) & 1 + bq = (idx >> (N - 1 - q)) & 1 + src = idx[bp != bq] + dst = src ^ (1 << (N - 1 - p)) ^ (1 << (N - 1 - q)) + H[dst, src] += 2.0 +E, V = np.linalg.eigh(H) + +dz = np.array([1 - 2 * ((idx >> (N - 1 - q)) & 1) for q in range(N)], dtype=float) +j0 = L // 2 +O0 = np.diag(0.5 * dz[j0] + 0.5 * dz[L + j0]).astype(complex) + +P4 = np.stack([np.eye(2), np.array([[0, 1], [1, 0]]), + np.array([[0, -1j], [1j, 0]]), np.array([[1, 0], [0, -1]])]).astype(complex) + +def pauli_coeffs(O): + """(2^N x 2^N) matrix -> (4^N,) Pauli coefficients, qubit-1 slowest.""" + Tm = O.reshape((2,) * (2 * N)) + order = [] + for k in range(N): + order += [k, N + k] # pairs (i_k, j_k) + Tm = np.transpose(Tm, order).reshape((-1,)) + Tm = Tm.reshape((1, -1)) + for k in range(N): + # leading done-paulis x (2,2) x rest -> contract this qubit's (i,j) + Tm = Tm.reshape((4 ** k, 2, 2, -1)) + c0 = (Tm[:, 0, 0, :] + Tm[:, 1, 1, :]) / 2 + c1 = (Tm[:, 0, 1, :] + Tm[:, 1, 0, :]) / 2 + c2 = 1j * (Tm[:, 0, 1, :] - Tm[:, 1, 0, :]) / 2 + c3 = (Tm[:, 0, 0, :] - Tm[:, 1, 1, :]) / 2 + Tm = np.stack([c0, c1, c2, c3], axis=1).reshape((4 ** (k + 1), -1)) + return Tm.reshape(-1) + +def pauli_matrix(c): + """inverse of pauli_coeffs.""" + Tm = c.reshape((4,) * N) + for _ in range(N): + Tm = np.tensordot(Tm, P4, axes=([0], [0])) # consume leading pauli axis, append (i,j) + order = [2 * k for k in range(N)] + [2 * k + 1 for k in range(N)] + return np.transpose(Tm, order).reshape((dim, dim)) + +def evolve(O, t): + ph = np.exp(1j * E * t) + W = (V * ph) @ V.T + return W.conj().T @ O @ W + +t_start = time.time() +# round-trip validation on O0 evolved a bit +Otest = evolve(O0, 0.3) +cc = pauli_coeffs(Otest) +err = np.abs(pauli_matrix(cc) - Otest).max() +assert err < 1e-10, f"transform round-trip failed: {err}" +print(f"transform round-trip OK ({err:.1e})", flush=True) + +Ot0 = evolve(O0, t0) +c = pauli_coeffs(Ot0) +fro = np.linalg.norm(Ot0) ** 2 +csum = (np.abs(c) ** 2).sum() * dim +print(f"Parseval check: Tr(O^2)={fro:.6f} 2^N*sum|c|^2={csum:.6f}", flush=True) + +mag = np.abs(c) +order = np.argsort(mag)[::-1] +pw = np.zeros(4 ** N, dtype=np.int8) +tmp = np.arange(4 ** N) +for _ in range(N): + pw += (tmp % 4 != 0).astype(np.int8) + tmp //= 4 +tot2 = (mag ** 2).sum() +n_nonzero = int((mag > 1e-14).sum()) +print(f"t0={t0}: {n_nonzero:,} strings with |c|>1e-14 (of {4**N:,})", flush=True) +for B in (100, 1000, 10000, 100000): + kept = order[:B] + frac = (mag[kept] ** 2).sum() / tot2 + print(f" B={B:>7,}: |c|^2 fraction {frac:.5f} min|c| kept {mag[kept].min():.2e} " + f"mean weight kept {pw[kept].mean():.2f} vs dust {pw[order[B:min(B+200000,len(order))]].mean():.2f}", flush=True) + +jcoord = np.arange(N) % L +dj2 = ((jcoord - j0) % L) +dj2 = np.where(dj2 > L // 2, dj2 - L, dj2).astype(float) ** 2 + +ts = np.arange(t0, T + 1e-9, 0.1) +Bs = (1000, 10000, 100000) +mats = {"full": Ot0} +for B in Bs: + ck = c.copy() + ck[order[B:]] = 0.0 + mats[f"kept{B}"] = pauli_matrix(ck) +res = {"ts": ts} +prof = {k: np.zeros((len(ts), N)) for k in mats} +for m, t in enumerate(ts): + s = t - t0 + ph = np.exp(1j * E * s) + W = (V * ph) @ V.T + Wd = W.conj().T + for k, M in mats.items(): + d = np.real(np.diagonal(Wd @ M @ W)) + prof[k][m] = (dz @ d) / dim +for k in prof: + a = prof[k] + res[f"msd_{k}"] = (a * dj2[None, :]).sum(axis=1) + res[f"suma_{k}"] = a.sum(axis=1) +np.savez(pref + ".npz", **res) +print(f"saved {pref}.npz ({time.time()-t_start:.0f}s)", flush=True) +fu = res["msd_full"] +late = ts >= t0 + 1.0 +wig_f = fu[late] - fu[late].mean() +for B in Bs: + ke = res[f"msd_kept{B}"] + wig_k = ke[late] - ke[late].mean() + corr = np.corrcoef(wig_k, wig_f)[0, 1] + print(f"B={B:>7,}: late ={ke[late].mean():.4f} vs exact {fu[late].mean():.4f}; " + f"wiggle corr {corr:+.3f}, amp ratio {wig_k.std()/wig_f.std():.3f}", flush=True) diff --git a/docs/autotune/2026-07-03-orbit-compare/scan_realspace_msd.py b/docs/autotune/2026-07-03-orbit-compare/scan_realspace_msd.py new file mode 100644 index 00000000..769d7c29 --- /dev/null +++ b/docs/autotune/2026-07-03-orbit-compare/scan_realspace_msd.py @@ -0,0 +1,84 @@ +"""Real-space MSD comparison at L=7, gamma=0, T=2: Trotter vs adaptive (pec), +driving xy-experiments/main_realspace_ladder.py. Metric: median over +t = 0.2..2.0 (t=0 excluded, MSD=0) of |MSD(t)-MSD_exact(t)|/|MSD_exact(t)|. +Reference: exact_msd_L7_T2.npz (blocked |U|^2 ED, validated at L=5). +Peak RSS via /usr/bin/time -l; wall from the run's own wall_s dataset. +K (PPVM_K_LEAKAGE) applies to the adaptive mode only. + +Usage: scan_realspace_msd.py [row ...] + rows like adaptive:0.05:1e-3[:K1] trotter:0.05:3e-4 + (knob = drop_tol for adaptive, min_abs_coeff for trotter) +""" +import os, re, subprocess, sys +import numpy as np +import h5py + +HERE = os.path.dirname(os.path.abspath(__file__)) +XY = "/Users/alexschuckert/dev/26_ppvm/xy-experiments" +sys.path.insert(0, XY) +from msd import msd_from_profile + +L = int(os.environ.get("SCAN_L", "7")) +T = float(os.environ.get("SCAN_T", "2.0")) +GAMMA = float(os.environ.get("SCAN_GAMMA", "0.0")) +try: + npz = np.load(os.path.join(HERE, f"exact_msd_L{L}_T{T:g}.npz")) + tR, MSDR = npz["ts"], npz["msd"] # t = 0 .. T + REF_DT = float(tR[1] - tR[0]) +except FileNotFoundError: + tR = MSDR = None # no exact reference (large L) + REF_DT = None + +def run_cell(mode, dt, knob, kleak=None, stream=False, mb=None, ab=None): + steps = round(T / dt) + out = (f"data/msd{'' if L==7 else f'_L{L}'}{'' if T==2.0 else f'_T{T:g}'}{'' if GAMMA==0.0 else f'_g{GAMMA:g}'}_{mode}_dt{dt}_knob{knob:g}" + (f"_K{kleak:g}" if kleak is not None else "") + + (f"_M{mb:g}" if mb is not None else "") + (f"_A{ab:g}" if ab is not None else "") + + ("_stream" if stream else "") + ".h5") + cmd = ["/usr/bin/time", "-l", "./run", "run", "python", "main_realspace_ladder.py", + "--mode", mode, "--L", str(L), "--gamma", str(GAMMA), "--dt", str(dt), + "--steps", str(steps), "--pbc", "1", "--preserve", "1", "--out", out] + cmd += ["--min_abs_coeff", str(knob)] if mode == "trotter" else ["--drop_tol", str(knob)] + if mb is not None: + cmd += ["--max_basis", str(int(mb))] + if ab is not None: + cmd += ["--admit_basis", str(int(ab))] + if kleak is not None and mode != "trotter": + cmd += ["--tau_add", str(kleak * knob / dt)] # K token -> explicit tau_add + r = subprocess.run(cmd, cwd=XY, capture_output=True, text=True) + m = re.search(r"(\d+)\s+maximum resident set size", r.stderr) + rss_mb = int(m.group(1)) / (1024 * 1024) if m else float("nan") + try: + t, msd = msd_from_profile(os.path.join(XY, out)) + with h5py.File(os.path.join(XY, out)) as h: + wall = float(h["wall_s"][-1]) + peak = int(h["n_basis"][:].max()) + if MSDR is None: + return dict(med=float("nan"), mx=float("nan"), wall=wall, rss=rss_mb, peak=peak) + if dt <= REF_DT: # run finer than ref: subsample run + stride = max(1, round(REF_DT / dt)) + msd_s = msd[::stride][:len(MSDR)] + ref = MSDR[:len(msd_s)] + else: # run coarser than ref: subsample ref + rstride = max(1, round(dt / REF_DT)) + ref = MSDR[::rstride][:len(msd)] + msd_s = msd[:len(ref)] + rel = np.abs(msd_s[1:] - ref[1:]) / np.abs(ref[1:]) + return dict(med=float(np.median(rel)), mx=float(rel.max()), wall=wall, rss=rss_mb, peak=peak) + except Exception as e: + return dict(fail=f"{e} / {r.stdout[-150:]}{r.stderr[-150:]}") + +print(f"REAL-SPACE MSD SCAN: ladder L={L}(N={2*L}) gamma=0 T={T} (exact-ED ref; median rel over t=0.2..2)", flush=True) +print(f"{'mode':9} {'dt':>6} {'knob':>7} {'K':>4} {'median_rel':>10} {'max_rel':>10} {'wall_s':>7} {'peakRSS_mb':>10} {'peak':>9}", flush=True) +for row in sys.argv[1:]: + f = row.split(":") + mode, dt, knob = f[0], float(f[1]), float(f[2]) + kleak = next((float(t[1:]) for t in f[3:] if t.startswith("K")), None) + mb = next((float(t[1:]) for t in f[3:] if t.startswith("M")), None) + ab = next((float(t[1:]) for t in f[3:] if t.startswith("A")), None) + stream = "stream" in f[3:] + d = run_cell(mode, dt, knob, kleak, stream, mb, ab) + ktag = ("-" if kleak is None else f"{kleak:g}") + ("s" if stream else "") + ("" if mb is None else f"/M{mb:g}") + ("" if ab is None else f"/A{ab:g}") + if "fail" in d: + print(f"{mode:9} {dt:>6} {knob:>7.0e} {ktag:>4} FAILED {d['fail'][:120]}", flush=True) + continue + print(f"{mode:9} {dt:>6} {knob:>7.0e} {ktag:>4} {d['med']:>10.2e} {d.get('mx',float('nan')):>10.2e} {d['wall']:>7.1f} {d['rss']:>10.0f} {d['peak']:>9}", flush=True) diff --git a/docs/autotune/2026-07-03-orbit-compare/scan_xy_mid.py b/docs/autotune/2026-07-03-orbit-compare/scan_xy_mid.py new file mode 100644 index 00000000..df1c05cc --- /dev/null +++ b/docs/autotune/2026-07-03-orbit-compare/scan_xy_mid.py @@ -0,0 +1,61 @@ +"""Mid-precision dt x drop_tol (x K for pec) scan at L=7, k=1, T=2, driving the +clean xy-experiments harnesses (main_k_pec_ladder.py / main_k_xy_ladder.py) +instead of orbit_bench_L7.py. Differences vs orbit_bench_L7 that matter: +- Trotter evolves the momentum-MERGED pair (momentum_merge each step, ~L x + fewer terms live) -- the proper symmetry-compressed baseline; orbit_bench + evolved the full real-space pair and merged only at readout. +- pec: same pc_step_orbit_rep kernel (no protected_arr; validated 1.86e-3 + at the L=5 exact-ED gate, matching the ledger). +Peak RSS via /usr/bin/time -l (their psutil attr is end-RSS, not peak). +rel vs exact ED npz (t = 0, .2, ..., 2.0). + +Usage: scan_xy_mid.py [row ...] rows like pec:0.1:3e-3[:K1] trot:0.05:3e-4 +""" +import os, re, subprocess, sys, time +import numpy as np +import h5py + +HERE = os.path.dirname(os.path.abspath(__file__)) +XY = "/Users/alexschuckert/dev/26_ppvm/xy-experiments" +L, K_MODE, T = 7, 1, 2.0 +REF = np.load(os.path.join(HERE, "exact_ref_L7_k1_T2.npz"))["ref"] + +def run_cell(kind, dt, drop, kleak=None): + steps = round(T / dt) + stride = round(0.2 / dt) + out = f"data/scan_{kind}_dt{dt}_drop{drop:g}" + (f"_K{kleak:g}" if kleak else "") + ".h5" + script = "main_k_pec_ladder.py" if kind == "pec" else "main_k_xy_ladder.py" + cmd = ["/usr/bin/time", "-l", "./run", "run", "python", script, + "--L", str(L), "--dt", str(dt), "--steps", str(steps), + "--drop_tol", str(drop), "--ks", str(K_MODE), "--out", out] + # PPVM_K_LEAKAGE was removed from ppvm (2026-07-07): K tokens now convert + # to the explicit --tau_add flag (pec cells only). + if kleak is not None and kind == "pec": + cmd += ["--tau_add", str(kleak * drop / dt)] + t0 = time.time() + r = subprocess.run(cmd, cwd=XY, capture_output=True, text=True) + wall = time.time() - t0 + m = re.search(r"(\d+)\s+maximum resident set size", r.stderr) + rss_mb = int(m.group(1)) / (1024 * 1024) if m else float("nan") + try: + with h5py.File(os.path.join(XY, out)) as h: + C = (h["C_re"][0] + 1j * h["C_im"][0])[::stride] + peak = int(h["n_basis"][0].max()) + except Exception as e: + return dict(fail=str(e) + " / " + r.stdout[-200:] + r.stderr[-200:]) + rel = float(np.linalg.norm(C - REF) / np.linalg.norm(REF)) + return dict(rel=rel, wall=wall, rss=rss_mb, peak=peak) + +rows = sys.argv[1:] +print(f"XY-EXPERIMENTS MID SCAN: ladder L={L}(N={2*L}) k={K_MODE} T={T} (exact-ED ref)", flush=True) +print(f"{'method':8} {'dt':>7} {'drop':>7} {'K':>4} {'rel_err':>9} {'wall_s':>7} {'peakRSS_mb':>10} {'peak':>8}", flush=True) +for row in rows: + f = row.split(":") + kind, dt, drop = f[0], float(f[1]), float(f[2]) + kleak = next((float(t[1:]) for t in f[3:] if t.startswith("K")), None) + d = run_cell(kind, dt, drop, kleak) + ktag = "-" if kleak is None else f"{kleak:g}" + if "fail" in d: + print(f"{kind:8} {dt:>7} {drop:>7.0e} {ktag:>4} FAILED {d['fail'][:120]}", flush=True) + continue + print(f"{kind:8} {dt:>7} {drop:>7.0e} {ktag:>4} {d['rel']:>9.2e} {d['wall']:>7.1f} {d['rss']:>10.0f} {d['peak']:>8}", flush=True) diff --git a/docs/autotune/2026-07-03-orbit-compare/trotter_orbit_verify.py b/docs/autotune/2026-07-03-orbit-compare/trotter_orbit_verify.py new file mode 100644 index 00000000..f76c5ddd --- /dev/null +++ b/docs/autotune/2026-07-03-orbit-compare/trotter_orbit_verify.py @@ -0,0 +1,48 @@ +import numpy as np +from functools import reduce +from ppvm import PauliSum +from ppvm._core import TranslationGroup + +N=8; k=1; dt=0.02; nsnap=5; steps_per=3; mac=1e-10 +phi=2*np.pi*k/N +g=TranslationGroup.chain_1d(N); mom=[k] +bonds=[(j,(j+1)%N) for j in range(N)] + +# exact ED (same as before) +SX=np.array([[0,1],[1,0]],complex);SY=np.array([[0,-1j],[1j,0]],complex);I2=np.eye(2) +def op(s,q):return reduce(np.kron,[s if k2==q else I2 for k2 in range(N)]) +H=np.zeros((2**N,2**N),complex) +for j,nx in bonds: H+=op(SX,j)@op(SX,nx)+op(SY,j)@op(SY,nx) +idx=np.arange(2**N); d=np.zeros(2**N,complex) +for j in range(N): d+=np.exp(-1j*phi*j)*(1-2*((idx>>(N-1-j))&1)) +E,V=np.linalg.eigh(H) +def exact_Ck(t): + U=(V*np.exp(1j*E*t))@V.conj().T; return (np.conj(d)@((np.abs(U)**2)@d))/(np.conj(d)@d) + +# real-pair Trotter, merged each step (orbit-preserving) +def mk(): + R=PauliSum.new(N,[(f"Z{j}", float(np.cos(phi*j))) for j in range(N)],min_abs_coeff=mac,max_pauli_weight=N) + Im=PauliSum.new(N,[(f"Z{j}", float(-np.sin(phi*j))) for j in range(N)],min_abs_coeff=mac,max_pauli_weight=N) + return R,Im +R,Im=mk(); Rs,Ims=mk() # Rs,Ims = seed copies for overlap +Rs.momentum_merge(Ims,g,mom) +norm=Rs.overlap(Rs)+Ims.overlap(Ims) +def Ck(R,Im): + Rm=R.copy() if hasattr(R,'copy') else R + # overlap = (Rs.R + Ims.Im) + i(Rs.Im - Ims.R) + re=Rs.overlap(R)+Ims.overlap(Im); im=Rs.overlap(Im)-Ims.overlap(R) + return (re+1j*im)/norm + +print(f"N={N} k={k} dt={dt} (merged Trotter, near-exact mac={mac})") +print(f"{'t':>6} {'exact':>20} {'trotter-merged':>22}") +for si in range(nsnap+1): + t=si*steps_per*dt + Rc=R.copy(); Imc=Im.copy() + Rc.momentum_merge(Imc,g,mom) + ex=exact_Ck(t); tr=Ck(Rc,Imc) + print(f"{t:6.3f} {ex.real:+.4f}{ex.imag:+.4f}j {tr.real:+.4f}{tr.imag:+.4f}j") + if si drop_tol: + rows.append(zterm_codes(j)) + coeffs.append(c) + basis_arr = np.array(rows, dtype=np.uint8) + coeff = np.array(coeffs, dtype=float) + index = {row.tobytes(): i for i, row in enumerate(basis_arr)} + protected_keys = set(index) + return basis_arr, coeff, index, protected_keys + + +def add_leakage_to_basis(basis_arr, probe_coeff, extend_coeffs, protected_arr, index): + """Compute leakage from `probe_coeff`, add above-threshold strings to + `basis_arr`, and pad each vector in `extend_coeffs` with zeros for the + new rows. Returns ``(basis_arr, [extended...], index, rate_below)`` where + `rate_below` is the l2 norm of the leakage too small to add.""" + leak_basis, leak_coeffs = L_op.leakage_arr(basis_arr, probe_coeff, protected_arr) + if not len(leak_coeffs): + return basis_arr, list(extend_coeffs), index, 0.0 + weights = weights_of(leak_basis) + add_mask = (np.abs(leak_coeffs) > add_tol) & (weights <= max_pauli_weight) + rate_below = float(np.linalg.norm(leak_coeffs[~add_mask])) + if not add_mask.any(): + return basis_arr, list(extend_coeffs), index, rate_below + cand = leak_basis[add_mask] + cand = cand[np.argsort(np.abs(leak_coeffs[add_mask]))[::-1]] + cand = cand[np.array([row.tobytes() not in index for row in cand])] + if max_basis: + cand = cand[: max(max_basis - len(basis_arr), 0)] + if not len(cand): + return basis_arr, list(extend_coeffs), index, rate_below + n0 = len(basis_arr) + basis_arr = np.vstack([basis_arr, cand]) + extended = [np.r_[c, np.zeros(len(cand))] for c in extend_coeffs] + for i, row in enumerate(cand): + index[row.tobytes()] = n0 + i + return basis_arr, extended, index, rate_below + + +def prune_and_cap(basis_arr, coeff, protected_keys): + """Drop below-`drop_tol` coefficients; if `max_basis` is set, cap by + keeping protected rows + largest-magnitude others.""" + keep = np.array( + [(row.tobytes() in protected_keys) or (abs(v) >= drop_tol) + for row, v in zip(basis_arr, coeff)] + ) + basis_arr = basis_arr[keep] + coeff = coeff[keep] + keys = [row.tobytes() for row in basis_arr] + index = {kk: i for i, kk in enumerate(keys)} + protected_keys = {pk for pk in protected_keys if pk in index} + if max_basis and len(basis_arr) > max_basis: + slots = max(max_basis - len(protected_keys), 0) + is_protected = np.array([kk in protected_keys for kk in keys]) + order = sorted(np.where(~is_protected)[0].tolist(), + key=lambda i: abs(coeff[i]), reverse=True) + keep2 = is_protected.copy() + keep2[order[:slots]] = True + basis_arr = basis_arr[keep2] + coeff = coeff[keep2] + keys = [row.tobytes() for row in basis_arr] + index = {kk: i for i, kk in enumerate(keys)} + protected_keys = {pk for pk in protected_keys if pk in index} + return basis_arr, coeff, index, protected_keys + + +# %% [markdown] +# ## Run one $k$-mode +# +# Returns per-step $C_k(t)$, basis size, max weight, and cumulative discarded +# weight (trapezoidal a-posteriori error estimate normalised by +# $\|\mathcal O_k\|$). + +# %% +def run_mode(kk): + basis_arr, coeff, index, protected_keys = init_mode(kk) + # `basis_arr` rebinds to a new ndarray on every vstack/slice; nothing + # mutates the initial array in place, so the target/protected views can + # simply alias it without a copy. + target_arr = basis_arr + target_coeff = coeff + protected_arr = basis_arr + norm0 = float(np.dot(coeff, coeff)) + norm_target = np.sqrt(norm0) + + ck = np.empty(steps + 1) + n_basis_t = np.empty(steps + 1, dtype=np.int64) + max_w_t = np.empty(steps + 1, dtype=np.int64) + discarded_cum = np.zeros(steps + 1) + + for nt in range(steps + 1): + # Overlap with the (fixed) initial target. + c_t = sum(coeff[index[trow.tobytes()]] * tc + for trow, tc in zip(target_arr, target_coeff) + if trow.tobytes() in index) + ck[nt] = c_t / norm0 + n_basis_t[nt] = len(basis_arr) + max_w_t[nt] = int(weights_of(basis_arr).max()) + if nt == steps: + break + + # Predictor: enrich basis with leakage from current state, then + # advance by exp(dt · M). + basis_arr, [coeff], index, rate_before = add_leakage_to_basis( + basis_arr, coeff, [coeff], protected_arr, index + ) + coeff_pre = coeff.copy() + coeff = expm_multiply(dt * generator_sparse(L_op, basis_arr), coeff) + + if predictor_corrector: + # Probe leakage with the predicted state; extend the pre-step + # vector with zeros for the new rows and re-run on the enlarged + # basis. Lifts O(dt²) -> O(dt³). + basis_arr, [coeff_pre], index, _ = add_leakage_to_basis( + basis_arr, coeff, [coeff_pre], protected_arr, index + ) + coeff = expm_multiply(dt * generator_sparse(L_op, basis_arr), coeff_pre) + + # Post-step leakage rate, for the trapezoidal error estimate. + norm2_pre_prune = float(np.dot(coeff, coeff)) + _, leak_after = L_op.leakage_arr(basis_arr, coeff, protected_arr) + rate_after = float(np.linalg.norm(leak_after)) if len(leak_after) else 0.0 + + basis_arr, coeff, index, protected_keys = prune_and_cap( + basis_arr, coeff, protected_keys + ) + # Pruning only removes entries, so the discarded l2 weight is + # sqrt(‖c‖²_pre − ‖c‖²_post). + dropped_w = np.sqrt(max(norm2_pre_prune - float(np.dot(coeff, coeff)), 0.0)) + d_total = 0.5 * dt * (rate_before + rate_after) + dropped_w + discarded_cum[nt + 1] = discarded_cum[nt] + d_total / norm_target + + return ck, n_basis_t, max_w_t, discarded_cum + + +# %% [markdown] +# ## Run all $k$-modes and plot + +# %% +Ck = np.empty((steps + 1, kmax)) +n_basis = np.empty((steps + 1, kmax), dtype=np.int64) +max_weight = np.empty((steps + 1, kmax), dtype=np.int64) +discarded_cum = np.empty((steps + 1, kmax)) +for m, kk in enumerate(k_modes): + Ck[:, m], n_basis[:, m], max_weight[:, m], discarded_cum[:, m] = run_mode(kk) + +# %% +fig, ax = plt.subplots() +for m in range(kmax): + ax.plot(times, Ck[:, m], "o-", ms=3, label=rf"$k = 2\pi\cdot{k_indices[m]}/L$") +ax.set_xlabel("$t$") +ax.set_ylabel(r"$C_k(t)/C_k(0)$") +ax.set_title(f"Adaptive Lindbladian evolution L={L} γ={gamma} α={alpha}") +ax.legend() +plt.tight_layout() +plt.show() + +# %% +fig, ax = plt.subplots(1, 2, figsize=(10, 4)) +for m in range(kmax): + ax[0].plot(times, n_basis[:, m], "o-", ms=3, label=rf"$k_{k_indices[m]}$") + ax[1].semilogy(times, discarded_cum[:, m] + 1e-16, "o-", ms=3, + label=rf"$k_{k_indices[m]}$") +ax[0].set(xlabel="$t$", ylabel="|basis|", title="Basis-size growth") +ax[1].set(xlabel="$t$", ylabel=r"cum. discarded / $||O_k||$", + title="A-posteriori error estimate") +for a in ax: + a.legend() +plt.tight_layout() +plt.show() diff --git a/ppvm-python/demo/lindblad_pc_scaling.py b/ppvm-python/demo/lindblad_pc_scaling.py new file mode 100644 index 00000000..52844215 --- /dev/null +++ b/ppvm-python/demo/lindblad_pc_scaling.py @@ -0,0 +1,185 @@ +# --- +# jupyter: +# jupytext: +# cell_metadata_filter: -all +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.1 +# kernelspec: +# display_name: ppvm (3.12.12) +# language: python +# name: python3 +# --- + +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 + +# %% [markdown] +# # `pc_step` parallel scaling +# +# End-to-end wall-time scaling of the pure-Rust predictor-corrector step +# (`ppvm.Lindbladian.pc_step`) with rayon thread count. The entire `pc_step` +# body — both leakage calls, the action-cache build, and both matrix +# exponentials — runs inside a rayon pool of the requested size: +# +# * leakage and the action cache parallelise over basis elements; +# * the matrix exponential parallelises over SpMV columns. +# +# So the speedup numbers reflect overall PC throughput, not just SpMV. + +# %% +from statistics import median +import time + +import matplotlib.pyplot as plt +import numpy as np + +from ppvm import Lindbladian + + +# %% [markdown] +# ## Parameters + +# %% +L = 51 +J = 1.0 +gamma = 1.0 +alpha = 1.0 +dt = 0.05 +n_steps = 20 +max_basis = 10_000_000 # large: rank cap never binds (full enrichment) +max_cores = 4 +warmup_steps = 4 +model = "long-range" # "nn" or "long-range" + + +# %% [markdown] +# ## Model +# +# All-to-all XY with $1/r^\alpha$ couplings (Kac-normalised) and per-site Z +# dephasing. Long-range activates every bond every step, giving a basis +# size that meaningfully exercises parallel scaling. + +# %% +def build_nn_xy_dephasing(L, J, gamma): + h_terms = [] + for i in range(L - 1): + a, b = i, i + 1 + xs = ["I"] * L + xs[a] = xs[b] = "X" + ys = ["I"] * L + ys[a] = ys[b] = "Y" + h_terms += [("".join(xs), J), ("".join(ys), J)] + jump_terms = [("I" * j + "Z" + "I" * (L - j - 1), gamma) for j in range(L)] + return h_terms, jump_terms + + +def build_long_range_xy_dephasing(L, J, alpha, gamma): + pairs = [ + (a, b, 1.0 / min(b - a, L - b + a) ** alpha) + for a in range(L) + for b in range(a + 1, L) + ] + kac = sum(j for _, _, j in pairs) / L + pairs = [(a, b, j / kac) for a, b, j in pairs] + h_terms = [] + for a, b, j in pairs: + for q in "XY": + term = ["I"] * L + term[a] = term[b] = q + h_terms.append(("".join(term), J * j)) + jump_terms = [("I" * j + "Z" + "I" * (L - j - 1), gamma) for j in range(L)] + return h_terms, jump_terms + + +if model == "nn": + h_terms, jump_terms = build_nn_xy_dephasing(L, J, gamma) +else: + h_terms, jump_terms = build_long_range_xy_dephasing(L, J, alpha, gamma) +L_op = Lindbladian(L, h_terms, jump_terms) + + +# %% [markdown] +# ## Timing harness +# +# Each call to `run_pc_steps` runs `n_steps` consecutive PC steps from +# $Z_{L/2}$, returning the per-step wall times and the final basis size. +# The `num_threads` kwarg pins this call to a freshly-built rayon pool of +# that size, isolating thread-count effects from JIT cache state. + +# %% +def run_pc_steps(L_op, L, site0, dt, n_steps, max_basis, num_threads): + z_strings = ["I" * j + "Z" + "I" * (L - j - 1) for j in range(L)] + basis = [z_strings[site0]] + coeffs = np.array([1.0]) + protected = [z_strings[site0]] + times = [] + for _ in range(n_steps): + t0 = time.perf_counter() + basis, coeffs = L_op.pc_step( + basis, + coeffs, + dt, + max_basis, + protected=protected, + num_threads=num_threads, + ) + times.append(time.perf_counter() - t0) + return times, len(basis) + + +# %% [markdown] +# ## Warmup +# +# Each thread count pre-builds its rayon pool and amortises one-time setup +# before the real timing pass. + +# %% +site0 = L // 2 +for n in range(1, max_cores + 1): + run_pc_steps(L_op, L, site0, dt, warmup_steps, max_basis, n) + + +# %% [markdown] +# ## Scaling sweep + +# %% +results = [] +for n in range(1, max_cores + 1): + times, basis_size = run_pc_steps(L_op, L, site0, dt, n_steps, max_basis, n) + first = times[0] * 1000.0 + steady = median(times[1:]) * 1000.0 + results.append({"threads": n, "first_ms": first, "steady_ms": steady, + "basis": basis_size}) + +baseline = results[0]["steady_ms"] +print(f"{'threads':>8s} {'first-step (ms)':>16s} {'steady (ms)':>12s} " + f"{'speedup':>9s} {'|basis|':>8s}") +for r in results: + speedup = baseline / r["steady_ms"] + print(f"{r['threads']:>8d} {r['first_ms']:>16.1f} {r['steady_ms']:>12.2f} " + f"{speedup:>8.2f}x {r['basis']:>8d}") + + +# %% [markdown] +# ## Plot +# +# Steady-state speedup vs thread count, with the linear-scaling reference. + +# %% +threads = np.array([r["threads"] for r in results]) +steady_ms = np.array([r["steady_ms"] for r in results]) +speedup = steady_ms[0] / steady_ms + +fig, ax = plt.subplots() +ax.plot(threads, speedup, "o-", label="measured") +ax.plot(threads, threads, "k--", alpha=0.4, label="linear") +ax.set_xlabel("threads") +ax.set_ylabel("speedup (vs 1 thread)") +ax.set_title(f"pc_step parallel scaling L={L} model={model} " + f"|basis|={results[-1]['basis']}") +ax.legend() +plt.tight_layout() +plt.show() diff --git a/ppvm-python/pyproject.toml b/ppvm-python/pyproject.toml index e805da44..55ed4156 100644 --- a/ppvm-python/pyproject.toml +++ b/ppvm-python/pyproject.toml @@ -10,6 +10,7 @@ requires-python = ">=3.10" dependencies = [ "bloqade-circuit>=0.14.1", "kirin>=0.0.15", + "numpy>=1.26", ] [build-system] @@ -58,3 +59,19 @@ dev = [ "pytest>=9.0.2", "pytest-benchmark>=5.2.3", ] +# Optional: only the `demo/` scripts use it (`expm_multiply` for the reference +# matrix exponential). Runtime ppvm and the test suite have no scipy dep. +demo = [ + "scipy>=1.13", +] +doc = [ + "griffe-inherited-docstrings>=1.1.1", + "matplotlib>=3.10.8", + "mike>=2.1.3", + "mkdocs>=1.6.1", + "mkdocs-gen-files>=0.5.0", + "mkdocs-jupyter>=0.25.1", + "mkdocs-literate-nav>=0.6.1", + "mkdocs-material>=9.5.44", + "mkdocstrings[python]>=0.27.0", +] diff --git a/ppvm-python/src/ppvm/__init__.py b/ppvm-python/src/ppvm/__init__.py index 8eaff949..28625062 100644 --- a/ppvm-python/src/ppvm/__init__.py +++ b/ppvm-python/src/ppvm/__init__.py @@ -5,6 +5,9 @@ from .generalized_tableau import GeneralizedTableau as GeneralizedTableau from .generalized_tableau import MeasurementResult as MeasurementResult from .generalized_tableau import sample_stim as sample_stim +from .lindblad import Lindbladian as Lindbladian +from .lindblad import sigma_minus as sigma_minus +from .lindblad import sigma_plus as sigma_plus from .paulisum import LossyPauliSum as LossyPauliSum from .paulisum import PauliSum as PauliSum from .squin_interpreter.device import ( diff --git a/ppvm-python/src/ppvm/lindblad.py b/ppvm-python/src/ppvm/lindblad.py new file mode 100644 index 00000000..04e2fe23 --- /dev/null +++ b/ppvm-python/src/ppvm/lindblad.py @@ -0,0 +1,429 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Direct Pauli-Lindbladian time evolution on an adaptive Pauli-string basis. + +Given a Hermitian Pauli Hamiltonian H = Σ c_i P_i and jump operators +L_k = Σ_a λ_{k,a} P_{k,a} (each a complex linear combination of Pauli +strings) with rates γ_k ≥ 0, this module exposes the primitives for +adaptive Heisenberg-picture evolution: + +- ``pc_step(...)`` / ``pc_step_arr(...)``: one adaptive predictor-corrector + step ``O ← exp(dt·L*) O``; ``pc_step_orbit_rep(...)`` is the + translation-symmetric (momentum-sector) variant +- ``action(p)`` / ``action_arr(p)``: L*(p) for one Pauli string p +- ``leakage(basis, coeffs)`` / ``leakage_arr(...)``: off-basis component of + L*(Σ c_j p_j), driving basis expansion +- ``generator(basis)``: COO triples ``(rows, cols, vals)`` for the generator + matrix M such that L* restricted to ``basis`` is ``M @ coeffs``. Users + wanting a sparse matrix can wrap them — e.g. + ``scipy.sparse.coo_matrix((vals, (rows, cols)), shape=(N, N)).tocsc()`` + +The ``*_arr`` variants pass Pauli strings as ``(N, n_qubits)`` ``uint8`` +arrays of Pauli codes (``0=I, 1=X, 2=Z, 3=Y``) and skip string +construction entirely — at ~10^5 basis rows per evolution step, per-row +``str.join`` dominates wall time. + +Each jump term can be either: + +- a single Hermitian Pauli (`("ZZII", γ)`), routed to a fast diagonal path, + or +- a complex Pauli sum (`([("XIII", 0.5+0j), ("YIII", 0+0.5j)], γ)`) to + describe e.g. amplitude-damping (`σ⁻`) and excitation (`σ⁺`) operators. + +For the general case the dissipator +``γ ( L† p L − ½ {L†L, p} )`` is evaluated directly; the L†L Pauli +expansion is precomputed once at construction. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Sequence +from typing import Union + +import numpy as np +from ._core import LindbladSpec as _LindbladSpec + +_PAULI_CODE = {"I": 0, "X": 1, "Z": 2, "Y": 3} +# Lookup table mapping code -> ASCII byte for vectorised string output. +_CODE_TO_ASCII = np.array([ord("I"), ord("X"), ord("Z"), ord("Y")], dtype=np.uint8) + +# A jump operator is either a Hermitian Pauli (single string) or a complex +# linear combination of Pauli strings. +PauliLincomb = Iterable[tuple[str, complex]] +JumpSpec = Union[tuple[str, float], tuple[PauliLincomb, float]] + + +def _string_to_codes(s: str, n_qubits: int) -> np.ndarray: + """Encode a Pauli string ``"IXYZ..."`` as a length-``n_qubits`` uint8 array. + + Underscores in the input are ignored, matching the Rust parser + (``parse_pauli_string`` in `ppvm-lindblad`) so users can write + ``"X_Y_Z"`` for readability. + """ + s_clean = s.replace("_", "") + if len(s_clean) != n_qubits: + raise ValueError( + f"Pauli string {s!r} has length {len(s_clean)} (after stripping '_') " + f"!= n_qubits {n_qubits}" + ) + try: + return np.array([_PAULI_CODE[c] for c in s_clean], dtype=np.uint8) + except KeyError as exc: + bad = exc.args[0] + raise ValueError( + f"Pauli string {s!r} contains invalid character {bad!r}; " + f"expected one of 'I', 'X', 'Y', 'Z' (and '_' is allowed as a separator)" + ) from None + + +def _codes_to_string(codes: np.ndarray) -> str: + """Decode one length-``n_qubits`` row of Pauli codes back to a string.""" + return _CODE_TO_ASCII[codes].tobytes().decode("ascii") + + +def _basis_to_codes(basis: Sequence[str], n_qubits: int) -> np.ndarray: + """Stack a sequence of Pauli strings into an ``(N, n_qubits)`` uint8 array.""" + arr = np.zeros((len(basis), n_qubits), dtype=np.uint8) + for i, s in enumerate(basis): + arr[i] = _string_to_codes(s, n_qubits) + return arr + + +def _codes_to_basis(arr: np.ndarray) -> list[str]: + """Inverse of :func:`_basis_to_codes`. One call into C per row.""" + bytes_per_row = _CODE_TO_ASCII[arr].tobytes() + n = arr.shape[1] + return [bytes_per_row[i * n : (i + 1) * n].decode("ascii") for i in range(arr.shape[0])] + + +def sigma_plus(site: int, n_qubits: int) -> list[tuple[str, complex]]: + """``σ⁺_q = (X_q + i Y_q) / 2``. Use as a Lindblad jump for excitation.""" + if not 0 <= site < n_qubits: + raise ValueError(f"site {site} out of range for n_qubits={n_qubits}") + x_str = "I" * site + "X" + "I" * (n_qubits - site - 1) + y_str = "I" * site + "Y" + "I" * (n_qubits - site - 1) + return [(x_str, 0.5 + 0.0j), (y_str, 0.0 + 0.5j)] + + +def sigma_minus(site: int, n_qubits: int) -> list[tuple[str, complex]]: + """``σ⁻_q = (X_q − i Y_q) / 2``. Use as a Lindblad jump for amplitude damping.""" + if not 0 <= site < n_qubits: + raise ValueError(f"site {site} out of range for n_qubits={n_qubits}") + x_str = "I" * site + "X" + "I" * (n_qubits - site - 1) + y_str = "I" * site + "Y" + "I" * (n_qubits - site - 1) + return [(x_str, 0.5 + 0.0j), (y_str, 0.0 - 0.5j)] + + +def _normalize_jump(jump_op: object) -> list[tuple[str, float, float]]: + """Convert a user-supplied jump operator to ``[(pauli_str, re, im), ...]``. + + Accepts either a single Pauli string (treated as a Hermitian-Pauli jump + with coefficient 1) or an iterable of ``(pauli_str, complex_coeff)`` + pairs. + """ + if isinstance(jump_op, str): + return [(jump_op, 1.0, 0.0)] + out: list[tuple[str, float, float]] = [] + for term in jump_op: + s, c = term + cc = complex(c) + out.append((str(s), float(cc.real), float(cc.imag))) + if not out: + raise ValueError("jump operator lincomb must contain at least one Pauli term") + return out + + +class Lindbladian: + """Pre-compiled adjoint Pauli-Lindbladian acting on Pauli strings. + + Parameters + ---------- + n_qubits: + Number of qubits. + h_terms: + Iterable of ``(pauli_string, coefficient)`` pairs for the + Hermitian Hamiltonian ``H = Σ c_i P_i``. Each ``pauli_string`` is + a length-``n_qubits`` ``str`` over ``"IXYZ"``. + jump_terms: + Iterable of ``(jump_op, rate)`` pairs. ``jump_op`` is either a + Pauli string ``"XYZI..."`` (treated as a Hermitian-Pauli jump + with coefficient 1, hitting the fast path) or an iterable of + ``(pauli_string, complex_coeff)`` pairs for a general complex + Pauli linear combination such as :func:`sigma_plus` or + :func:`sigma_minus`. ``rate`` is the non-negative GKSL rate + ``γ_k``. + + Examples + -------- + Dephasing (Hermitian Pauli): + + >>> Lindbladian(2, [("XX", 1.0)], [("ZI", 0.3), ("IZ", 0.3)]) + + Amplitude damping on site 0 (non-Hermitian): + + >>> jumps = [(sigma_minus(0, 2), 0.5)] + >>> Lindbladian(2, [("XX", 1.0)], jumps) + """ + + def __init__( + self, + n_qubits: int, + h_terms: Iterable[tuple[str, float]], + jump_terms: Iterable[tuple[object, float]] = (), + ): + self.n_qubits = int(n_qubits) + h_strs: list[str] = [] + h_coeffs: list[float] = [] + for s, c in h_terms: + h_strs.append(s) + h_coeffs.append(float(c)) + j_lincombs: list[list[tuple[str, float, float]]] = [] + j_rates: list[float] = [] + for jump_op, rate in jump_terms: + j_lincombs.append(_normalize_jump(jump_op)) + j_rates.append(float(rate)) + self._spec = _LindbladSpec(self.n_qubits, h_strs, h_coeffs, j_lincombs, j_rates) + + @property + def num_h_terms(self) -> int: + return self._spec.num_h_terms + + @property + def num_jump_terms(self) -> int: + return self._spec.num_jump_terms + + # ── Pure-ndarray hot path ── + + def action_arr(self, p: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Apply ``L*`` to a single Pauli string given as uint8 codes. + + Returns ``(out_basis, out_coeffs)``: a ``(M, n_qubits)`` uint8 + array and a length-``M`` float64 array. + """ + return self._spec.action(np.ascontiguousarray(p, dtype=np.uint8)) + + def leakage_arr( + self, + basis_arr: np.ndarray, + coeffs: np.ndarray, + protected_arr: np.ndarray | None = None, + ) -> tuple[np.ndarray, np.ndarray]: + """Off-basis component of ``L*( Σ_j coeffs[j] basis[j] )``. + + ``basis_arr``: ``(N, n_qubits)`` uint8. ``coeffs``: length-N float64. + ``protected_arr``: optional ``(K, n_qubits)`` uint8 of Pauli strings + that must NEVER appear in the leakage output. + + Returns ``(out_basis, out_coeffs)`` packed the same way as + :meth:`action_arr`. + """ + n = self.n_qubits + if protected_arr is None: + protected_arr = np.zeros((0, n), dtype=np.uint8) + return self._spec.leakage( + np.ascontiguousarray(basis_arr, dtype=np.uint8), + np.ascontiguousarray(coeffs, dtype=np.float64), + np.ascontiguousarray(protected_arr, dtype=np.uint8), + ) + + def pc_step_arr( + self, + basis_arr: np.ndarray, + coeffs: np.ndarray, + dt: float, + max_basis: int, + drop_tol: float = 1e-12, + protected_arr: np.ndarray | None = None, + num_threads: int | None = None, + admit_basis: int | None = None, + tau_add: float | None = None, + ) -> tuple[np.ndarray, np.ndarray]: + """One predictor-corrector adaptive step. + + All work — leakage expansion, matrix-exponential step, second-hop + re-expansion, corrector — runs in Rust; SciPy is not required. + The matrix-exponential action is computed matrix-free via the + external ``quspin-expm`` crate. + + Truncation. ``max_basis`` is a hard rank cap on the live basis: + enrichment adds at most ``max_basis - len(basis)`` of the largest + leakage strings, and the post-step basis is trimmed to the + top-``max_basis`` entries by ``|coeff|`` (``protected`` words always + kept). Pass a large value (e.g. ``10_000_000``) for the near-exact, + uncapped case. ``drop_tol`` additionally prunes basis entries whose + absolute coefficient is below the threshold after the corrector + (unless the word is ``protected``). + + ``num_threads``, when set, pins this call to a freshly-built rayon + pool of that size — useful for benchmarking parallel scaling. + + ``tau_add``, when set, filters leakage admission by an absolute + coefficient-rate threshold (a dt- and drop_tol-independent + parameterization). Off by default — with cap-based truncation it + is at most a modest wall optimization. + + ``admit_basis``, when set (must be >= ``max_basis``), bounds the + enriched working set during the step instead of ``max_basis``: the + step may hold up to ``admit_basis`` strings transiently, and the + final truncation keeps the top-``max_basis`` by evolved ``|coeff|`` + over the whole union (retained + newly admitted) — rank + displacement, so no ``drop_tol`` is needed to sustain membership + turnover. With the default ``None``, admission is bounded by + ``max_basis`` and turnover requires ``drop_tol > 0``. + + Returns ``(new_basis_arr, new_coeffs)``; the basis may have grown + (or shrunk, if ``max_basis`` / ``drop_tol`` pruned entries). + """ + n = self.n_qubits + if protected_arr is None: + protected_arr = np.zeros((0, n), dtype=np.uint8) + return self._spec.pc_step( + np.ascontiguousarray(basis_arr, dtype=np.uint8), + np.ascontiguousarray(coeffs, dtype=np.float64), + float(dt), + int(max_basis), + float(drop_tol), + np.ascontiguousarray(protected_arr, dtype=np.uint8), + None if num_threads is None else int(num_threads), + None if admit_basis is None else int(admit_basis), + None if tau_add is None else float(tau_add), + ) + + def pc_step_orbit_rep( + self, + basis_arr: np.ndarray, + coeffs: np.ndarray, + dt: float, + max_basis: int, + group, + momentum: np.ndarray, + drop_tol: float = 1e-12, + protected_arr: np.ndarray | None = None, + canonicalize_first: bool = False, + admit_basis: int | None = None, + tau_add: float | None = None, + ) -> tuple[np.ndarray, np.ndarray]: + """Per-step orbit-representative pc evolution. + + State lives entirely in orbit-rep form throughout: ``basis_arr`` + contains only canonical translation-orbit representatives, + ``coeffs`` are complex, and the action is phase-aware. The basis + is ~``|group|×`` smaller than the equivalent full-basis complex + evolution, and the reduction persists across every step. + + Truncation. ``max_basis`` is a hard rank cap on the live orbit-rep + basis: enrichment adds at most ``max_basis - len(basis)`` of the + largest leakage reps, and the post-step basis is trimmed to the + top-``max_basis`` reps by ``|c|`` (``protected`` reps always kept). + Pass a large value (e.g. ``10_000_000``) for the near-exact, + uncapped case. ``drop_tol`` additionally prunes reps whose absolute + coefficient is below the threshold after the corrector. + + ``admit_basis``, when set (>= ``max_basis``), bounds the enriched + working set instead of ``max_basis``: the step may hold up to + ``admit_basis`` reps transiently and the final truncation keeps the + top-``max_basis`` by evolved ``|c|`` over the whole union — the + displacement scheme, matching the real-space ``pc_step_arr``. + + ``basis_arr`` is assumed to contain canonical reps only. Pass + ``canonicalize_first=True`` to rewrite each row to its canonical + rep on entry (coefficients unchanged). + """ + n = self.n_qubits + if protected_arr is None: + protected_arr = np.zeros((0, n), dtype=np.uint8) + return self._spec.pc_step_orbit_rep( + np.ascontiguousarray(basis_arr, dtype=np.uint8), + np.ascontiguousarray(coeffs, dtype=np.complex128), + float(dt), + int(max_basis), + group, + np.ascontiguousarray(momentum, dtype=np.int32), + float(drop_tol), + np.ascontiguousarray(protected_arr, dtype=np.uint8), + bool(canonicalize_first), + None if admit_basis is None else int(admit_basis), + None if tau_add is None else float(tau_add), + ) + + def pc_step( + self, + basis: Sequence[str], + coeffs: np.ndarray, + dt: float, + max_basis: int, + drop_tol: float = 1e-12, + protected: Sequence[str] | None = None, + num_threads: int | None = None, + ) -> tuple[list[str], np.ndarray]: + """String-keyed variant of :meth:`pc_step_arr`.""" + n = self.n_qubits + basis_arr = _basis_to_codes(basis, n) + protected_arr = ( + _basis_to_codes(list(protected), n) if protected else np.zeros((0, n), dtype=np.uint8) + ) + new_basis_arr, new_coeffs = self.pc_step_arr( + basis_arr, + coeffs, + dt, + max_basis, + drop_tol, + protected_arr, + num_threads, + ) + return _codes_to_basis(new_basis_arr), new_coeffs + + def generator_arr( + self, basis_arr: np.ndarray + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Generator matrix as COO triples ``(rows, cols, vals)``. + + Basis given as uint8 codes. To get a SciPy sparse matrix: + + >>> import scipy.sparse as sp + >>> rows, cols, vals = L_op.generator_arr(basis_arr) + >>> M = sp.coo_matrix( + ... (vals, (rows, cols)), shape=(len(basis_arr), len(basis_arr)) + ... ).tocsc() + """ + return self._spec.generator(np.ascontiguousarray(basis_arr, dtype=np.uint8)) + + # ── String-keyed convenience API (slower; for tests / display) ── + + def action(self, p: str) -> dict[str, float]: + """Apply ``L*`` to a single Pauli string ``p`` (string-keyed dict).""" + codes = _string_to_codes(p, self.n_qubits) + out_basis, out_coeffs = self._spec.action(codes) + keys = _codes_to_basis(out_basis) + return {k: float(v) for k, v in zip(keys, out_coeffs) if v != 0.0} + + def leakage( + self, + basis: Sequence[str], + coeffs: np.ndarray, + protected: Sequence[str] | None = None, + ) -> dict[str, float]: + """Off-basis leakage as a ``dict[str, float]`` (slower API).""" + n = self.n_qubits + basis_arr = _basis_to_codes(basis, n) + protected_arr = ( + _basis_to_codes(list(protected), n) if protected else np.zeros((0, n), dtype=np.uint8) + ) + out_basis, out_coeffs = self._spec.leakage( + basis_arr, + np.ascontiguousarray(coeffs, dtype=np.float64), + protected_arr, + ) + keys = _codes_to_basis(out_basis) + return {k: float(v) for k, v in zip(keys, out_coeffs) if v != 0.0} + + def generator( + self, basis: Sequence[str] + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Generator matrix as COO triples ``(rows, cols, vals)``, + basis given as strings. See :meth:`generator_arr` for the conversion + to a SciPy sparse matrix.""" + n = self.n_qubits + basis_arr = _basis_to_codes(basis, n) + return self.generator_arr(basis_arr) diff --git a/ppvm-python/src/ppvm/paulisum.py b/ppvm-python/src/ppvm/paulisum.py index dca573d8..b367a724 100644 --- a/ppvm-python/src/ppvm/paulisum.py +++ b/ppvm-python/src/ppvm/paulisum.py @@ -386,6 +386,50 @@ def trace(self, pattern: str) -> float: """ return self._interface.trace(pattern) + def symmetry_merge(self, group) -> None: + """Merge entries into orbit-representative form under a translation group. + + Each Pauli word in the sum is replaced by its canonical (lex-min) + representative under the action of ``group``; coefficients of words + that collapse to the same representative are summed. Entry count + reduces by up to ``|group|×`` for translation-invariant operators. + + For a translation-invariant dynamics that you apply between + merging steps, this preserves all ``group``-invariant expectation + values (Theorem 1 of Teng et al., arXiv:2512.12094). Plain + real-coefficient merge — handles the trivial (``k=0``) momentum + sector only. + + Args: + group: A `ppvm._core.TranslationGroup` + (use ``TranslationGroup.chain_1d(n)``, ``.torus_2d``, + ``.torus_3d``, ``.ladder``, or ``.from_generators``). + """ + self._interface.symmetry_merge(group) + + def momentum_merge(self, other: "PauliSum", group, momentum) -> None: + """Phase-aware (momentum-sector) merge for a complex operator stored + as a *real pair*: ``self`` is the real part and ``other`` the + imaginary part of ``O = self + i·other``. Both are overwritten in + place with the orbit-representative form projected onto momentum + sector ``momentum``. + + Generalizes `symmetry_merge` to non-trivial momentum sectors + (``k != 0``) while keeping real coefficients on both PauliSums — the + only complex arithmetic is the internal character-weighted fold. + ``self`` and ``other`` must be distinct objects with the same qubit + count. Exact after a translation-covariant gate layer; under a + generic Trotter step it carries the same ``O(dt^{p+1})`` equivariance + error as the ``k=0`` merge. + + Args: + other: the PauliSum holding the imaginary component (modified in place). + group: a `ppvm._core.TranslationGroup`. + momentum: sequence of integer modes, one per group generator + (e.g. ``[k]`` for a 1D chain; ``[0, ...]`` is the trivial sector). + """ + self._interface.momentum_merge(other._interface, group, list(momentum)) + def amplitude_damping(self, addr0: int, gamma: float, *, truncate: bool = True): """Apply an amplitude-damping channel. diff --git a/ppvm-python/test/lindblad/__init__.py b/ppvm-python/test/lindblad/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ppvm-python/test/lindblad/_helpers.py b/ppvm-python/test/lindblad/_helpers.py new file mode 100644 index 00000000..49cb94a5 --- /dev/null +++ b/ppvm-python/test/lindblad/_helpers.py @@ -0,0 +1,346 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 +"""Shared helpers for the Lindbladian tests. + +Two reference kernels live here: + +- :func:`_reference_action` builds `L*(p)` for **Hermitian-Pauli** jumps via + the single-qubit Pauli multiplication table (cheap; only depends on `p`'s + weight). +- :func:`_dense_action` builds `L*(p)` for **arbitrary jump operators** by + constructing the full 2^L × 2^L dense Liouvillian. Only viable for L ≤ 3, + but it makes no assumption about the shape of the jumps. + +The bilinear NN-XY + Z-dephasing reference :func:`_bilinear_nn_xy_z_dephasing_obc` +gives an exact closed-form answer that the predictor-corrector tests converge +toward as dt → 0. +""" + +from __future__ import annotations + +import numpy as np + +# --- Pauli multiplication table for the Hermitian-Pauli reference ----------- +I, X, Y, Z = range(4) # noqa: E741 (I is standard Pauli notation here) +MUL = { + (I, I): (1, I), (I, X): (1, X), (I, Y): (1, Y), (I, Z): (1, Z), + (X, I): (1, X), (X, X): (1, I), (X, Y): (1j, Z), (X, Z): (-1j, Y), + (Y, I): (1, Y), (Y, X): (-1j, Z), (Y, Y): (1, I), (Y, Z): (1j, X), + (Z, I): (1, Z), (Z, X): (1j, Y), (Z, Y): (-1j, X), (Z, Z): (1, I), +} +CODE = {I: "I", X: "X", Y: "Y", Z: "Z"} +ICODE = {"I": I, "X": X, "Y": Y, "Z": Z} + + +def _str_to_tuple(s): + return tuple(ICODE[ch] for ch in s) + + +def _tuple_to_str(t): + return "".join(CODE[ch] for ch in t) + + +def _mul_pauli(p, q): + """Return ``(phase, p·q)`` for two Pauli strings given as code tuples.""" + phase = 1 + r = list(p) + for i, (pi, qi) in enumerate(zip(p, q)): + ph, rr = MUL[(pi, qi)] + phase *= ph + r[i] = rr + return phase, tuple(r) + + +def _reference_action(p_str, h_terms, jump_terms): + """``L*(p) = i[H, p] + sum_k gamma_k (L_k p L_k - p)``, term by term.""" + p = _str_to_tuple(p_str) + out: dict = {} + for h_str, coeff_h in h_terms: + h = _str_to_tuple(h_str) + ph_pp, pp = _mul_pauli(h, p) + ph_pq, _ = _mul_pauli(p, h) + # i[H, p] = i (Hp - pH) = i (ph_pp - ph_pq) r ; real for Hermitian H, p. + coeff = (1j * coeff_h * (ph_pp - ph_pq)).real + if coeff: + out[pp] = out.get(pp, 0.0) + coeff + for j_str, gamma in jump_terms: + j = _str_to_tuple(j_str) + ph_pp, _ = _mul_pauli(j, p) + # Hermitian Pauli L, p: Lp has imaginary phase iff they anti-commute, + # and then L p L = -p, contributing -2 gamma p. + if abs(ph_pp.imag) > 0.5: + out[p] = out.get(p, 0.0) + (-2.0 * gamma) + return {kk: v for kk, v in out.items() if v} + + +def _to_str_dict(d): + return {_tuple_to_str(kk): v for kk, v in d.items()} + + +# --- model builders --------------------------------------------------------- + + +def xy_dephasing(L, alpha, gamma): + """Long-range XY model with PBC + Z dephasing.""" + pairs = [ + (a, b, 1.0 / min(b - a, L - b + a) ** alpha) for a in range(L) for b in range(a + 1, L) + ] + kac = sum(j for _, _, j in pairs) / L + pairs = [(a, b, j / kac) for a, b, j in pairs] + h_terms = [] + for a, b, j in pairs: + for q in "XY": + term = ["I"] * L + term[a] = term[b] = q + h_terms.append(("".join(term), j)) + jump_terms = [("I" * i + "Z" + "I" * (L - i - 1), gamma) for i in range(L)] + return h_terms, jump_terms + + +def tfim_xdeph(L, J, h, gamma): + """TFIM (ZZ + transverse X) with X dephasing.""" + h_terms = [] + for i in range(L - 1): + term = ["I"] * L + term[i] = term[i + 1] = "Z" + h_terms.append(("".join(term), J)) + for i in range(L): + term = ["I"] * L + term[i] = "X" + h_terms.append(("".join(term), h)) + jump_terms = [("I" * i + "X" + "I" * (L - i - 1), gamma) for i in range(L)] + return h_terms, jump_terms + + +def nn_xy_z_dephasing_obc(L, J, gamma): + """Nearest-neighbour XY (OBC) + per-site Z dephasing.""" + h_terms = [] + for i in range(L - 1): + a, b = i, i + 1 + xs = ["I"] * L + xs[a] = xs[b] = "X" + ys = ["I"] * L + ys[a] = ys[b] = "Y" + h_terms += [("".join(xs), J), ("".join(ys), J)] + jump_terms = [("I" * j + "Z" + "I" * (L - j - 1), gamma) for j in range(L)] + return h_terms, jump_terms + + +def random_pauli_str(rng, L): + chars = ["I"] * L + positions = rng.choice(L, size=int(rng.integers(1, L + 1)), replace=False) + for q in positions: + chars[q] = rng.choice(["X", "Y", "Z"]) + return "".join(chars) + + +def assert_action_matches(L_op, h_terms, jump_terms, strings): + """Compare `L_op.action(p)` against :func:`_reference_action` for each `p`.""" + for p in strings: + got = L_op.action(p) + want = _to_str_dict(_reference_action(p, h_terms, jump_terms)) + for kk in set(got) | set(want): + assert abs(got.get(kk, 0.0) - want.get(kk, 0.0)) < 1e-12, ( + f"action mismatch at p={p!r} k={kk!r}: " + f"shim={got.get(kk, 0.0)} ref={want.get(kk, 0.0)}" + ) + + +# --- dense Liouvillian reference (for non-Hermitian jumps) ------------------ + +_DENSE_PAULI = { + "I": np.eye(2, dtype=complex), + "X": np.array([[0, 1], [1, 0]], dtype=complex), + "Y": np.array([[0, -1j], [1j, 0]], dtype=complex), + "Z": np.array([[1, 0], [0, -1]], dtype=complex), +} +SIGMA_MINUS_MAT = np.array([[0, 0], [1, 0]], dtype=complex) +SIGMA_PLUS_MAT = np.array([[0, 1], [0, 0]], dtype=complex) + + +def pauli_mat(s): + """Dense matrix for Pauli string ``s`` (leftmost char = leftmost factor).""" + M = np.array([[1.0]], dtype=complex) + for c in s: + M = np.kron(M, _DENSE_PAULI[c]) + return M + + +def all_strings(L): + if L == 0: + return [""] + sub = all_strings(L - 1) + return [c + s for c in "IXYZ" for s in sub] + + +def dense_action(H, jumps, p_str, L): + """`L*(p)` computed densely, returned as a real Pauli-coefficient dict.""" + p_mat = pauli_mat(p_str) + # macOS Accelerate emits spurious divide warnings on exact zeros. + with np.errstate(divide="ignore", invalid="ignore", over="ignore"): + out_mat = 1j * (H @ p_mat - p_mat @ H) + for Lop, gamma in jumps: + Ld = Lop.conj().T + out_mat += gamma * ( + Ld @ p_mat @ Lop - 0.5 * (Ld @ Lop @ p_mat + p_mat @ Ld @ Lop) + ) + d = 2**L + out = {} + for q_str in all_strings(L): + coef = np.trace(pauli_mat(q_str) @ out_mat) / d + assert abs(coef.imag) < 1e-9, f"non-real coefficient for {q_str}: {coef}" + if abs(coef.real) > 1e-12: + out[q_str] = coef.real + return out + + +def embed_op(op_1q, site, L): + """Embed a single-qubit dense operator at ``site`` of an L-qubit register.""" + eye = _DENSE_PAULI["I"] + M = np.array([[1.0]], dtype=complex) + for j in range(L): + M = np.kron(M, op_1q if j == site else eye) + return M + + +# --- closed bilinear evolution for NN-XY + Z-dephasing (OBC) ---------------- + + +def bilinear_nn_xy_z_dephasing_obc(L, J, gamma, times, site0): + """Closed bilinear evolution of `C_j(t)` for the NN XY + Z-dephasing model + with open boundary conditions. + + `F_{mn}(t) = 2^{-L} Tr[B_{mn}(t) Z_i]` evolves as + `∂_t F_{mn} = i·2J·(F_{m+1,n}+F_{m-1,n}-F_{m,n+1}-F_{m,n-1}) - 4γ(1-δ_{mn})F_{mn}` + on the L×L lattice; OBC means edge terms (m=0 or m=L-1, etc.) drop. + Z_j = I - 2 n_j gives C_j = -2 F_{jj}. + """ + dim = L * L + + def idx(m, n): + return m * L + n + + gen = np.zeros((dim, dim), dtype=complex) + for m in range(L): + for n in range(L): + row = idx(m, n) + if m + 1 < L: + gen[row, idx(m + 1, n)] += 1j * 2 * J + if m - 1 >= 0: + gen[row, idx(m - 1, n)] += 1j * 2 * J + if n + 1 < L: + gen[row, idx(m, n + 1)] += -1j * 2 * J + if n - 1 >= 0: + gen[row, idx(m, n - 1)] += -1j * 2 * J + if m != n: + gen[row, row] += -4 * gamma + + evals, evecs = np.linalg.eig(gen) + evecs_inv = np.linalg.inv(evecs) + f0 = np.zeros(dim, dtype=complex) + f0[idx(site0, site0)] = -0.5 + coeffs = evecs_inv @ f0 + + corr = np.empty((len(times), L)) + diag = [idx(j, j) for j in range(L)] + for nt, t in enumerate(times): + ft = evecs @ (np.exp(evals * t) * coeffs) + corr[nt] = np.real(-2 * ft[diag]) + return corr + + +# --- numpy-only matrix exponential reference -------------------------------- + + +def coo_to_dense(triples, n_basis): + """Build a dense ``(n_basis, n_basis)`` array from COO triples.""" + rows, cols, vals = triples + M = np.zeros((n_basis, n_basis), dtype=float) + M[rows, cols] = vals + return M + + +def expm_mv_dense(M, v): + """``exp(M) @ v`` via numpy eigendecomposition. Independent of the Rust + Al-Mohy & Higham implementation; small bases only. + + The Lindbladian is generally diagonalizable, so + ``M = V diag(λ) V^{-1}`` and ``exp(M) v = V (exp(λ) ⊙ (V^{-1} v))``. + """ + evals, evecs = np.linalg.eig(M) + rhs = np.linalg.solve(evecs, v.astype(complex)) + return np.real(evecs @ (np.exp(evals) * rhs)) + + +# --- adaptive PC evolution reference (used by test_adaptive_pc.py) ---------- + + +def _generator_dense(L_op, basis): + """`L_op.generator(basis)` returns COO triples; convert to dense.""" + return coo_to_dense(L_op.generator(basis), len(basis)) + + +def adaptive_z_correlator(L_op, L, site0, dt, n_steps, tau_add): + """Adaptive Heisenberg-picture evolution of Z_{site0} on a growing basis. + + First-hop only: each step adds the strings from `L_op.leakage(...)`, + then matrix-exponentiates the (small, dense) restricted generator via + numpy eigendecomposition. Local truncation is O(dt²) per step. + """ + z_strings = ["I" * j + "Z" + "I" * (L - j - 1) for j in range(L)] + basis = [z_strings[site0]] + coeffs = np.array([1.0]) + protected = [z_strings[site0]] + + corr = np.zeros((n_steps + 1, L)) + corr[0, site0] = 1.0 + + for step in range(n_steps): + leak = L_op.leakage(basis, coeffs, protected=protected) + new = [k for k, v in leak.items() if abs(v) > tau_add] + if new: + basis = basis + new + coeffs = np.concatenate([coeffs, np.zeros(len(new))]) + M = _generator_dense(L_op, basis) + coeffs = expm_mv_dense(dt * M, coeffs) + index = {s: i for i, s in enumerate(basis)} + for j in range(L): + if z_strings[j] in index: + corr[step + 1, j] = coeffs[index[z_strings[j]]] + return corr + + +def adaptive_z_correlator_pc(L_op, L, site0, dt, n_steps, tau_add): + """Same as :func:`adaptive_z_correlator` but with predictor-corrector + basis expansion: predict, then add the second-hop leakage strings before + redoing the step. Lifts the per-step truncation from O(dt²) to O(dt³). + """ + z_strings = ["I" * j + "Z" + "I" * (L - j - 1) for j in range(L)] + basis = [z_strings[site0]] + coeffs = np.array([1.0]) + protected = [z_strings[site0]] + + corr = np.zeros((n_steps + 1, L)) + corr[0, site0] = 1.0 + + for step in range(n_steps): + leak = L_op.leakage(basis, coeffs, protected=protected) + new = [k for k, v in leak.items() if abs(v) > tau_add] + if new: + basis = basis + new + coeffs = np.concatenate([coeffs, np.zeros(len(new))]) + coeffs_pre = coeffs.copy() + M = _generator_dense(L_op, basis) + coeffs_predict = expm_mv_dense(dt * M, coeffs) + leak2 = L_op.leakage(basis, coeffs_predict, protected=protected) + new2 = [k for k, v in leak2.items() if abs(v) > tau_add] + if new2: + basis = basis + new2 + coeffs_pre = np.concatenate([coeffs_pre, np.zeros(len(new2))]) + M = _generator_dense(L_op, basis) + coeffs = expm_mv_dense(dt * M, coeffs_pre) + index = {s: i for i, s in enumerate(basis)} + for j in range(L): + if z_strings[j] in index: + corr[step + 1, j] = coeffs[index[z_strings[j]]] + return corr diff --git a/ppvm-python/test/lindblad/test_action_generator.py b/ppvm-python/test/lindblad/test_action_generator.py new file mode 100644 index 00000000..7b571c9c --- /dev/null +++ b/ppvm-python/test/lindblad/test_action_generator.py @@ -0,0 +1,108 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 +"""Cross-checks for :meth:`Lindbladian.action` / :meth:`Lindbladian.generator` +/ :meth:`Lindbladian.leakage` against the Hermitian-Pauli reference built from +the single-qubit Pauli multiplication table. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from ppvm import Lindbladian + +from ._helpers import ( + _reference_action, + _tuple_to_str, + assert_action_matches, + coo_to_dense, + expm_mv_dense, + random_pauli_str, + tfim_xdeph, + xy_dephasing, +) + + +def test_action_xy_dephasing(): + L = 8 + h_terms, jump_terms = xy_dephasing(L, alpha=1.0, gamma=0.3) + L_op = Lindbladian(L, h_terms, jump_terms) + rng = np.random.default_rng(42) + strings = ["I" * L] + strings += ["I" * i + "Z" + "I" * (L - i - 1) for i in range(L)] + strings += [random_pauli_str(rng, L) for _ in range(20)] + assert_action_matches(L_op, h_terms, jump_terms, strings) + + +def test_action_tfim_xdephasing(): + L = 6 + h_terms, jump_terms = tfim_xdeph(L, J=0.7, h=0.4, gamma=0.2) + L_op = Lindbladian(L, h_terms, jump_terms) + rng = np.random.default_rng(7) + strings = [random_pauli_str(rng, L) for _ in range(30)] + assert_action_matches(L_op, h_terms, jump_terms, strings) + + +def test_generator_leakage_and_expm(): + L = 5 + dt = 0.1 + h_terms, jump_terms = xy_dephasing(L, alpha=1.0, gamma=0.4) + L_op = Lindbladian(L, h_terms, jump_terms) + basis = ["I" * i + "Z" + "I" * (L - i - 1) for i in range(L)] + basis += ["YIYII", "IYIYI", "ZZIII"] + coeffs = np.array([0.5, -0.3, 0.2, 0.4, -0.1, 0.6, 0.2, 0.1]) + + M_shim = coo_to_dense(L_op.generator(basis), len(basis)) + M_ref = np.zeros((len(basis), len(basis))) + index = {p: i for i, p in enumerate(basis)} + for col, p in enumerate(basis): + for r_tuple, v in _reference_action(p, h_terms, jump_terms).items(): + r = _tuple_to_str(r_tuple) + if r in index: + M_ref[index[r], col] += v + assert np.max(np.abs(M_shim - M_ref)) < 1e-12 + + shim_leak = L_op.leakage(basis, coeffs) + ref_leak: dict = {} + for p, cf in zip(basis, coeffs): + for r_tuple, v in _reference_action(p, h_terms, jump_terms).items(): + r = _tuple_to_str(r_tuple) + if r not in index: + ref_leak[r] = ref_leak.get(r, 0.0) + v * cf + ref_leak = {kk: v for kk, v in ref_leak.items() if v} + for kk in set(shim_leak) | set(ref_leak): + assert abs(shim_leak.get(kk, 0.0) - ref_leak.get(kk, 0.0)) < 1e-12 + + c_shim = expm_mv_dense(dt * M_shim, coeffs) + c_ref = expm_mv_dense(dt * M_ref, coeffs) + assert np.allclose(c_shim, c_ref, atol=1e-13) + + +def test_generator_rejects_duplicate_basis(): + """Duplicate basis rows would silently overwrite each other in the + row-index map and produce an incorrect sparse generator. The user-facing + entry point must reject them with a clear ValueError instead. + """ + L = 4 + h_terms, jump_terms = xy_dephasing(L, alpha=1.0, gamma=0.3) + L_op = Lindbladian(L, h_terms, jump_terms) + basis = ["ZIII", "IZII", "ZIII"] # duplicate at rows 0 and 2 + with pytest.raises(ValueError, match=r"duplicate Pauli word at row 0 and row 2"): + L_op.generator(basis) + # pc_step also builds the row index and must reject duplicates. + with pytest.raises(ValueError, match=r"duplicate Pauli word"): + L_op.pc_step(basis, np.ones(len(basis)), 0.01, 10_000_000) + + +def test_protected_strings_suppressed(): + L = 4 + h_terms, jump_terms = xy_dephasing(L, alpha=1.0, gamma=0.0) + L_op = Lindbladian(L, h_terms, jump_terms) + basis = ["ZIII"] + coeffs = np.array([1.0]) + leak = L_op.leakage(basis, coeffs) + assert leak, "expected some leakage" + protected_key = next(iter(leak)) + leak2 = L_op.leakage(basis, coeffs, protected=[protected_key]) + assert protected_key not in leak2 diff --git a/ppvm-python/test/lindblad/test_adaptive_pc.py b/ppvm-python/test/lindblad/test_adaptive_pc.py new file mode 100644 index 00000000..8a05144f --- /dev/null +++ b/ppvm-python/test/lindblad/test_adaptive_pc.py @@ -0,0 +1,106 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 +"""End-to-end convergence of the adaptive predictor-corrector evolution +(numpy eigendecomposition reference) against the closed bilinear NN-XY + +Z-dephasing solution. + +For NN interactions the JW bilinears stay closed under the adjoint +Lindbladian, so the spin correlator obeys a tractable L²×L² ODE +(:func:`bilinear_nn_xy_z_dephasing_obc`). OBC keeps this exact; PBC would +introduce a parity-twist that only matches up to 1/L corrections. +""" + +from __future__ import annotations + +from itertools import pairwise + +import numpy as np + +from ppvm import Lindbladian + +from ._helpers import ( + adaptive_z_correlator, + adaptive_z_correlator_pc, + bilinear_nn_xy_z_dephasing_obc, + nn_xy_z_dephasing_obc, +) + + +def test_adaptive_converges_to_nn_xy_z_dephasing_bilinear(): + """Halving dt drives the adaptive stepper toward the closed bilinear solution. + + Single-hop has local truncation O(dt²) per step → global error O(T·dt). + """ + L = 4 + J = 1.0 + gamma = 1.0 + site0 = L // 2 + T = 0.05 + tau_add = 1e-12 # tight enough that integrator (T·dt) dominates + + h_terms, jump_terms = nn_xy_z_dephasing_obc(L, J, gamma) + L_op = Lindbladian(L, h_terms, jump_terms) + + errors = [] + final_corr = None + for dt in (0.01, 0.005, 0.0025): + n_steps = round(T / dt) + times = np.arange(n_steps + 1) * dt + exact = bilinear_nn_xy_z_dephasing_obc(L, J, gamma, times, site0) + shim = adaptive_z_correlator(L_op, L, site0, dt, n_steps, tau_add) + # Endpoint comparison only — independent of which step counts we use. + errors.append(float(np.max(np.abs(shim[-1] - exact[-1])))) + if dt == 0.0025: + final_corr = (shim[-1], exact[-1]) + + # Halving dt should roughly halve the error; allow 2× slack. + assert errors[1] < 0.8 * errors[0], f"dt-halving did not help: {errors}" + assert errors[2] < 0.8 * errors[1], f"dt-halving did not help: {errors}" + + # Integrator floor at dt = 0.0025 is ~T·dt = 1.25e-4; expect <1e-3. + assert errors[-1] < 1e-3, ( + f"shim vs bilinear at smallest dt: max abs error = {errors[-1]:.3g}; " + f"shim={final_corr[0]}, exact={final_corr[1]}" + ) + + +def test_predictor_corrector_lifts_dt_scaling_to_cubic(): + """The predictor-corrector basis expansion lifts the single-hop scheme's + local O(dt²) truncation to O(dt³). PC error is also strictly smaller at + every dt we test. + """ + L = 4 + J = 1.0 + gamma = 1.0 + site0 = L // 2 + T = 0.05 + tau_add = 1e-12 + + h_terms, jump_terms = nn_xy_z_dephasing_obc(L, J, gamma) + L_op = Lindbladian(L, h_terms, jump_terms) + + err_single = [] + err_pc = [] + for dt in (0.01, 0.005, 0.0025): + n_steps = round(T / dt) + times = np.arange(n_steps + 1) * dt + exact = bilinear_nn_xy_z_dephasing_obc(L, J, gamma, times, site0) + single = adaptive_z_correlator(L_op, L, site0, dt, n_steps, tau_add) + pc = adaptive_z_correlator_pc(L_op, L, site0, dt, n_steps, tau_add) + err_single.append(float(np.max(np.abs(single[-1] - exact[-1])))) + err_pc.append(float(np.max(np.abs(pc[-1] - exact[-1])))) + + # PC strictly more accurate than single-hop at every dt (by ~100× in this + # regime). Threshold loose enough to absorb expm_multiply tolerance noise. + for s, p, dt in zip(err_single, err_pc, (0.01, 0.005, 0.0025)): + assert p < s / 50, ( + f"PC ({p:.3e}) not meaningfully better than single-hop ({s:.3e}) at dt={dt}" + ) + + # dt-scaling one order steeper: halving dt should drop the error ~8× + # (dt³ vs single-hop's ~4×). Require >5× per halving with safety margin. + for prev, curr in pairwise(err_pc): + assert curr < prev / 5, f"PC dt-halving ratio < 5: errors {err_pc}" + + # Smallest-dt PC error should sit at FP noise of the bilinear reference. + assert err_pc[-1] < 1e-7, f"PC at smallest dt: error = {err_pc[-1]:.3e}" diff --git a/ppvm-python/test/lindblad/test_non_hermitian_jumps.py b/ppvm-python/test/lindblad/test_non_hermitian_jumps.py new file mode 100644 index 00000000..e0170a56 --- /dev/null +++ b/ppvm-python/test/lindblad/test_non_hermitian_jumps.py @@ -0,0 +1,106 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 +"""Non-Hermitian dissipators: cross-check :meth:`Lindbladian.action` / +:meth:`Lindbladian.generator` / :meth:`Lindbladian.leakage` against the dense +2^L × 2^L Liouvillian reference. Only viable for L ≤ 3. +""" + +from __future__ import annotations + +import numpy as np + +from ppvm import Lindbladian, sigma_minus, sigma_plus + +from ._helpers import ( + SIGMA_MINUS_MAT, + SIGMA_PLUS_MAT, + all_strings, + coo_to_dense, + dense_action, + embed_op, + pauli_mat, + random_pauli_str, +) + + +def test_amplitude_damping_action(): + L = 3 + gamma = 0.5 + h_terms = [("XXI", 1.0), ("IXX", 0.7), ("ZIZ", 0.3)] + jump_terms = [(sigma_minus(i, L), gamma) for i in range(L)] + L_op = Lindbladian(L, h_terms, jump_terms) + + H = sum(c * pauli_mat(s) for s, c in h_terms) + jumps_dense = [(embed_op(SIGMA_MINUS_MAT, i, L), gamma) for i in range(L)] + + rng = np.random.default_rng(11) + strings = ["III", "ZII", "IZI", "IIZ", "XYZ", "YYZ"] + strings += [random_pauli_str(rng, L) for _ in range(10)] + + for p in strings: + got = L_op.action(p) + want = dense_action(H, jumps_dense, p, L) + for k in set(got) | set(want): + diff = abs(got.get(k, 0.0) - want.get(k, 0.0)) + assert diff < 1e-10, ( + f"sigma_minus action mismatch at p={p!r} k={k!r}: " + f"shim={got.get(k, 0.0)} ref={want.get(k, 0.0)} diff={diff}" + ) + + +def test_thermal_excitation_damping_action(): + """σ⁺ + σ⁻ jumps together (thermal bath at finite temperature).""" + L = 2 + h_terms = [("XX", 1.0), ("ZI", 0.2), ("IZ", 0.1)] + jump_terms = [(sigma_minus(i, L), 0.4) for i in range(L)] + [ + (sigma_plus(i, L), 0.1) for i in range(L) + ] + L_op = Lindbladian(L, h_terms, jump_terms) + + H = sum(c * pauli_mat(s) for s, c in h_terms) + jumps_dense = [(embed_op(SIGMA_MINUS_MAT, i, L), 0.4) for i in range(L)] + [ + (embed_op(SIGMA_PLUS_MAT, i, L), 0.1) for i in range(L) + ] + + for p in all_strings(L): + got = L_op.action(p) + want = dense_action(H, jumps_dense, p, L) + for k in set(got) | set(want): + diff = abs(got.get(k, 0.0) - want.get(k, 0.0)) + assert diff < 1e-10, ( + f"sigma_plus/sigma_minus action mismatch at p={p!r} k={k!r}: " + f"shim={got.get(k, 0.0)} ref={want.get(k, 0.0)}" + ) + + +def test_amplitude_damping_generator_and_leakage(): + L = 3 + gamma = 0.3 + h_terms = [("XXI", 0.5), ("IXX", 0.5), ("ZII", 0.2), ("IZI", 0.2), ("IIZ", 0.2)] + jump_terms = [(sigma_minus(0, L), gamma), (sigma_minus(2, L), gamma)] + L_op = Lindbladian(L, h_terms, jump_terms) + + basis = ["III", "ZII", "IZI", "IIZ", "ZZI", "IZZ"] + coeffs = np.array([0.1, 0.5, -0.3, 0.4, 0.2, -0.1]) + + H = sum(c * pauli_mat(s) for s, c in h_terms) + jumps_dense = [(embed_op(SIGMA_MINUS_MAT, i, L), gamma) for i in (0, 2)] + + M_shim = coo_to_dense(L_op.generator(basis), len(basis)) + M_ref = np.zeros((len(basis), len(basis))) + idx = {p: i for i, p in enumerate(basis)} + leak_ref: dict = {} + for col, p in enumerate(basis): + action_p = dense_action(H, jumps_dense, p, L) + for q, v in action_p.items(): + if q in idx: + M_ref[idx[q], col] += v + else: + leak_ref[q] = leak_ref.get(q, 0.0) + v * coeffs[col] + assert np.max(np.abs(M_shim - M_ref)) < 1e-10 + + leak_shim = L_op.leakage(basis, coeffs) + leak_ref = {k: v for k, v in leak_ref.items() if abs(v) > 1e-14} + for k in set(leak_shim) | set(leak_ref): + diff = abs(leak_shim.get(k, 0.0) - leak_ref.get(k, 0.0)) + assert diff < 1e-10, f"leakage mismatch at k={k!r}: diff={diff}" diff --git a/ppvm-python/test/lindblad/test_pc_step_rust.py b/ppvm-python/test/lindblad/test_pc_step_rust.py new file mode 100644 index 00000000..f2269e47 --- /dev/null +++ b/ppvm-python/test/lindblad/test_pc_step_rust.py @@ -0,0 +1,123 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 +"""Pure-Rust :meth:`Lindbladian.pc_step` (Al-Mohy & Higham expm + parallel +SpMV): agrees with the numpy-eigendecomp PC reference at FP precision and +shows the same cubic dt-scaling against the bilinear reference. Plus a +sanity check that a length-1 real lincomb routes to the Hermitian fast path. +""" + +from __future__ import annotations + +from itertools import pairwise + +import numpy as np + +from ppvm import Lindbladian + +from ._helpers import ( + adaptive_z_correlator_pc, + bilinear_nn_xy_z_dephasing_obc, + nn_xy_z_dephasing_obc, + random_pauli_str, + xy_dephasing, +) + + +def _adaptive_z_correlator_pc_rust(L_op, L, site0, dt, n_steps, max_basis): + """Same as :func:`_helpers.adaptive_z_correlator_pc` but the per-step PC + work (leakage expansion, predictor expm, second-hop expansion, corrector + expm) all runs in Rust through :meth:`Lindbladian.pc_step`.""" + z_strings = ["I" * j + "Z" + "I" * (L - j - 1) for j in range(L)] + basis = [z_strings[site0]] + coeffs = np.array([1.0]) + protected = [z_strings[site0]] + + corr = np.zeros((n_steps + 1, L)) + corr[0, site0] = 1.0 + + for step in range(n_steps): + basis, coeffs = L_op.pc_step( + basis, coeffs, dt, max_basis, drop_tol=0.0, protected=protected + ) + index = {s: i for i, s in enumerate(basis)} + for j in range(L): + if z_strings[j] in index: + corr[step + 1, j] = coeffs[index[z_strings[j]]] + return corr + + +def test_pc_step_rust_matches_python_pc(): + """The pure-Rust PC step agrees with the numpy-eigendecomp PC reference + at FP precision. + + Pins the Rust matrix exponential (Al-Mohy & Higham) against an + independent reference (numpy ``eig``) under the exact same + basis-expansion schedule.""" + L = 4 + J = 1.0 + gamma = 1.0 + site0 = L // 2 + dt = 0.01 + n_steps = 5 + tau_add = 1e-12 + # Large max_basis so the rust rank cap never binds: full enrichment, + # matching the python reference's effectively-all-leakage tau_add. + max_basis = 10_000_000 + + h_terms, jump_terms = nn_xy_z_dephasing_obc(L, J, gamma) + L_op = Lindbladian(L, h_terms, jump_terms) + + rust = _adaptive_z_correlator_pc_rust(L_op, L, site0, dt, n_steps, max_basis) + py_ref = adaptive_z_correlator_pc(L_op, L, site0, dt, n_steps, tau_add) + + diff = float(np.max(np.abs(rust - py_ref))) + assert diff < 1e-10, f"Rust PC differs from numpy-eigendecomp PC by {diff:.3e}" + + +def test_pc_step_rust_dt_scaling_is_cubic(): + """End-to-end: the Rust-only PC step matches the bilinear reference with + cubic dt-scaling, confirming the Rust matrix exponential is not the + accuracy bottleneck.""" + L = 4 + J = 1.0 + gamma = 1.0 + site0 = L // 2 + T = 0.05 + max_basis = 10_000_000 # large: rank cap never binds (full enrichment) + + h_terms, jump_terms = nn_xy_z_dephasing_obc(L, J, gamma) + L_op = Lindbladian(L, h_terms, jump_terms) + + err = [] + for dt in (0.01, 0.005, 0.0025): + n_steps = round(T / dt) + times = np.arange(n_steps + 1) * dt + exact = bilinear_nn_xy_z_dephasing_obc(L, J, gamma, times, site0) + rust = _adaptive_z_correlator_pc_rust(L_op, L, site0, dt, n_steps, max_basis) + err.append(float(np.max(np.abs(rust[-1] - exact[-1])))) + + # Halving dt should drop the error by ≥5× (cubic gives 8×). + for prev, curr in pairwise(err): + assert curr < prev / 5, f"Rust PC dt-halving ratio < 5: errors {err}" + assert err[-1] < 1e-7, f"Rust PC tight-dt error too large: {err[-1]:.3e}" + + +def test_lincomb_single_term_matches_hermitian_fast_path(): + """A length-1 real lincomb should route to the Hermitian fast path and + produce numerically identical results to passing the string directly.""" + L = 4 + h_terms, jump_simple = xy_dephasing(L, alpha=1.0, gamma=0.3) + L_simple = Lindbladian(L, h_terms, jump_simple) + # Same operator, expressed as a length-1 complex lincomb. + jump_lincomb = [([(s, 1.0 + 0.0j)], g) for s, g in jump_simple] + L_lincomb = Lindbladian(L, h_terms, jump_lincomb) + + rng = np.random.default_rng(99) + for _ in range(5): + p = random_pauli_str(rng, L) + got_a = L_simple.action(p) + got_b = L_lincomb.action(p) + for k in set(got_a) | set(got_b): + assert got_a.get(k, 0.0) == got_b.get(k, 0.0), ( + f"lincomb fast path mismatch at p={p!r}: {got_a} vs {got_b}" + ) diff --git a/ppvm-python/test/test_momentum_merge.py b/ppvm-python/test/test_momentum_merge.py new file mode 100644 index 00000000..de3302ff --- /dev/null +++ b/ppvm-python/test/test_momentum_merge.py @@ -0,0 +1,199 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for momentum-sector (k != 0) symmetry merging of real PauliSum pairs. + +A complex operator O = O_re + i·O_im is carried as a pair of real PauliSums. +``PauliSum.momentum_merge`` folds the pair onto translation-orbit +representatives in momentum sector k, generalizing ``symmetry_merge`` (k=0). + +These checks compare against *exact* references — the projector definition, +idempotency, and exact diagonalization of the dynamics — NOT against any +other propagation scheme. +""" +import cmath +import math + +import numpy as np +import pytest + +from ppvm import PauliSum +from ppvm._core import TranslationGroup + +# ── dense Pauli helpers (exact references) ─────────────────────────────────── +_I = np.eye(2, dtype=complex) +_X = np.array([[0, 1], [1, 0]], dtype=complex) +_Y = np.array([[0, -1j], [1j, 0]], dtype=complex) +_Z = np.array([[1, 0], [0, -1]], dtype=complex) +_P = {"I": _I, "X": _X, "Y": _Y, "Z": _Z} + + +def dense(pauli_str): + m = np.array([[1]], dtype=complex) + for ch in pauli_str: + m = np.kron(m, _P[ch]) + return m + + +def zstr(n, q): + return "".join("Z" if i == q else "I" for i in range(n)) + + +def chain_bonds(n): + return [(i, (i + 1) % n, 1.0) for i in range(n)] + + +# ── helpers shared with the k-resolved Trotter driver ──────────────────────── +def _seed_pair(n, k): + a = np.arange(n) + re = np.cos(2 * np.pi * k * a / n) + im = -np.sin(2 * np.pi * k * a / n) # e^{-2πi k a/n} = cos - i sin + Z = [zstr(n, q) for q in range(n)] + PA = PauliSum.new(n, [(Z[q], float(re[q])) for q in range(n)], + min_abs_coeff=0.0, max_pauli_weight=n) + PB = PauliSum.new(n, [(Z[q], float(im[q])) for q in range(n)], + min_abs_coeff=0.0, max_pauli_weight=n) + return PA, PB + + +def _to_complex_dict(PA, PB): + d = {} + for s, c in PA.terms: + d[s] = d.get(s, 0j) + c + for s, c in PB.terms: + d[s] = d.get(s, 0j) + 1j * c + return {s: v for s, v in d.items() if v != 0j} + + +def _ovl(sA, sB, oA, oB): + re = sA.overlap(oA) + sB.overlap(oB) + im = sA.overlap(oB) - sB.overlap(oA) + return complex(re, im) + + +# ============================================================================= +# 1. The merge is an exact sector projector: idempotent, and it leaves a +# genuine momentum-k eigenoperator unchanged. +# ============================================================================= +@pytest.mark.parametrize("k", [0, 1, 2, 3]) +def test_momentum_merge_idempotent(k): + n = 4 + g = TranslationGroup.chain_1d(n) + PA, PB = _seed_pair(n, k) # S^z_k is exactly in sector k + PA.momentum_merge(PB, g, [k]) + once = _to_complex_dict(PA, PB) + PA.momentum_merge(PB, g, [k]) # merging again must be a no-op + twice = _to_complex_dict(PA, PB) + keys = set(once) | set(twice) + assert max(abs(once.get(x, 0j) - twice.get(x, 0j)) for x in keys) < 1e-12 + + +def test_momentum_merge_projects_out_other_sectors(): + """Merging a pure sector-k operator in sector k' != k gives ~zero.""" + n = 4 + g = TranslationGroup.chain_1d(n) + PA, PB = _seed_pair(n, 1) # operator lives in k=1 + PA.momentum_merge(PB, g, [2]) # project onto k=2 + d = _to_complex_dict(PA, PB) + assert all(abs(v) < 1e-12 for v in d.values()), d + + +# ============================================================================= +# 2. End-to-end: k-resolved, symmetry-compressed Trotter reproduces the +# EXACT (dense-diagonalization) operator autocorrelator as dt -> 0. +# ============================================================================= +def _ed_autocorr(n, bonds, k, ts): + """C_k(t) = Tr[O0^dagger O(t)] / Tr[O0^dagger O0], O0 = S^z_k, exact.""" + H = np.zeros((2 ** n, 2 ** n), dtype=complex) + for (i, j, J) in bonds: + for q in "XY": + s = ["I"] * n + s[i] = q + s[j] = q + H += J * dense("".join(s)) + O0 = np.zeros((2 ** n, 2 ** n), dtype=complex) + for a in range(n): + O0 += cmath.exp(-2j * math.pi * k * a / n) * dense(zstr(n, a)) + E, V = np.linalg.eigh(H) + out = [] + with np.errstate(all="ignore"): # silence spurious macOS-Accelerate matmul warnings + norm = np.trace(O0.conj().T @ O0).real + for t in ts: + U = (V * np.exp(-1j * E * t)) @ V.conj().T + Ot = U.conj().T @ O0 @ U + out.append(np.trace(O0.conj().T @ Ot) / norm) + return np.array(out) + + +def _ctrotter_autocorr(n, bonds, k, dt, steps): + g = TranslationGroup.chain_1d(n) + PA, PB = _seed_pair(n, k) + PA.momentum_merge(PB, g, [k]) + refA, refB = PA.copy(), PB.copy() + C0 = _ovl(refA, refB, PA, PB) + out = [1.0 + 0j] + for _ in range(steps): + for (i, j, J) in bonds: # Strang: forward then reversed + PA.rxx(i, j, J * dt, truncate=False); PA.ryy(i, j, J * dt, truncate=False) + PB.rxx(i, j, J * dt, truncate=False); PB.ryy(i, j, J * dt, truncate=False) + for (i, j, J) in reversed(bonds): + PA.rxx(i, j, J * dt, truncate=False); PA.ryy(i, j, J * dt, truncate=False) + PB.rxx(i, j, J * dt, truncate=False); PB.ryy(i, j, J * dt, truncate=False) + PA.momentum_merge(PB, g, [k]) + out.append(_ovl(refA, refB, PA, PB) / C0) + return np.array(out) + + +@pytest.mark.parametrize("k", [0, 1, 2, 3]) +def test_k_resolved_trotter_converges_to_exact(k): + n, T = 4, 0.3 + bonds = chain_bonds(n) + # exact reference at the matching times for two step sizes + err = {} + for dt in (0.04, 0.02): + steps = round(T / dt) + ts = np.arange(steps + 1) * dt + c = _ctrotter_autocorr(n, bonds, k, dt, steps) + ed = _ed_autocorr(n, bonds, k, ts) + err[dt] = np.max(np.abs(c - ed)) + + assert abs(_ctrotter_autocorr(n, bonds, k, 0.02, 1)[0] - 1.0) < 1e-12 # C_k(0)=1 + if k == 0: + # total Z is conserved -> exact in every sector-0 step + assert err[0.02] < 1e-10 + else: + assert err[0.02] < 5e-3 # close to exact at dt=0.02 + assert err[0.02] < err[0.04] # converges toward exact as dt->0 + + +def test_compressed_matches_uncompressed_evolution(): + """Merging must not change observables beyond the O(dt^2) equivariance + error: compressed (merge each step) vs the same gates with no merge.""" + n, k, dt, steps = 4, 2, 0.02, 10 + bonds = chain_bonds(n) + g = TranslationGroup.chain_1d(n) + + # uncompressed: evolve the full real pair, project only at readout + PA, PB = _seed_pair(n, k) + rA, rB = _seed_pair(n, k) + rA.momentum_merge(rB, g, [k]) + C0 = _ovl(rA, rB, *_merged_copy(PA, PB, g, k)) + comp = _ctrotter_autocorr(n, bonds, k, dt, steps) + unc = [] + for _ in range(steps): + for (i, j, J) in bonds: + PA.rxx(i, j, J * dt, truncate=False); PA.ryy(i, j, J * dt, truncate=False) + PB.rxx(i, j, J * dt, truncate=False); PB.ryy(i, j, J * dt, truncate=False) + for (i, j, J) in reversed(bonds): + PA.rxx(i, j, J * dt, truncate=False); PA.ryy(i, j, J * dt, truncate=False) + PB.rxx(i, j, J * dt, truncate=False); PB.ryy(i, j, J * dt, truncate=False) + mA, mB = _merged_copy(PA, PB, g, k) + unc.append(_ovl(rA, rB, mA, mB) / C0) + unc = np.array([1.0 + 0j] + unc) + assert np.max(np.abs(comp - unc)) < 5e-3 # only O(dt^2) equivariance + + +def _merged_copy(PA, PB, g, k): + a, b = PA.copy(), PB.copy() + a.momentum_merge(b, g, [k]) + return a, b diff --git a/ppvm-python/uv.lock b/ppvm-python/uv.lock index 71d4c591..6cfe8df2 100644 --- a/ppvm-python/uv.lock +++ b/ppvm-python/uv.lock @@ -284,6 +284,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + [[package]] name = "backports-tarfile" version = "1.2.0" @@ -293,6 +302,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, ] +[[package]] +name = "backrefs" +version = "7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/a7dd63622beef68cc0d3c3c36d472e143dd95443d5ebf14cd1a5b4dfbf11/backrefs-7.0.tar.gz", hash = "sha256:4989bb9e1e99eb23647c7160ed51fb21d0b41b5d200f2d3017da41e023097e82", size = 7012453, upload-time = "2026-04-28T16:28:04.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/39/39a31d7eae729ea14ed10c3ccef79371197177b9355a86cb3525709e8502/backrefs-7.0-py310-none-any.whl", hash = "sha256:b57cd227ea556b0aed3dc9b8da4628db4eabc0402c6d7fcfc69283a93955f7e9", size = 380824, upload-time = "2026-04-28T16:27:55.647Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b5/9302644225ba7dfa934a2ff2b9c7bb85701313a90dddb3dfaf693fa5bae2/backrefs-7.0-py311-none-any.whl", hash = "sha256:a0fa7360c63509e9e077e174ef4e6d3c21c8db94189b9d957289ae6d794b9475", size = 392626, upload-time = "2026-04-28T16:27:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/36/da/87912ddec6e06feffbaa3d7aa18fc6352bee2e8f1fee185d7d1690f8f4e8/backrefs-7.0-py312-none-any.whl", hash = "sha256:ca42ce6a49ace3d75684dfa9937f3373902a63284ecb385ce36d15e5dcb41c12", size = 398537, upload-time = "2026-04-28T16:27:58.913Z" }, + { url = "https://files.pythonhosted.org/packages/00/bb/90ba423612b6aa0adccc6b1874bcd4a9b44b660c0c16f346611e00f64ac3/backrefs-7.0-py313-none-any.whl", hash = "sha256:f2c52955d631b9e1ac4cd56209f0a3a946d592b98e7790e77699339ae01c102a", size = 400491, upload-time = "2026-04-28T16:28:00.928Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-none-any.whl", hash = "sha256:a6448b28180e3ca01134c9cf09dcebafad8531072e09903c5451748a05f24bc9", size = 412349, upload-time = "2026-04-28T16:28:02.412Z" }, +] + [[package]] name = "beartype" version = "0.22.9" @@ -302,6 +324,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, ] +[[package]] +name = "beautifulsoup4" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, +] + [[package]] name = "beliefmatching" version = "0.2.0" @@ -318,6 +353,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f2/48/49f5bc2a7ac44226d07381ac553f44966bcee1f4c17d3688d960023ac228/beliefmatching-0.2.0-py3-none-any.whl", hash = "sha256:601e5511e34acd00daad6f421a229aa60f3ff58a1c8800f416b2ffa236c7a22a", size = 12740, upload-time = "2025-06-28T21:19:43.487Z" }, ] +[[package]] +name = "bleach" +version = "6.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/3c/e12ac860709702bd5ebeb9b56a4fe334f1001246ee1b8f2b7ee28912df7d/bleach-6.4.0.tar.gz", hash = "sha256:4202482733d85cedd04e59fcb2f89f4e4c7c385a78d3c3c23c30446843a37452", size = 204857, upload-time = "2026-06-05T13:01:13.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/9d/40b6267367182187139a4000b82a3b287d84d745bccd808e75d916920e9d/bleach-6.4.0-py3-none-any.whl", hash = "sha256:4b6b6a54fff2e69a3dde9d21cc6301220bee3c3cb792187d11403fd795031081", size = 165109, upload-time = "2026-06-05T13:01:12.504Z" }, +] + +[package.optional-dependencies] +css = [ + { name = "tinycss2" }, +] + [[package]] name = "bloqade-circuit" version = "0.14.2" @@ -879,6 +931,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, ] +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + [[package]] name = "dnspython" version = "2.8.0" @@ -925,6 +986,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, ] +[[package]] +name = "fastjsonschema" +version = "2.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, +] + [[package]] name = "fonttools" version = "4.62.1" @@ -1131,6 +1201,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/37/3922951a55a3d0f0340e884929087ce08e333cbb16a86002535c095960fc/gcsfs-2026.4.0-py3-none-any.whl", hash = "sha256:d9e838834d8cce6cb623c6a6a5fad66a4d122dc5c609d4b1c1977b55f759dcc5", size = 72190, upload-time = "2026-04-29T21:04:09.997Z" }, ] +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + [[package]] name = "google-api-core" version = "2.30.3" @@ -1290,6 +1372,27 @@ grpc = [ { name = "grpcio" }, ] +[[package]] +name = "griffe-inherited-docstrings" +version = "1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffelib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/da/fd002dc5f215cd896bfccaebe8b4aa1cdeed8ea1d9d60633685bd61ff933/griffe_inherited_docstrings-1.1.3.tar.gz", hash = "sha256:cd1f937ec9336a790e5425e7f9b92f5a5ab17f292ba86917f1c681c0704cb64e", size = 26738, upload-time = "2026-02-21T09:38:44.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/20/4bc15f242181daad1c104e0a7d33be49e712461ea89e548152be0365b9ea/griffe_inherited_docstrings-1.1.3-py3-none-any.whl", hash = "sha256:aa7f6e624515c50d9325a5cfdf4b2acac547f1889aca89092d5da7278f739695", size = 6710, upload-time = "2026-02-20T11:06:38.75Z" }, +] + +[[package]] +name = "griffelib" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/e4/8d187ea29c2e30b3a09505c567513077d6117861bde1fbd997a167f262ec/griffelib-2.1.0.tar.gz", hash = "sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813", size = 216234, upload-time = "2026-06-19T12:05:42.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" }, +] + [[package]] name = "grpc-google-iam-v1" version = "0.14.4" @@ -1655,6 +1758,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/42/cf027b4ac873b076189d935b135397675dac80cb29acb13e1ab86ad6c631/json5-0.14.0-py3-none-any.whl", hash = "sha256:56cf861bab076b1178eb8c92e1311d273a9b9acea2ccc82c276abf839ebaef3a", size = 36271, upload-time = "2026-03-27T22:50:47.073Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "jupyter-client" version = "8.8.0" @@ -1684,6 +1815,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, ] +[[package]] +name = "jupyterlab-pygments" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d", size = 512900, upload-time = "2023-11-23T09:26:37.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884, upload-time = "2023-11-23T09:26:34.325Z" }, +] + [[package]] name = "jupyterlab-widgets" version = "3.0.16" @@ -1693,6 +1833,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl", hash = "sha256:45fa36d9c6422cf2559198e4db481aa243c7a32d9926b500781c830c80f7ecf8", size = 914926, upload-time = "2025-11-01T21:11:28.008Z" }, ] +[[package]] +name = "jupytext" +version = "1.19.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "mdit-py-plugins" }, + { name = "nbformat" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/52/e014296ac8f40ca783aeb73dae52e65edbb0eaae0dcdc1ea41bfaa8aebf7/jupytext-1.19.4.tar.gz", hash = "sha256:739bcd4bc12aa4fe298a38017cdb5ae27b08a6ba3a5470728d2fe9e04b155db1", size = 4581977, upload-time = "2026-06-21T21:48:58.32Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/e9/e2ae007456069dbe01865c69a4203a7ada6f7e337b78fc2f12e51bd3fae7/jupytext-1.19.4-py3-none-any.whl", hash = "sha256:032d4ef4bd2e96addcac780b9b1d6b5a266ca39beceaaca95bfb4f06e0b77029", size = 170889, upload-time = "2026-06-21T21:48:56.352Z" }, +] + [[package]] name = "keyring" version = "25.7.0" @@ -1935,6 +2092,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, ] +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -2118,6 +2284,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, ] +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -2127,6 +2305,207 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mike" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "mkdocs" }, + { name = "pyparsing" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "verspec" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/47/fa87e9d56bef16cdfe34b059a437e8c6f7ec6f1b9c378871c3cf95ebea9c/mike-2.2.0.tar.gz", hash = "sha256:1e3858e32c0f125aac14432fc7848434358f9ae0962c5c5cde387ad47f6ad25e", size = 38450, upload-time = "2026-04-14T04:59:03.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/8e/56ccb09c7232a55403a7637caa21922f3b65901a37f5e8bdb405d0de0946/mike-2.2.0-py3-none-any.whl", hash = "sha256:e1f4981c1152eec7c2490a3401142292cc47d686194188416db2648fdfe1d040", size = 34026, upload-time = "2026-04-14T04:59:02.602Z" }, +] + +[[package]] +name = "mistune" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/a5/2dab368d6950e6808904dec98f54c7e726ee7be4a0c6afe00e6e011bd52d/mistune-3.3.3.tar.gz", hash = "sha256:c4c6c0c840b8637a2e9b8b6d607eb7c8f00888bf14c754409bcd339e848c2477", size = 115363, upload-time = "2026-07-09T06:18:05.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/70/b1e4737b84163db5bb1dfde6f216dbfbf32783330a9989c965e121172830/mistune-3.3.3-py3-none-any.whl", hash = "sha256:99de1585e42dcbd826faa9e11a202727a5e202e4e4722a4c69ac1ff615793dd7", size = 63569, upload-time = "2026-07-09T06:18:03.839Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, +] + +[[package]] +name = "mkdocs-gen-files" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mkdocs" }, + { name = "properdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/43/428f312149c161cae557eecd35f3c4a82b867998b1d47fb29fdfe927be26/mkdocs_gen_files-0.6.1.tar.gz", hash = "sha256:57d7ff2229e23d077e46d14a33db6d37c8823f6ce1a503c874c1764a71679763", size = 8746, upload-time = "2026-03-16T23:26:09.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/1b/3075eb67fe66e19db059f0a25744c4e56978a309603a20e1d3353d545b5e/mkdocs_gen_files-0.6.1-py3-none-any.whl", hash = "sha256:b3182bfc6219e35b8d26658cb988368659d5d023aac30c2a819247558fc12189", size = 8282, upload-time = "2026-03-16T23:26:08.292Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, +] + +[[package]] +name = "mkdocs-jupyter" +version = "0.26.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ipykernel" }, + { name = "jupytext" }, + { name = "mkdocs" }, + { name = "mkdocs-material" }, + { name = "nbconvert" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/aa/f8d15409a9a3112486994a80d5a975694c7d145c4f8b5b484aeb383420ef/mkdocs_jupyter-0.26.3.tar.gz", hash = "sha256:e1e8bd48a1b96542e84e3028e3066112bac7b94d95ab69f8b91305c84003ca26", size = 1628353, upload-time = "2026-04-17T18:56:31.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl", hash = "sha256:cd6644fb578131157194d750fd4d10fc2fd8f1e84e00036ee62df3b5b4b84c82", size = 1459740, upload-time = "2026-04-17T18:56:30.031Z" }, +] + +[[package]] +name = "mkdocs-literate-nav" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mkdocs" }, + { name = "properdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/af/dd3776a7a713f798f79bec7eb9c661d5cfb83ddc17d9a3667595e53e1559/mkdocs_literate_nav-0.6.3.tar.gz", hash = "sha256:edbaca22343f861fe4e34aac47d55a0c9955c640dbf02eea99fe631e914cf9ee", size = 17526, upload-time = "2026-03-16T23:26:50.688Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/2c/bcf1ae903975ad6f169abb05c1eb0f94395478364deb89270cf034081b29/mkdocs_literate_nav-0.6.3-py3-none-any.whl", hash = "sha256:2c421561280fa9184f88cbf399bebbd4cc17ee507e978a31ce11fd6f3aabf233", size = 13355, upload-time = "2026-03-16T23:26:49.562Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "mkdocstrings" +version = "1.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/71/f85bdf13355073ae15a7375f09879375a830553552e58c1c4b7e0bbc5c8b/mkdocstrings-1.0.6.tar.gz", hash = "sha256:a0b8c2bdd29a6416c80d717aa369bbf7831946bd9f23c2a66db1b1dbe7693dbd", size = 100649, upload-time = "2026-07-11T19:38:05.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/5b/4c1902e8bdd5c4db63284e9d101dece4038d4025d6d88850ffe0a1578980/mkdocstrings-1.0.6-py3-none-any.whl", hash = "sha256:2703708697487d1b6d6d7b412e176fa436edf120c1bf81dc9e126b12d00893c7", size = 35787, upload-time = "2026-07-11T19:38:04.417Z" }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffelib" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/b6/e858701499d57eee8b3fd8e78168083956c6683ddbe727b46758b19e1119/mkdocstrings_python-2.0.5.tar.gz", hash = "sha256:3a4d92556ad39637e88af94a5374213af9a8e3040c3824ceaed04b486c017594", size = 199578, upload-time = "2026-06-19T10:41:08.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/fc/10ab7e80650a9c9e8f4f1105f8c8e73567f88ed0c06ada589ab81d38687c/mkdocstrings_python-2.0.5-py3-none-any.whl", hash = "sha256:30c837bbff016549f659fcba6539ac351303f0fd7e713c89a040611072236e9d", size = 104951, upload-time = "2026-06-19T10:41:07.378Z" }, +] + [[package]] name = "more-itertools" version = "11.0.2" @@ -2274,6 +2653,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] +[[package]] +name = "nbclient" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "nbformat" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/a5/b3bae4b590c0cbcada2c63a34f7580024e834a8ba213e949a2f906705787/nbclient-0.11.0.tar.gz", hash = "sha256:04a134a5b087f2c5887f228aca155db50169b8cd9334dee6942c8e927e56081a", size = 62535, upload-time = "2026-06-05T07:52:41.746Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/c9/94d73e5a01c5b926c3fa2496e97d7a8dc28ed5a77c0b2ed712f1a62e6694/nbclient-0.11.0-py3-none-any.whl", hash = "sha256:ef7fa0d59d6e1d41103933d8a445a18d5de860ca6b613b87b8574accdb3c2895", size = 25288, upload-time = "2026-06-05T07:52:40.115Z" }, +] + +[[package]] +name = "nbconvert" +version = "7.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "bleach", extra = ["css"] }, + { name = "defusedxml" }, + { name = "jinja2" }, + { name = "jupyter-core" }, + { name = "jupyterlab-pygments" }, + { name = "markupsafe" }, + { name = "mistune" }, + { name = "nbclient" }, + { name = "nbformat" }, + { name = "packaging" }, + { name = "pandocfilters" }, + { name = "pygments" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/b1/708e53fe2e429c103c6e6e159106bcf0357ac41aa4c28772bd8402339051/nbconvert-7.17.1.tar.gz", hash = "sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2", size = 865311, upload-time = "2026-04-08T00:44:14.914Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl", hash = "sha256:aa85c087b435e7bf1ffd03319f658e285f2b89eccab33bc1ba7025495ab3e7c8", size = 261927, upload-time = "2026-04-08T00:44:12.845Z" }, +] + +[[package]] +name = "nbformat" +version = "5.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastjsonschema" }, + { name = "jsonschema" }, + { name = "jupyter-core" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749, upload-time = "2024-04-04T11:20:37.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" }, +] + [[package]] name = "nest-asyncio" version = "1.6.0" @@ -2396,6 +2830,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + [[package]] name = "pandas" version = "2.3.3" @@ -2457,6 +2900,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, ] +[[package]] +name = "pandocfilters" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454, upload-time = "2024-01-18T20:08:13.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663, upload-time = "2024-01-18T20:08:11.28Z" }, +] + [[package]] name = "parso" version = "0.8.6" @@ -2466,6 +2918,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b6/61/fae042894f4296ec49e3f193aff5d7c18440da9e48102c3315e1bc4519a7/parso-0.8.6-py2.py3-none-any.whl", hash = "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff", size = 106894, upload-time = "2026-02-09T15:45:21.391Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + [[package]] name = "pexpect" version = "4.9.0" @@ -2601,27 +3062,56 @@ source = { editable = "." } dependencies = [ { name = "bloqade-circuit" }, { name = "kirin" }, + { name = "numpy" }, ] [package.dev-dependencies] +demo = [ + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] dev = [ { name = "numpy" }, { name = "pytest" }, { name = "pytest-benchmark" }, ] +doc = [ + { name = "griffe-inherited-docstrings" }, + { name = "matplotlib" }, + { name = "mike" }, + { name = "mkdocs" }, + { name = "mkdocs-gen-files" }, + { name = "mkdocs-jupyter" }, + { name = "mkdocs-literate-nav" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings", extra = ["python"] }, +] [package.metadata] requires-dist = [ { name = "bloqade-circuit", specifier = ">=0.14.1" }, { name = "kirin", specifier = ">=0.0.15" }, + { name = "numpy", specifier = ">=1.26" }, ] [package.metadata.requires-dev] +demo = [{ name = "scipy", specifier = ">=1.13" }] dev = [ { name = "numpy", specifier = ">=2.2.6" }, { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-benchmark", specifier = ">=5.2.3" }, ] +doc = [ + { name = "griffe-inherited-docstrings", specifier = ">=1.1.1" }, + { name = "matplotlib", specifier = ">=3.10.8" }, + { name = "mike", specifier = ">=2.1.3" }, + { name = "mkdocs", specifier = ">=1.6.1" }, + { name = "mkdocs-gen-files", specifier = ">=0.5.0" }, + { name = "mkdocs-jupyter", specifier = ">=0.25.1" }, + { name = "mkdocs-literate-nav", specifier = ">=0.6.1" }, + { name = "mkdocs-material", specifier = ">=9.5.44" }, + { name = "mkdocstrings", extras = ["python"], specifier = ">=0.27.0" }, +] [[package]] name = "prompt-toolkit" @@ -2763,6 +3253,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, ] +[[package]] +name = "properdocs" +version = "1.6.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/29/f27a4e1eddf72ed3db6e47818fbafe6debbf09fd7051f9c1a007239b46ef/properdocs-1.6.7.tar.gz", hash = "sha256:adc7b16e562890af0e098a7e5b02e3a81c20894a87d6a28d345c9300de73c26e", size = 276141, upload-time = "2026-03-20T20:07:48.167Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/4d/fc923f5c85318ee8cc903566dc4e0ebe41b2dfc1d2ecf5546db232397ed6/properdocs-1.6.7-py3-none-any.whl", hash = "sha256:6fa0cfa2e01bf338f684892c8a506cf70ea88ae7f3479c933b6fa20168101cbd", size = 225406, upload-time = "2026-03-20T20:07:46.875Z" }, +] + [[package]] name = "proto-plus" version = "1.28.0" @@ -3039,6 +3552,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/c4/0e53ff3c9312ffb0e1677ebb62172660c5614077dca9c69ccfc31b90b7e4/pymatching-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:7ebce2e8bc98379f0bb7643a0ef0171142cf384ba626e46108c84249c868f0a6", size = 347977, upload-time = "2025-09-25T21:46:15.044Z" }, ] +[[package]] +name = "pymdown-extensions" +version = "11.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/a9/5f0c535ba3b08fe09270c16808e053a968868242ecbd5676d4e3a488bf28/pymdown_extensions-11.0.1.tar.gz", hash = "sha256:dd2905ae6fc5b75582fafb139a1266ffc754705efa902aa50067fa7ff4f94ec0", size = 857113, upload-time = "2026-07-02T17:59:22.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl", hash = "sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2", size = 269455, upload-time = "2026-07-02T17:59:21.271Z" }, +] + [[package]] name = "pymongo" version = "4.17.0" @@ -3236,6 +3762,82 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + [[package]] name = "pyzmq" version = "27.1.0" @@ -3309,6 +3911,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/1b/5dbe84eefc86f48473947e2f41711aded97eecef1231f4558f1f02713c12/pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355", size = 544862, upload-time = "2025-09-08T23:09:56.509Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "requests" version = "2.33.1" @@ -3350,6 +3967,266 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + [[package]] name = "s3fs" version = "2026.4.0" @@ -3565,6 +4442,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "soupsieve" +version = "2.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, +] + [[package]] name = "stack-data" version = "0.6.3" @@ -3627,6 +4513,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" }, ] +[[package]] +name = "tinycss2" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -3787,6 +4685,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/41/ac2dfdbc1f60c7af4f994c7a335cfa7040c01642b605d65f611cecc2a1e4/uvicorn-0.47.0-py3-none-any.whl", hash = "sha256:2c5715bc12d1892d84752049f400cd1c3cb018514967fdfeb97640443a6a9432", size = 71301, upload-time = "2026-05-14T18:16:51.762Z" }, ] +[[package]] +name = "verspec" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/44/8126f9f0c44319b2efc65feaad589cadef4d77ece200ae3c9133d58464d0/verspec-0.1.0.tar.gz", hash = "sha256:c4504ca697b2056cdb4bfa7121461f5a0e81809255b41c03dda4ba823637c01e", size = 27123, upload-time = "2020-11-30T02:24:09.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/ce/3b6fee91c85626eaf769d617f1be9d2e15c1cca027bbdeb2e0d751469355/verspec-0.1.0-py3-none-any.whl", hash = "sha256:741877d5633cc9464c45a469ae2a31e801e6dbbaa85b9675d481cda100f11c31", size = 19640, upload-time = "2020-11-30T02:24:08.387Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + [[package]] name = "wcwidth" version = "0.6.0" @@ -3796,6 +4735,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, ] +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, +] + [[package]] name = "widgetsnbextension" version = "4.0.15"