diff --git a/sv2/channels-sv2/Cargo.toml b/sv2/channels-sv2/Cargo.toml index 1c22cd1e15..66f17286a6 100644 --- a/sv2/channels-sv2/Cargo.toml +++ b/sv2/channels-sv2/Cargo.toml @@ -1,5 +1,10 @@ [package] name = "channels_sv2" +# TODO: bump to 5.1.0 once the project's pinned Rust 1.75 toolchain can +# write the v4 Cargo.lock format (a version change here triggers a lockfile +# rewrite that 1.75 can't perform). The `add_shares` method added to the +# Vardiff trait in this commit is a minor-version semver change that +# downstream consumers should learn about via a version bump. version = "5.0.0" authors = ["The Stratum V2 Developers"] edition = "2021" diff --git a/sv2/channels-sv2/sim/Cargo.lock b/sv2/channels-sv2/sim/Cargo.lock new file mode 100644 index 0000000000..fceede6eef --- /dev/null +++ b/sv2/channels-sv2/sim/Cargo.lock @@ -0,0 +1,641 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "base58ck" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" +dependencies = [ + "bitcoin-internals", + "bitcoin_hashes", +] + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "binary_sv2" +version = "5.0.1" +dependencies = [ + "derive_codec_sv2", +] + +[[package]] +name = "bitcoin" +version = "0.32.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e499f9fc0407f50fe98af744ab44fa67d409f76b6772e1689ec8485eb0c0f66" +dependencies = [ + "base58ck", + "bech32", + "bitcoin-internals", + "bitcoin-io", + "bitcoin-units", + "bitcoin_hashes", + "hex-conservative", + "hex_lit", + "secp256k1", +] + +[[package]] +name = "bitcoin-internals" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" + +[[package]] +name = "bitcoin-io" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" + +[[package]] +name = "bitcoin-units" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5285c8bcaa25876d07f37e3d30c303f2609179716e11d688f51e8f1fe70063e2" +dependencies = [ + "bitcoin-internals", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" +dependencies = [ + "bitcoin-io", + "hex-conservative", +] + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "byte-slice-cast" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "channels_sv2" +version = "5.0.0" +dependencies = [ + "binary_sv2", + "bitcoin", + "mining_sv2", + "primitive-types", + "template_distribution_sv2", + "tracing", +] + +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +dependencies = [ + "const_format_proc_macros", +] + +[[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 = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "derive_codec_sv2" +version = "1.1.2" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[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", + "rustc-hex", + "static_assertions", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + +[[package]] +name = "impl-codec" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d40b9d5e17727407e55028eafc22b2dc68781786e6d7eb8a21103f5058e3a14" +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", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mining_sv2" +version = "9.0.0" +dependencies = [ + "binary_sv2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[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", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primitive-types" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d15600a7d856470b7d278b3fe0e311fe28c2526348549f8ef2ff7db3299c87f5" +dependencies = [ + "fixed-hash", + "impl-codec", + "uint", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[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", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rustc-hex" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "bitcoin_hashes", + "secp256k1-sys", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "template_distribution_sv2" +version = "5.0.0" +dependencies = [ + "binary_sv2", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow 0.7.15", +] + +[[package]] +name = "toml_parser" +version = "1.0.10+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" +dependencies = [ + "winnow 1.0.0", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "uint" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "909988d098b2f738727b161a106cfc7cab00c539c2687a8836f8e565976fb53e" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "vardiff_sim" +version = "0.1.0" +dependencies = [ + "bitcoin", + "channels_sv2", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "zerocopy" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/sv2/channels-sv2/sim/Cargo.toml b/sv2/channels-sv2/sim/Cargo.toml new file mode 100644 index 0000000000..41a4a0d4f0 --- /dev/null +++ b/sv2/channels-sv2/sim/Cargo.toml @@ -0,0 +1,22 @@ +# `[workspace]` declaration intentionally marks this crate as the root of a +# separate workspace, isolating its lockfile from the parent stratum workspace. +# The parent's `Cargo.lock` is v4 and the parent's pinned 1.75 toolchain cannot +# update it in-place; adding members triggers an update and breaks the build. +# Keeping `vardiff_sim` in its own workspace sidesteps the issue and lets the +# sim crate be built and tested via `cd sv2/channels-sv2/sim && cargo test`. +[workspace] + +[package] +name = "vardiff_sim" +version = "0.1.0" +authors = ["The Stratum V2 Developers"] +edition = "2021" +description = "Vardiff algorithm simulation framework for behavioral characterization and CI regression testing" +license = "MIT OR Apache-2.0" +repository = "https://github.com/stratum-mining/stratum" +homepage = "https://stratumprotocol.org" +keywords = ["stratum", "mining", "bitcoin", "protocol", "vardiff"] + +[dependencies] +channels_sv2 = { path = ".." } +bitcoin = "0.32.5" diff --git a/sv2/channels-sv2/sim/README.md b/sv2/channels-sv2/sim/README.md new file mode 100644 index 0000000000..54a23f8436 --- /dev/null +++ b/sv2/channels-sv2/sim/README.md @@ -0,0 +1,113 @@ +# vardiff_sim + +A deterministic in-process simulation framework for characterizing the behavioral attributes of any [`Vardiff`](../src/vardiff/mod.rs) implementation, plus a regression test that asserts the current algorithm against a checked-in baseline. + +For the conceptual design, metric definitions, and tradeoff rationale see [`VARDIFF_SIMULATION_FRAMEWORK.md`](./VARDIFF_SIMULATION_FRAMEWORK.md). + +## What this crate measures + +Given any `Vardiff` implementation, the framework characterizes five behavioral attributes across a parameterized grid of share rates and hashrate scenarios: + +- **Convergence time** — how long the algorithm takes to settle after a cold start +- **Settled accuracy** — how close to truth the algorithm lands once settled +- **Steady-state jitter** — how often the algorithm fires on noise after settling +- **Reaction time** — how long until the algorithm fires after a genuine load change +- **Reaction sensitivity** — what magnitude of change reliably triggers a fire + +Each metric is a distribution across many independent simulated trials (1000 per cell by default), reported as percentiles. The full output for the current algorithm lives in [`vardiff_baseline.md`](./vardiff_baseline.md) (human-readable) and [`vardiff_baseline.toml`](./vardiff_baseline.toml) (machine-readable, consumed by the regression test). + +## Running + +The crate is structured as its own Cargo workspace (see the note on `Cargo.lock` below). All commands run from `sv2/channels-sv2/sim/`: + +```bash +# Fast unit tests (~1 second) +cargo test + +# Generate a fresh baseline (~5-15 seconds) +cargo run --release --bin generate-baseline + +# Run the slow regression test against the committed baseline (~5-15 seconds) +cargo test --release --lib -- --ignored +``` + +The regression test is `#[ignore]`-d by default so `cargo test` stays fast. CI workflows that want full regression coverage should invoke `cargo test --release --lib -- --ignored` explicitly. + +## Reading the baseline output + +`vardiff_baseline.md` is organized by metric: + +- **Convergence time**: per share rate, the fraction of trials that converged plus percentile of convergence time. Look at `rate` first (catastrophic regressions show up here as <90%) then at `p90` and `p99` (tail-end slowdowns). +- **Settled accuracy**: relative error between the algorithm's final hashrate estimate and truth. Smaller is better; at low share rates (6 spm) this is bounded by Poisson noise on the share count, so don't expect zero. +- **Steady-state jitter**: fires per minute during the settled period. Smaller is better; ideal is zero under stable load. +- **Reaction time to -50% drop**: per share rate, fraction of trials that reacted within 5 minutes plus percentile of reaction time. +- **Reaction sensitivity curve**: for each Δ in {±5, ±10, ±25, ±50}%, fraction of trials that fired within 5 minutes. The shape of this curve reveals threshold structure — sharp step = tight thresholds, smooth ramp = loose thresholds. + +## Updating the baseline + +When a proposed algorithm change legitimately improves metrics, the baseline must be regenerated. Workflow: + +1. Make the algorithm change. +2. Run `cargo test --release --lib -- --ignored` to see which metrics changed and how. The failure message identifies the cell and metric. +3. Decide whether each change is intended improvement vs. regression. Discuss in PR review. +4. If the changes are intended, regenerate: `cargo run --release --bin generate-baseline`. +5. Review the diff on `vardiff_baseline.toml` and `vardiff_baseline.md` carefully. The TOML diff is the structured source of truth; the markdown is for human review. +6. Commit the regenerated baseline alongside the algorithm change. + +Updating the baseline is intentionally manual — automatic baseline drift would defeat the purpose of regression detection. The PR review is the gate. + +## Adding a new algorithm + +Any implementor of the [`Vardiff`](../src/vardiff/mod.rs) trait can be plugged into the simulation harness: + +```rust +let clock = Arc::new(MockClock::new(0)); +let my_vardiff = MyAlgorithm::new_with_clock(1.0, clock.clone()); +let trial = run_trial(my_vardiff, clock, config, &schedule, seed); +``` + +To get a characterization comparable to the checked-in baseline, write a binary similar to [`bin/generate-baseline.rs`](./src/bin/generate-baseline.rs) that constructs your algorithm in place of `VardiffState`. + +## Project-specific notes + +### `Cargo.lock` is checked in + +The sim crate is declared as its own workspace (see the `[workspace]` block in `Cargo.toml`) and ships with a `Cargo.lock` that pins transitive dependency versions. This is **by design** — the parent stratum workspace is pinned to Rust 1.75, which cannot write the `v4` lockfile format produced by newer toolchains. Adding the sim crate as a workspace member would force a lockfile rewrite that 1.75 can't perform. + +If you delete `Cargo.lock` "to refresh" the dep tree, the build will break with `feature 'edition2024' is required` errors from transitive deps. Recover by copying the parent's lockfile back: + +```bash +cp ../../../Cargo.lock . +``` + +### Determinism + +Every trial is fully deterministic given its `(config, schedule, seed)` triple. The baseline run uses a fixed `base_seed` (default `0xDEADBEEFCAFEF00D`) and derives per-cell, per-trial seeds via `base_seed.wrapping_add(cell_index << 20).wrapping_add(trial_index)`. Re-runs of `generate-baseline` produce byte-identical output across machines and time, modulo Rust toolchain version changes that affect floating-point determinism (e.g., a different `f64::ln` implementation). + +The `Trial` struct carries the seed it was produced from, so a failing trial in the regression test can be reproduced in isolation by replaying with the same seed. + +### Simulation fidelity vs production + +The simulation models share arrivals as pure Poisson with rate matching the algorithm's configured target. Real miners cluster shares (TCP batching, hash-device timing, network jitter) so the variance under real-world load is wider than Poisson predicts. The framework's claims are therefore **conservative comparisons between algorithms under identical simulated load** — "A jitters 87% less than B" — rather than absolute predictions about deployment behavior. Real-world plausibility is the job of integration tests and deployment observation. + +## Layout + +``` +sim/ +├── Cargo.toml # crate manifest + [workspace] declaration +├── Cargo.lock # copy of parent workspace's lockfile (see above) +├── README.md # this file +├── VARDIFF_SIMULATION_FRAMEWORK.md # design proposal +├── vardiff_baseline.toml # machine-readable baseline (regression-test input) +├── vardiff_baseline.md # human-readable baseline summary +└── src/ + ├── lib.rs # module declarations + re-exports + ├── rng.rs # XorShift64 + Poisson / exponential samplers + ├── schedule.rs # HashrateSchedule for trial scenarios + ├── trial.rs # run_trial: the per-tick simulation loop + ├── metrics.rs # Distribution + the five metric functions + ├── baseline.rs # Cell / CellResult / run_baseline + serializers + ├── regression.rs # baseline-parsing + tolerance assertions + └── bin/ + └── generate-baseline.rs # CLI binary +``` diff --git a/sv2/channels-sv2/sim/VARDIFF_SIMULATION_FRAMEWORK.md b/sv2/channels-sv2/sim/VARDIFF_SIMULATION_FRAMEWORK.md new file mode 100644 index 0000000000..1637789ec6 --- /dev/null +++ b/sv2/channels-sv2/sim/VARDIFF_SIMULATION_FRAMEWORK.md @@ -0,0 +1,459 @@ +# Vardiff Characterization Framework — Design Proposal + +## Goal + +Build a deterministic in-process simulation framework that measures the operationally-important behavioral attributes of any `Vardiff` trait implementation, and expose those measurements as an assertable CI policy. + +The framework is the deliverable. The current `VardiffState` (classic) algorithm becomes the first characterized baseline. Any future algorithmic proposal — parametric thresholds, EWMA, SPRT, anything else — plugs into the same harness and produces a comparable delta report. The goal is to surface existing problems concretely (jitter, ramp-up time) so that proposed improvements have to demonstrate, with numbers, what they move and in which direction. + +## Why simulation rather than network integration + +Three properties make in-process simulation the right level for *characterization*. Network integration tests remain valuable for *plumbing verification* — they're complementary, not competing. + +**Determinism.** Seeded RNG means every run produces identical numbers. Two CI runs of the same algorithm produce byte-identical reports. Statistical claims become checkable rather than "well, in my run I saw...". + +**Speed.** A network integration test takes ~5-10 minutes per trial because the 60s ticker is real wall-clock time. The same trial in simulation runs in ~100-300μs — five orders of magnitude faster. That's exactly the gap needed to characterize a distribution from `N=1000+` independent trials per cell. The full baseline runs in ~10 seconds. + +**Coverage.** Simulation can sweep parameters cheaply. A baseline characterization across 5 share rates × 6 hashrate scenarios × 1000 trials = 30,000 trials is comfortably tractable. Running the same as network integration would take ~150 days. + +The tradeoff is fidelity: simulation uses idealized Poisson share arrival, while real miners exhibit clustering, batching, and irregular submission. The framework's claims are therefore *conservative comparisons between algorithms under identical Poisson load* ("A jitters 87% less than B"), not absolute predictions about deployment behavior. Real-world plausibility is the job of testnet4 deployment and network integration tests — not this framework. + +## Architecture + +Three layers, each independently testable: + +**Layer 1 — `Vardiff` trait.** Already exists in `sv2/channels-sv2/src/vardiff/mod.rs`. `pub trait Vardiff` defines `try_vardiff`, `reset_counter`, etc. `VardiffState` is currently the sole implementor. Any new algorithm implements the same trait and becomes interchangeable in the harness. + +**Layer 2 — Simulation harness.** New `vardiff-sim` crate. Drives a `Vardiff` implementation through a single trial against a mocked clock and a Poisson share stream. Produces a `Trial` record containing the full timeline of fires. + +**Layer 3 — Metric computation and reporting.** Library functions taking the `Vec` for a given parameter combination — what we'll call a **cell** — and producing metric distributions: percentile tables, sensitivity curves, summary statistics. A *cell* is one tuple of `(algorithm, share_rate, scenario)`, where `scenario` is a `HashrateSchedule` such as "stable 1 PH/s" or "step down 50% at 15 min." The full baseline run sweeps a fixed grid of cells (5 share rates × 6 scenarios = 30 cells in the default configuration). Output formats: structured TOML for CI assertions, markdown for human review. + +## Simulation mechanism + +### Mock clock + +The classic algorithm currently calls `SystemTime::now()` directly inside `try_vardiff`. To run trials at >1000× wall-clock speed, the framework introduces a `Clock` trait: + +```rust +trait Clock { + fn now_secs(&self) -> u64; +} + +struct SystemClock; +impl Clock for SystemClock { + fn now_secs(&self) -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + } +} + +struct MockClock { now: AtomicU64 } +impl Clock for MockClock { + fn now_secs(&self) -> u64 { self.now.load(Ordering::Relaxed) } +} +impl MockClock { + fn advance(&self, secs: u64) { self.now.fetch_add(secs, Ordering::Relaxed); } + fn set(&self, secs: u64) { self.now.store(secs, Ordering::Relaxed); } +} +``` + +`VardiffState::try_vardiff` and `reset_counter` are modified to accept a `&dyn Clock` parameter. `SystemClock` is the default; production behavior is identical to current code. The change is mechanical, ~50 lines. + +This is the only invasive change to the existing algorithm. If clock injection is rejected by maintainers, the framework can't work — there's no good alternative. + +### Poisson share stream + +Given a true miner hashrate `H` (hashes/sec) and a current pool-side target `T`, the rate at which the miner produces accepted shares is `λ = H × T_normalized` per second, where `T_normalized` is the target as a fraction of `2^256`. Inter-arrival times are `Exp(λ)`. + +Shares are *not* pre-generated in bulk for the whole trial — when the target changes mid-trial (vardiff fires and applies a new hashrate, which the harness converts to a new target), the share rate λ changes. Exponential interarrival is memoryless, so the next share's time is sampled afresh from `Exp(λ_new)` starting at the current simulated time. This is statistically correct and operationally simple. + +The RNG is a deterministic `XorShift64` seeded from the trial's seed value. Same seed produces identical share arrival sequence. + +### Hashrate schedule + +A trial's true miner hashrate is a step function over time: + +```rust +struct HashrateSchedule { + segments: Vec<(u64, f32)>, // (start_secs, hashrate_h_per_sec) +} + +impl HashrateSchedule { + fn at(&self, secs: u64) -> f32 { + self.segments.iter() + .rev() + .find(|(start, _)| secs >= *start) + .map(|(_, h)| *h) + .unwrap_or(self.segments[0].1) + } +} +``` + +This makes it cheap to express scenarios: + +- **Stable**: `[(0, 1e15)]` — constant 1 PH/s for trial duration +- **Step down**: `[(0, 1e15), (900, 0.5e15)]` — halves at 15 min +- **Step up**: `[(0, 1e15), (900, 2e15)]` — doubles at 15 min +- **Throttle**: `[(0, 1e15), (900, 0.7e15), (1200, 1e15)]` — 30% drop for 5 min, then recovers +- **Cold start far off**: `[(0, 1e15)]` with `initial_target` chosen to imply 10 GH/s — algorithm has to find truth from way off + +### Trial loop + +```rust +fn run_trial( + vardiff: &mut V, + clock: &MockClock, + config: TrialConfig, + schedule: &HashrateSchedule, + seed: u64, +) -> Trial { + let mut rng = XorShift64::new(seed); + let mut current_target = config.initial_target; + let mut configured_hashrate = config.initial_hashrate_estimate; + let mut fires = Vec::new(); + + let mut next_share_at = 0u64; + let mut next_tick_at = 60u64; + + loop { + let next_event_at = next_share_at.min(next_tick_at); + if next_event_at > config.duration_secs { break; } + clock.set(next_event_at); + + if next_share_at <= next_tick_at { + // Share event + vardiff.increment_shares_since_last_update(); + let true_h = schedule.at(clock.now_secs()); + let lambda = true_h as f64 * target_to_fraction(¤t_target); + let delta_secs = sample_exponential(&mut rng, lambda).round() as u64; + next_share_at += delta_secs.max(1); + } else { + // Tick event + if let Ok(Some(new_h)) = vardiff.try_vardiff( + configured_hashrate, + ¤t_target, + config.shares_per_minute, + clock, + ) { + fires.push(FireEvent { + at_secs: clock.now_secs(), + old_hashrate: configured_hashrate, + new_hashrate: new_h, + }); + configured_hashrate = new_h; + current_target = hashrate_to_target(new_h, config.shares_per_minute); + } + next_tick_at += 60; + } + } + + Trial { + fires, + final_target: current_target, + final_configured_hashrate: configured_hashrate, + final_true_hashrate: schedule.at(config.duration_secs), + duration_secs: config.duration_secs, + config: config.clone(), + } +} +``` + +(Sketch — actual implementation handles edge cases more carefully. The structure is the point.) + +### Determinism + +Every trial is fully deterministic given its seed. The full baseline run fixes a `base_seed` and derives per-trial seeds as `base_seed.wrapping_add(cell_index << 16).wrapping_add(trial_index)`. Anyone running the same baseline gets byte-identical results — including the test runner in CI. + +## The five metrics + +Each metric is described as a pipeline: definition → measurement → collection → characterization → summarization → CI assertion form. + +### 1. Convergence time + +**Definition.** Wall-clock time from `t=0` (trial start, defined as first tick) until the algorithm has not fired for `quiet_window = 5 min`. The reported value is the timestamp of the **last fire** before the quiet window. If no fire happens at all during the trial, convergence_time is `0`. If fires never stop within `convergence_timeout = 30 min`, the trial is `DNF`. + +**Measurement.** Iterate over `trial.fires` in chronological order. Convergence time = `f.at_secs` for the first fire `f` such that no subsequent fire exists with timestamp in `[f.at_secs, f.at_secs + quiet_window]`. If no such fire and `trial.duration_secs >= convergence_timeout`, mark `DNF`. + +**Collection.** Per cell `(algorithm, share_rate, scenario)`, run `N=1000` trials with distinct seeds. Collect `Vec>` — `Some(t)` for converged, `None` for DNF. + +**Characterization.** Two outputs: +- `convergence_rate ∈ [0, 1]`: fraction of trials that converged +- `convergence_distribution`: p10, p25, p50, p75, p90, p95, p99 over converged trials + +**Summarization.** Line in baseline report: + +``` +Convergence (12 spm, cold start 10 GH/s → 1 PH/s true): + rate: 998/1000 (99.8%) + p10=7m45s p50=8m30s p90=9m50s p95=10m25s p99=11m20s +``` + +**CI assertion.** Two-part: +- `current.convergence_rate >= baseline.convergence_rate - 0.01` — catastrophic regression guard +- `current.p90 <= baseline.p90 * 1.10` — tail-end slowdown guard + +Tail percentiles (p90/p95) are asserted rather than median because regressions in convergence behavior most commonly surface at the tails — a small fraction of trials hitting a new pathology. + +### 2. Settled accuracy + +**Definition.** Relative error between the algorithm's settled target and the *ideal* target that produces exactly `shares_per_minute` at the true miner hashrate. + +``` +ideal_difficulty = true_hashrate × 60 / (shares_per_minute × 2^32) +ideal_target = difficulty_to_target(ideal_difficulty) +accuracy_error = abs(target_to_difficulty(final_target) / ideal_difficulty - 1) +``` + +Measured at trial end. Only meaningful for converged trials. + +**Measurement.** Read `trial.final_target` and `trial.final_true_hashrate`. Compute per formula. Skip DNF trials. + +**Collection.** `Vec` of accuracy errors over converged trials. + +**Characterization.** Percentile distribution. The distribution can be asymmetric — Poisson noise pushes accuracy either direction; over-estimates tend to have a longer tail than under-estimates because the share-count distribution is right-skewed at low expected counts. Report both percentile distribution and asymmetry indicator (`p90 / p10` ratio). + +**Summarization.** + +``` +Settled accuracy (12 spm, stable 1 PH/s): + p10=2.1% p50=8.9% p90=17.4% p95=20.9% p99=24.3% +``` + +**CI assertion.** `current.p50 <= baseline.p50 * 1.15 && current.p90 <= baseline.p90 * 1.15`. The 15% tolerance accounts for natural variation in trial seed selection without trial-count adjustment; tighter risks false positives. + +### 3. Steady-state jitter + +**Definition.** Fires per minute during the *settled period* of a trial. Settled period = `[convergence_time + settle_buffer, trial_end]` where `settle_buffer = 2 min` ensures any post-convergence transient fires don't pollute the count. Trial must have converged with at least `min_settled_window = 10 min` remaining. + +**Measurement.** Count fires in `[convergence_time + 2min, trial_end]`. Divide by `(trial_end - convergence_time - 2min) / 60.0` to get fires/min. + +**Collection.** `Vec` of jitter values over qualifying trials. + +**Characterization.** Percentile distribution. Most algorithms produce a distribution heavily concentrated near zero with occasional outlier spikes; both p50 and p95 matter. Report mean as well — useful for "expected fires per hour" calculations. + +**Summarization.** + +``` +Jitter (12 spm, stable 1 PH/s, ~20-min settled window): + p50=0.04 fires/min p90=0.16 p95=0.22 p99=0.32 + mean=0.07 (~4 fires/hour at steady state) +``` + +**CI assertion.** Two-part: +- `current.p50 <= baseline.p50 + 0.02` — absolute tolerance because baseline can be near zero where multiplicative tolerance is meaningless +- `current.p95 <= baseline.p95 * 1.25` — multiplicative tolerance on tail + +### 4. Reaction time + +**Definition.** Wall-clock time from a scheduled hashrate change event to the first fire after that event. Trial schedule must include a step change at a fixed `change_at_secs` (default 15 min). If no fire occurs within `react_window = 10 min` after change, record `DNF`. + +**Measurement.** Find smallest `f.at_secs - change_at_secs` over `f in trial.fires` with `f.at_secs > change_at_secs`. If none within `react_window`, mark `DNF`. + +**Collection.** Per (algorithm, share_rate, change_magnitude) cell, `Vec>`. + +**Characterization.** `reaction_rate` (fraction reacting in window) + percentile distribution over reacting trials. + +**Summarization.** + +``` +Reaction time (12 spm, -50% step at 15 min): + reacted: 1000/1000 (100%) + p10=1m15s p50=2m30s p90=4m45s p95=5m50s p99=7m20s +``` + +**CI assertion.** `current.reaction_rate >= baseline.reaction_rate - 0.02 && current.p50 <= baseline.p50 * 1.20`. Looser tolerance than convergence because reaction time has higher inherent variance — it depends on share-arrival timing relative to step change. + +### 5. Reaction sensitivity + +**Definition.** Probability of *any* fire within `react_window` after a step change of magnitude `Δ`, as a function of `Δ`. This is a *curve* — for each `Δ` in `{-50%, -25%, -10%, -5%, +5%, +10%, +25%, +50%}`, compute `P(fire within react_window | Δ)`. + +**Measurement.** For each `(algorithm, share_rate, Δ)` cell, run `M=500` trials. Sensitivity at `Δ` = (# trials with ≥1 fire in `[change_at_secs, change_at_secs + react_window]`) / M. + +**Collection.** Per cell, `HashMap` mapping each Δ to `(point_estimate, 95%_CI_half_width)`. The CI is computed from the binomial distribution: `1.96 * sqrt(p * (1-p) / M)`. + +**Characterization.** A 2D table — rows are Δ values, columns are share rates. + +**Summarization.** + +``` +Reaction sensitivity (P[fire within 5 min of step change]): + 6 spm 12 spm 30 spm 60 spm 120 spm + Δ=-50%: 0.97±0.02 1.00 1.00 1.00 1.00 + Δ=-25%: 0.65±0.04 0.81±0.03 0.94±0.02 0.99±0.01 1.00 + Δ=-10%: 0.18±0.03 0.31±0.04 0.55±0.04 0.78±0.04 0.95±0.02 + Δ=-5%: 0.05±0.02 0.09±0.03 0.18±0.03 0.31±0.04 0.52±0.04 + Δ=+5%: 0.06±0.02 0.10±0.03 0.20±0.04 0.33±0.04 0.55±0.04 + Δ=+10%: 0.22±0.04 0.33±0.04 0.59±0.04 0.80±0.04 0.96±0.02 + Δ=+25%: 0.71±0.04 0.85±0.03 0.95±0.02 1.00 1.00 + Δ=+50%: 0.99±0.01 1.00 1.00 1.00 1.00 +``` + +**CI assertion.** The shape of the curve matters more than any single point. Two assertions enforce its boundary behavior: + +- **Large-delta sensitivity floor**: `current.sensitivity[|Δ| ≥ 50%] >= baseline - 0.02` — algorithm must fire on genuine large changes +- **Small-delta sensitivity ceiling**: `current.sensitivity[|Δ| ≤ 5%] <= baseline + 0.05` — algorithm must not fire eagerly on noise-level deviations + +Mid-range Δ values (10-25%) are reported but not asserted — they're where the legitimate algorithmic tradeoffs live, and a reviewer should look at the full curve in the PR comment to evaluate them. A PR that legitimately shifts mid-range sensitivity is making an intentional design choice, not a regression, and the assertions shouldn't pretend otherwise. + +## Performance + +Estimated wall-clock runtimes: + +| Configuration | Trial count | Wall time | Use case | +| -- | -- | -- | -- | +| Smoke | 5 rates × 3 scenarios × 100 trials = 1,500 | ~500 ms | Every `cargo test` invocation | +| Standard | 5 rates × 6 scenarios × 1,000 trials = 30,000 | ~10 s | PR-gating CI workflow | +| Full baseline | 5 rates × 6 scenarios × 10,000 trials = 300,000 | ~100 s | Nightly drift detection | + +Caveats: +- Single-threaded. Parallelizing across cells with rayon would shorten by a factor of (cores − 1). +- Release-mode Rust. Debug mode is 5-10× slower; the CI integration should pin release mode for the sim crate even when running under `cargo test`. +- Performance ceiling is `try_vardiff` invocation cost (~10 μs). If a future algorithm has substantially more expensive per-call logic (e.g., SPRT with bookkeeping), runtimes scale linearly. + +**Verdict: standard CI is comfortable.** A 10-second PR-gating test fits well within typical CI budgets and provides better-than-current evidence of algorithmic behavior. The framework is fast enough that the limiting factor is assertion design, not compute. + +## CI assertion policy + +Three tiers: + +**Tier 1: Smoke test in `cargo test`.** +Runs the smoke configuration on every test invocation, including local developer runs. Asserts only catastrophic regressions: +- All `convergence_rate >= baseline - 0.05` +- All `p50` metrics within 2× of baseline +- All `reaction_sensitivity[|Δ| ≥ 50%] >= 0.90` + +Time budget: < 1 second. Purpose: catch outright algorithm breakage during development without waiting for full CI. + +**Tier 2: PR characterization in CI.** +Runs the standard configuration. Generates a delta report against the checked-in baseline. Asserts the per-metric tolerances described above (~10-20% on percentile values, absolute floors on probabilities). Posts the delta report as a PR comment. Required for merge. + +Time budget: ~15-30 seconds (with reporting overhead). Run on every PR touching `vardiff/*`. + +**Tier 3: Nightly baseline refresh.** +Runs the full configuration. If current measurements differ from checked-in baseline by less than statistical-significance-thresholds (95% CI overlap), no action. If differs significantly, opens an issue rather than breaking the build — could indicate environmental drift, RNG behavior change, etc. + +Purpose: detect slow drift, verify long-term stability of measurement infrastructure. + +## Baseline file format + +```toml +# vardiff_baseline.toml +[meta] +algorithm = "VardiffState" +algorithm_git_sha = "3ee7d8e1" +seed = 0xdeadbeef +trial_count = 1000 +generated_at = "2026-05-09T..." + +[cell.spm_12.cold_start_10gh_to_1ph] +convergence_rate = 0.998 +convergence_p10 = 465 +convergence_p50 = 510 +convergence_p90 = 590 +convergence_p95 = 625 +convergence_p99 = 680 + +settled_accuracy_p10 = 0.021 +settled_accuracy_p50 = 0.089 +settled_accuracy_p90 = 0.174 +settled_accuracy_p95 = 0.209 +settled_accuracy_p99 = 0.243 + +[cell.spm_12.stable_1ph] +jitter_p50 = 0.04 +jitter_p90 = 0.16 +jitter_p95 = 0.22 +jitter_p99 = 0.32 +jitter_mean = 0.07 + +[cell.spm_12.step_minus_50_at_15min] +reaction_rate = 1.0 +reaction_p10 = 75 +reaction_p50 = 150 +reaction_p90 = 285 +reaction_p95 = 350 +reaction_p99 = 440 + +[cell.spm_12.sensitivity] +delta_minus_50 = 1.00 +delta_minus_25 = 0.81 +delta_minus_10 = 0.31 +delta_minus_5 = 0.09 +delta_plus_5 = 0.10 +delta_plus_10 = 0.33 +delta_plus_25 = 0.85 +delta_plus_50 = 1.00 + +# ... repeated for each share rate ... +``` + +The baseline is checked into the repo. When a PR legitimately improves performance, the PR includes regenerated baseline values (the framework provides `cargo run -p vardiff-sim --bin refresh-baseline` for this) and the reviewer can diff the TOML to see exactly what changed. + +## Assertion code shape + +```rust +// in vardiff-sim/tests/baseline_regression.rs + +#[test] +fn classic_algorithm_no_regression() { + let baseline = load_baseline("vardiff_baseline.toml"); + let report = run_standard_baseline::(baseline.seed); + + for (cell_key, current) in &report.cells { + let prev = baseline.cells.get(cell_key) + .unwrap_or_else(|| panic!("missing baseline for {cell_key}")); + + if let (Some(c_rate), Some(b_rate)) = (current.convergence_rate, prev.convergence_rate) { + assert!(c_rate >= b_rate - 0.01, + "{cell_key}: convergence_rate {c_rate} < baseline {b_rate} - 0.01"); + } + if let (Some(c), Some(b)) = (current.convergence_p90, prev.convergence_p90) { + assert!(c <= (b as f64 * 1.10) as u64, + "{cell_key}: convergence p90 {c} > baseline {b} × 1.10"); + } + if let (Some(c), Some(b)) = (current.jitter_p50, prev.jitter_p50) { + assert!(c <= b + 0.02, + "{cell_key}: jitter p50 {c} > baseline {b} + 0.02"); + } + // ... and so on + } +} +``` + +Each assertion is named, identifies the cell, and produces a clear failure message showing baseline vs. current values. + +## Implementation plan (scope-narrowed) + +Five-step plan, ~3-4 days of focused work: + +1. **Algorithm clock injection** — `Clock` trait + `MockClock` impl. Modify `VardiffState::try_vardiff` and `reset_counter` to accept `&dyn Clock`. Default everywhere to `SystemClock`. ~50 LOC + minor test updates. + +2. **`vardiff-sim` crate** — New crate under `sv2/channels-sv2/sim/`. Contains: `Trial`, `TrialConfig`, `HashrateSchedule`, `FireEvent`, `run_trial`, the metric-computation functions, `Distribution` helper, percentile helper, exponential sampler with seeded RNG. ~500 LOC. + +3. **Baseline characterization binary** — `cargo run -p vardiff-sim --bin generate-baseline`. Runs the full baseline against `VardiffState`, writes `vardiff_baseline.toml` and a human-readable `vardiff_baseline.md`. ~200 LOC. + +4. **Regression test** — `#[test]` in sim crate that runs standard configuration and asserts per-metric tolerances against checked-in baseline. ~100 LOC. + +5. **Documentation** — `VARDIFF_SIMULATION.md` explaining the framework, what each metric means, how to read the baseline report, how to add a new algorithm, how to legitimately update baseline values in a PR. ~300 lines of prose. + +Total: ~850 LOC + 300 lines of prose. + +## Decisions deferred to implementation + +The following design questions will be revisited when the framework is closer to implementation. Each affects the rigor of the assertion policy rather than the framework's structure, and can be added incrementally without redesigning the core. + +- **Per-percentile confidence intervals on baseline values.** Whether to compute and store per-percentile CIs (binomial for sensitivity, bootstrap for time percentiles) and use "current value within baseline CI" as the assertion threshold rather than fixed tolerances. + +- **Algorithm version tracking in baseline.** How to record the algorithm crate's identity (git SHA, version string) in the baseline file and detect mismatches between baseline and current algorithm. + +- **RNG seed scope and derivation.** Whether the framework uses a single base seed for the entire baseline run or distinct per-cell seeds, and the derivation scheme used. + +## What this framework does NOT do + +Worth being explicit about scope limits: + +- **Doesn't validate that vardiff is wired into pool/jdc/translator correctly.** That's the network integration test's job. +- **Doesn't validate behavior under real miner share-arrival patterns.** Poisson is idealized; real miners are messier. Testnet4 observation is the right tool there. +- **Doesn't predict absolute deployment metrics.** It produces *comparisons* between algorithms under identical simulated load. "Algorithm A jitters 87% less than B" is the kind of claim it supports; "Algorithm A will produce 0.04 fires per minute on testnet4" is not. +- **Doesn't account for cross-component interaction.** Pool, JDC, and translator each run independent vardiff state machines that can fire on each other's adjustments. The framework characterizes one channel in isolation, not the multi-layer system. + +These are all valuable testing surfaces — they just live elsewhere in the test pyramid. The simulation framework's value is precisely that it isolates the algorithm from those other variables, so changes in algorithmic behavior are visible without confounders. diff --git a/sv2/channels-sv2/sim/src/baseline.rs b/sv2/channels-sv2/sim/src/baseline.rs new file mode 100644 index 0000000000..43e5c9bc24 --- /dev/null +++ b/sv2/channels-sv2/sim/src/baseline.rs @@ -0,0 +1,693 @@ +//! Baseline characterization: parameterized sweep of cells × trials, producing +//! a structured result that can be serialized to TOML (machine-readable, for +//! regression assertions) and Markdown (human-readable, for PR review). +//! +//! A **cell** is one tuple of `(algorithm, share_rate, scenario)`. The default +//! grid is 5 share rates × 10 scenarios = 50 cells. With `N=1000` trials per +//! cell that's 50,000 trials and ~20 seconds of wall clock at release-mode +//! speed. +//! +//! Scenarios are constructed to exercise specific algorithm behaviors: +//! - `ColdStart`: algorithm's initial belief is 5 orders of magnitude below +//! truth. Tests Phase 1 ramp-up + Phase 2 settling. +//! - `Stable`: algorithm starts aligned with truth. Tests Phase 3 jitter under +//! stable load. +//! - `Step { delta_pct }`: algorithm starts aligned, hashrate steps by +//! `delta_pct` at 15 minutes. Tests reaction time and reaction sensitivity. +//! The default grid includes deltas at {-50, -25, -10, -5, +5, +10, +25, +50} +//! so the full sensitivity curve is captured. + +use std::sync::Arc; + +use channels_sv2::vardiff::MockClock; +use channels_sv2::VardiffState; + +use crate::metrics::{ + convergence_time_distribution, jitter_distribution, reaction_time_distribution, + settled_accuracy_distribution, +}; +use crate::schedule::HashrateSchedule; +use crate::trial::{run_trial, Trial, TrialConfig}; + +/// Default trial-count per cell. Overridable via the `VARDIFF_BASELINE_TRIALS` +/// environment variable when running `generate-baseline`. +pub const DEFAULT_TRIAL_COUNT: usize = 1000; + +/// Default base seed. Overridable via `VARDIFF_BASELINE_SEED`. +pub const DEFAULT_BASELINE_SEED: u64 = 0xDEAD_BEEF_CAFE_F00D; + +/// Quiet-window for convergence detection (seconds). +pub const QUIET_WINDOW_SECS: u64 = 300; + +/// Settle-buffer for jitter measurement (seconds). +pub const SETTLE_BUFFER_SECS: u64 = 120; + +/// Minimum settled-window for jitter to be reported (seconds). +pub const MIN_SETTLED_WINDOW_SECS: u64 = 600; + +/// Reaction window for step-change scenarios (seconds). +pub const REACT_WINDOW_SECS: u64 = 300; + +/// Step-change scenarios fire their event at this offset from trial start. +pub const STEP_EVENT_AT_SECS: u64 = 15 * 60; + +/// Trial duration (seconds). +pub const TRIAL_DURATION_SECS: u64 = 30 * 60; + +/// The "true" miner hashrate used by every scenario (1 PH/s). This is held +/// constant across the grid so cells differ only in share rate and scenario +/// shape, not in absolute scale. +pub const TRUE_HASHRATE: f32 = 1.0e15; + +/// Default initial estimate for cold-start scenarios (10 GH/s — five orders of +/// magnitude below truth). +pub const COLD_START_INITIAL_HASHRATE: f32 = 1.0e10; + +/// What kind of trial to run. +#[derive(Debug, Clone, PartialEq)] +pub enum Scenario { + /// Algorithm's initial belief is far below true hashrate; tests + /// convergence. + ColdStart, + /// Algorithm starts aligned with truth; tests steady-state jitter. + Stable, + /// Hashrate steps by `delta_pct` at [`STEP_EVENT_AT_SECS`]; tests + /// reaction time and sensitivity. `delta_pct` is signed (e.g., `-50` + /// is a 50% drop, `+25` is a 25% rise). + Step { delta_pct: i32 }, +} + +impl Scenario { + /// Stable, machine-readable identifier suitable for use as a key in the + /// TOML output. Distinct scenarios produce distinct keys. + pub fn key(&self) -> String { + match self { + Scenario::ColdStart => "cold_start_10gh_to_1ph".to_string(), + Scenario::Stable => "stable_1ph".to_string(), + Scenario::Step { delta_pct } => { + if *delta_pct >= 0 { + format!("step_plus_{}_at_15min", delta_pct.unsigned_abs()) + } else { + format!("step_minus_{}_at_15min", delta_pct.unsigned_abs()) + } + } + } + } + + /// Builds the `TrialConfig` and `HashrateSchedule` for this scenario at a + /// given share rate. All scenarios share the same trial duration and tick + /// cadence. + pub fn build(&self, shares_per_minute: f32) -> (TrialConfig, HashrateSchedule) { + let common = TrialConfig { + duration_secs: TRIAL_DURATION_SECS, + initial_hashrate: TRUE_HASHRATE, + shares_per_minute, + tick_interval_secs: 60, + }; + match self { + Scenario::ColdStart => { + let config = TrialConfig { + initial_hashrate: COLD_START_INITIAL_HASHRATE, + ..common + }; + let schedule = HashrateSchedule::stable(TRUE_HASHRATE); + (config, schedule) + } + Scenario::Stable => { + let schedule = HashrateSchedule::stable(TRUE_HASHRATE); + (common, schedule) + } + Scenario::Step { delta_pct } => { + let post = TRUE_HASHRATE * (1.0 + *delta_pct as f32 / 100.0); + let schedule = HashrateSchedule::step(TRUE_HASHRATE, post, STEP_EVENT_AT_SECS); + (common, schedule) + } + } + } +} + +/// A single (algorithm, share_rate, scenario) cell. The algorithm is currently +/// implicit — only `VardiffState` is characterized by this build of the +/// framework — but the cell key in the output baseline carries scenario and +/// rate explicitly. +#[derive(Debug, Clone)] +pub struct Cell { + pub shares_per_minute: f32, + pub scenario: Scenario, +} + +impl Cell { + /// Stable, machine-readable identifier suitable for use as a key in the + /// TOML output, in the form `spm_.`. + pub fn key(&self) -> String { + format!( + "spm_{}.{}", + self.shares_per_minute as u32, + self.scenario.key() + ) + } +} + +/// The full set of metrics computed for one cell. All time values are in +/// seconds; all rates and percentile values are dimensionless. +#[derive(Debug, Clone, Default)] +pub struct CellResult { + pub shares_per_minute: f32, + pub scenario_key: String, + + /// Fraction of trials that converged in `[0.0, 1.0]`. + pub convergence_rate: f64, + pub convergence_p10_secs: Option, + pub convergence_p50_secs: Option, + pub convergence_p90_secs: Option, + pub convergence_p95_secs: Option, + pub convergence_p99_secs: Option, + + pub settled_accuracy_p10: Option, + pub settled_accuracy_p50: Option, + pub settled_accuracy_p90: Option, + pub settled_accuracy_p95: Option, + pub settled_accuracy_p99: Option, + + pub jitter_p50_per_min: Option, + pub jitter_p90_per_min: Option, + pub jitter_p95_per_min: Option, + pub jitter_p99_per_min: Option, + pub jitter_mean_per_min: Option, + + /// Only populated for `Step` scenarios. `None` for `ColdStart`/`Stable`. + pub reaction_rate: Option, + pub reaction_p10_secs: Option, + pub reaction_p50_secs: Option, + pub reaction_p90_secs: Option, + pub reaction_p99_secs: Option, +} + +/// Runs a single cell: builds the scenario, runs `trial_count` trials with +/// deterministic seeds, computes metric distributions, returns a `CellResult`. +pub fn run_cell(cell: &Cell, trial_count: usize, base_seed: u64, cell_index: u64) -> CellResult { + let (config, schedule) = cell.scenario.build(cell.shares_per_minute); + + let mut trials: Vec = Vec::with_capacity(trial_count); + for trial_index in 0..trial_count { + let seed = base_seed + .wrapping_add(cell_index.wrapping_shl(20)) + .wrapping_add(trial_index as u64); + let clock = Arc::new(MockClock::new(0)); + let vardiff = VardiffState::new_with_clock(1.0, clock.clone()) + .expect("VardiffState construction should never fail"); + let trial = run_trial(vardiff, clock, config.clone(), &schedule, seed); + trials.push(trial); + } + + let (convergence_rate, conv_dist) = convergence_time_distribution(&trials, QUIET_WINDOW_SECS); + let accuracy = settled_accuracy_distribution(&trials); + let jitter = jitter_distribution( + &trials, + QUIET_WINDOW_SECS, + SETTLE_BUFFER_SECS, + MIN_SETTLED_WINDOW_SECS, + ); + + let (reaction_rate_opt, reaction_dist) = match cell.scenario { + Scenario::Step { .. } => { + let (rate, dist) = + reaction_time_distribution(&trials, STEP_EVENT_AT_SECS, REACT_WINDOW_SECS); + (Some(rate), Some(dist)) + } + _ => (None, None), + }; + + CellResult { + shares_per_minute: cell.shares_per_minute, + scenario_key: cell.scenario.key(), + convergence_rate, + convergence_p10_secs: conv_dist.p10(), + convergence_p50_secs: conv_dist.p50(), + convergence_p90_secs: conv_dist.p90(), + convergence_p95_secs: conv_dist.p95(), + convergence_p99_secs: conv_dist.p99(), + settled_accuracy_p10: accuracy.p10(), + settled_accuracy_p50: accuracy.p50(), + settled_accuracy_p90: accuracy.p90(), + settled_accuracy_p95: accuracy.p95(), + settled_accuracy_p99: accuracy.p99(), + jitter_p50_per_min: jitter.p50(), + jitter_p90_per_min: jitter.p90(), + jitter_p95_per_min: jitter.p95(), + jitter_p99_per_min: jitter.p99(), + jitter_mean_per_min: jitter.mean(), + reaction_rate: reaction_rate_opt, + reaction_p10_secs: reaction_dist.as_ref().and_then(|d| d.p10()), + reaction_p50_secs: reaction_dist.as_ref().and_then(|d| d.p50()), + reaction_p90_secs: reaction_dist.as_ref().and_then(|d| d.p90()), + reaction_p99_secs: reaction_dist.as_ref().and_then(|d| d.p99()), + } +} + +/// The default characterization grid: 5 share rates × 10 scenarios. +/// +/// Share rates: 6, 12, 30, 60, 120 — covering the operational range from +/// "current production default" through "high-throughput pool". +/// +/// Scenarios: +/// - `ColdStart` and `Stable` (jitter / convergence) +/// - `Step` at deltas ±50, ±25, ±10, ±5 (reaction sensitivity curve) +pub fn default_cells() -> Vec { + let rates: [f32; 5] = [6.0, 12.0, 30.0, 60.0, 120.0]; + let deltas: [i32; 8] = [-50, -25, -10, -5, 5, 10, 25, 50]; + let mut cells = Vec::new(); + for &spm in &rates { + cells.push(Cell { + shares_per_minute: spm, + scenario: Scenario::ColdStart, + }); + cells.push(Cell { + shares_per_minute: spm, + scenario: Scenario::Stable, + }); + for &delta_pct in &deltas { + cells.push(Cell { + shares_per_minute: spm, + scenario: Scenario::Step { delta_pct }, + }); + } + } + cells +} + +/// Runs every cell in `cells` against the classic [`VardiffState`] algorithm. +/// Sequential; rayon parallelism is a future optimization. +pub fn run_baseline(cells: &[Cell], trial_count: usize, base_seed: u64) -> Vec { + cells + .iter() + .enumerate() + .map(|(idx, cell)| run_cell(cell, trial_count, base_seed, idx as u64)) + .collect() +} + +// ============================================================================ +// Serialization: TOML +// ============================================================================ + +/// Serializes a baseline result set to TOML. +/// +/// Hand-written rather than via `serde` + `toml` to avoid adding dependencies +/// to the sim crate's lockfile (which we want to keep minimal given the +/// project's pinned 1.75 toolchain). The schema is small and stable enough +/// that the maintenance cost of hand-writing is low. +pub fn serialize_toml( + results: &[CellResult], + meta_algorithm: &str, + trial_count: usize, + base_seed: u64, +) -> String { + let mut out = String::new(); + + out.push_str("# Vardiff baseline characterization. Regenerate with\n"); + out.push_str("# `cargo run --release --bin generate-baseline` from the sim crate.\n\n"); + + out.push_str("[meta]\n"); + out.push_str(&format!("algorithm = \"{}\"\n", meta_algorithm)); + out.push_str(&format!("trial_count = {}\n", trial_count)); + out.push_str(&format!("base_seed = {}\n", base_seed)); + out.push_str(&format!("quiet_window_secs = {}\n", QUIET_WINDOW_SECS)); + out.push_str(&format!("settle_buffer_secs = {}\n", SETTLE_BUFFER_SECS)); + out.push_str(&format!( + "min_settled_window_secs = {}\n", + MIN_SETTLED_WINDOW_SECS + )); + out.push_str(&format!("react_window_secs = {}\n", REACT_WINDOW_SECS)); + out.push_str(&format!("step_event_at_secs = {}\n", STEP_EVENT_AT_SECS)); + out.push_str(&format!("trial_duration_secs = {}\n", TRIAL_DURATION_SECS)); + out.push('\n'); + + for result in results { + let key = format!( + "spm_{}.{}", + result.shares_per_minute as u32, result.scenario_key + ); + out.push_str(&format!("[cell.{}]\n", key)); + out.push_str(&format!( + "shares_per_minute = {}\n", + result.shares_per_minute + )); + out.push_str(&format!("scenario = \"{}\"\n", result.scenario_key)); + out.push_str(&format!("convergence_rate = {}\n", result.convergence_rate)); + write_opt( + &mut out, + "convergence_p10_secs", + result.convergence_p10_secs, + ); + write_opt( + &mut out, + "convergence_p50_secs", + result.convergence_p50_secs, + ); + write_opt( + &mut out, + "convergence_p90_secs", + result.convergence_p90_secs, + ); + write_opt( + &mut out, + "convergence_p95_secs", + result.convergence_p95_secs, + ); + write_opt( + &mut out, + "convergence_p99_secs", + result.convergence_p99_secs, + ); + write_opt( + &mut out, + "settled_accuracy_p10", + result.settled_accuracy_p10, + ); + write_opt( + &mut out, + "settled_accuracy_p50", + result.settled_accuracy_p50, + ); + write_opt( + &mut out, + "settled_accuracy_p90", + result.settled_accuracy_p90, + ); + write_opt( + &mut out, + "settled_accuracy_p95", + result.settled_accuracy_p95, + ); + write_opt( + &mut out, + "settled_accuracy_p99", + result.settled_accuracy_p99, + ); + write_opt(&mut out, "jitter_p50_per_min", result.jitter_p50_per_min); + write_opt(&mut out, "jitter_p90_per_min", result.jitter_p90_per_min); + write_opt(&mut out, "jitter_p95_per_min", result.jitter_p95_per_min); + write_opt(&mut out, "jitter_p99_per_min", result.jitter_p99_per_min); + write_opt(&mut out, "jitter_mean_per_min", result.jitter_mean_per_min); + if let Some(r) = result.reaction_rate { + out.push_str(&format!("reaction_rate = {}\n", r)); + } + write_opt(&mut out, "reaction_p10_secs", result.reaction_p10_secs); + write_opt(&mut out, "reaction_p50_secs", result.reaction_p50_secs); + write_opt(&mut out, "reaction_p90_secs", result.reaction_p90_secs); + write_opt(&mut out, "reaction_p99_secs", result.reaction_p99_secs); + out.push('\n'); + } + + out +} + +fn write_opt(out: &mut String, key: &str, value: Option) { + if let Some(v) = value { + out.push_str(&format!("{} = {}\n", key, v)); + } +} + +// ============================================================================ +// Serialization: Markdown +// ============================================================================ + +/// Serializes a baseline result set to a human-readable markdown summary. +/// +/// Tables are grouped by metric type (convergence / accuracy / jitter / +/// reaction time / reaction sensitivity) so a reviewer can scan one +/// property at a time across the operational range. +pub fn serialize_markdown( + results: &[CellResult], + meta_algorithm: &str, + trial_count: usize, + base_seed: u64, +) -> String { + let mut out = String::new(); + + out.push_str(&format!( + "# Vardiff baseline characterization — `{}`\n\n", + meta_algorithm + )); + out.push_str(&format!( + "*Generated by `cargo run --release --bin generate-baseline` from the \ + vardiff_sim crate. {} trials per cell, base seed `{:#x}`.*\n\n", + trial_count, base_seed + )); + + let rates = unique_rates(results); + + // ---- Convergence ---- + out.push_str("## Convergence time (cold start: 10 GH/s → 1 PH/s)\n\n"); + out.push_str("| share/min | rate | p10 | p50 | p90 | p99 |\n"); + out.push_str("| --- | --- | --- | --- | --- | --- |\n"); + for spm in &rates { + if let Some(r) = find_cell(results, *spm, "cold_start_10gh_to_1ph") { + out.push_str(&format!( + "| {} | {:.1}% | {} | {} | {} | {} |\n", + spm, + r.convergence_rate * 100.0, + fmt_duration(r.convergence_p10_secs), + fmt_duration(r.convergence_p50_secs), + fmt_duration(r.convergence_p90_secs), + fmt_duration(r.convergence_p99_secs), + )); + } + } + out.push('\n'); + + // ---- Settled accuracy ---- + out.push_str("## Settled accuracy (stable load, post-convergence)\n\n"); + out.push_str("`|final_hashrate / true_hashrate - 1|` at trial end. Smaller is better.\n\n"); + out.push_str("| share/min | p10 | p50 | p90 | p99 |\n"); + out.push_str("| --- | --- | --- | --- | --- |\n"); + for spm in &rates { + if let Some(r) = find_cell(results, *spm, "stable_1ph") { + out.push_str(&format!( + "| {} | {} | {} | {} | {} |\n", + spm, + fmt_pct(r.settled_accuracy_p10), + fmt_pct(r.settled_accuracy_p50), + fmt_pct(r.settled_accuracy_p90), + fmt_pct(r.settled_accuracy_p99), + )); + } + } + out.push('\n'); + + // ---- Jitter ---- + out.push_str("## Steady-state jitter (fires per minute)\n\n"); + out.push_str( + "Post-convergence rate of vardiff fires. Smaller is better — \ + ideal is zero under stable load.\n\n", + ); + out.push_str("| share/min | p50 | p90 | p99 | mean |\n"); + out.push_str("| --- | --- | --- | --- | --- |\n"); + for spm in &rates { + if let Some(r) = find_cell(results, *spm, "stable_1ph") { + out.push_str(&format!( + "| {} | {} | {} | {} | {} |\n", + spm, + fmt_f(r.jitter_p50_per_min), + fmt_f(r.jitter_p90_per_min), + fmt_f(r.jitter_p99_per_min), + fmt_f(r.jitter_mean_per_min), + )); + } + } + out.push('\n'); + + // ---- Reaction time (50% drop) ---- + out.push_str("## Reaction time to a 50% drop (step at 15 min)\n\n"); + out.push_str("| share/min | reacted | p10 | p50 | p90 | p99 |\n"); + out.push_str("| --- | --- | --- | --- | --- | --- |\n"); + for spm in &rates { + if let Some(r) = find_cell(results, *spm, "step_minus_50_at_15min") { + out.push_str(&format!( + "| {} | {:.1}% | {} | {} | {} | {} |\n", + spm, + r.reaction_rate.unwrap_or(0.0) * 100.0, + fmt_duration(r.reaction_p10_secs), + fmt_duration(r.reaction_p50_secs), + fmt_duration(r.reaction_p90_secs), + fmt_duration(r.reaction_p99_secs), + )); + } + } + out.push('\n'); + + // ---- Reaction sensitivity curve ---- + out.push_str("## Reaction sensitivity (P[fire within 5 min of step change])\n\n"); + out.push_str("| Δ% |"); + for spm in &rates { + out.push_str(&format!(" {} |", spm)); + } + out.push_str("\n| ---"); + for _ in &rates { + out.push_str(" | ---"); + } + out.push_str(" |\n"); + + let deltas: [i32; 8] = [-50, -25, -10, -5, 5, 10, 25, 50]; + for delta in &deltas { + let scenario_key = if *delta >= 0 { + format!("step_plus_{}_at_15min", delta.unsigned_abs()) + } else { + format!("step_minus_{}_at_15min", delta.unsigned_abs()) + }; + let sign = if *delta >= 0 { "+" } else { "" }; + out.push_str(&format!("| {}{}% |", sign, delta)); + for spm in &rates { + let pct = find_cell(results, *spm, &scenario_key) + .and_then(|r| r.reaction_rate) + .map(|r| format!(" {:.2} |", r)) + .unwrap_or_else(|| " — |".to_string()); + out.push_str(&pct); + } + out.push('\n'); + } + out.push('\n'); + + out +} + +fn unique_rates(results: &[CellResult]) -> Vec { + let mut rates: Vec = results.iter().map(|r| r.shares_per_minute as u32).collect(); + rates.sort_unstable(); + rates.dedup(); + rates +} + +fn find_cell<'a>( + results: &'a [CellResult], + spm: u32, + scenario_key: &str, +) -> Option<&'a CellResult> { + results + .iter() + .find(|r| r.shares_per_minute as u32 == spm && r.scenario_key == scenario_key) +} + +fn fmt_duration(secs: Option) -> String { + match secs { + None => "—".to_string(), + Some(s) => { + let total = s.round() as u64; + if total < 60 { + format!("{}s", total) + } else { + let m = total / 60; + let s = total % 60; + if s == 0 { + format!("{}m", m) + } else { + format!("{}m{:02}s", m, s) + } + } + } + } +} + +fn fmt_pct(v: Option) -> String { + match v { + None => "—".to_string(), + Some(f) => format!("{:.1}%", f * 100.0), + } +} + +fn fmt_f(v: Option) -> String { + match v { + None => "—".to_string(), + Some(f) => format!("{:.3}", f), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_cells_has_50_entries() { + // 5 share rates × (1 cold + 1 stable + 8 step deltas) = 50 cells + assert_eq!(default_cells().len(), 50); + } + + #[test] + fn scenario_keys_are_distinct() { + let cells = default_cells(); + let mut keys: Vec = cells.iter().map(|c| c.key()).collect(); + let total = keys.len(); + keys.sort(); + keys.dedup(); + assert_eq!(keys.len(), total, "cell keys must be unique"); + } + + #[test] + fn small_cell_run_produces_reasonable_results() { + // Run a tiny baseline (3 trials per cell) to exercise the full pipeline. + // This is a smoke test, not a characterization run. + let cells = vec![ + Cell { + shares_per_minute: 12.0, + scenario: Scenario::Stable, + }, + Cell { + shares_per_minute: 12.0, + scenario: Scenario::Step { delta_pct: -50 }, + }, + ]; + let results = run_baseline(&cells, 3, 0xCAFE); + assert_eq!(results.len(), 2); + assert!(results[0].convergence_rate >= 0.0 && results[0].convergence_rate <= 1.0); + assert_eq!(results[0].reaction_rate, None); // Stable has no reaction metric + assert!(results[1].reaction_rate.is_some()); // Step does + } + + #[test] + fn toml_serialization_includes_meta_and_cells() { + let cells = vec![Cell { + shares_per_minute: 12.0, + scenario: Scenario::Stable, + }]; + let results = run_baseline(&cells, 3, 0xCAFE); + let toml = serialize_toml(&results, "VardiffState", 3, 0xCAFE); + assert!(toml.contains("[meta]")); + assert!(toml.contains("algorithm = \"VardiffState\"")); + assert!(toml.contains("[cell.spm_12.stable_1ph]")); + assert!(toml.contains("convergence_rate =")); + } + + #[test] + fn markdown_serialization_includes_section_headers() { + let cells = vec![ + Cell { + shares_per_minute: 12.0, + scenario: Scenario::ColdStart, + }, + Cell { + shares_per_minute: 12.0, + scenario: Scenario::Stable, + }, + Cell { + shares_per_minute: 12.0, + scenario: Scenario::Step { delta_pct: -50 }, + }, + ]; + let results = run_baseline(&cells, 3, 0xCAFE); + let md = serialize_markdown(&results, "VardiffState", 3, 0xCAFE); + assert!(md.contains("# Vardiff baseline characterization")); + assert!(md.contains("## Convergence time")); + assert!(md.contains("## Settled accuracy")); + assert!(md.contains("## Steady-state jitter")); + assert!(md.contains("## Reaction time")); + assert!(md.contains("## Reaction sensitivity")); + } + + #[test] + fn fmt_duration_renders_minutes_and_seconds() { + assert_eq!(fmt_duration(Some(30.0)), "30s"); + assert_eq!(fmt_duration(Some(60.0)), "1m"); + assert_eq!(fmt_duration(Some(125.0)), "2m05s"); + assert_eq!(fmt_duration(None), "—"); + } +} diff --git a/sv2/channels-sv2/sim/src/bin/generate-baseline.rs b/sv2/channels-sv2/sim/src/bin/generate-baseline.rs new file mode 100644 index 0000000000..39f2db77c6 --- /dev/null +++ b/sv2/channels-sv2/sim/src/bin/generate-baseline.rs @@ -0,0 +1,91 @@ +//! Runs the default baseline grid against the classic [`VardiffState`] +//! algorithm and writes `vardiff_baseline.toml` (machine-readable, consumed +//! by regression assertions) and `vardiff_baseline.md` (human-readable +//! summary) to the current working directory. +//! +//! ## Usage +//! +//! From the sim crate root: +//! +//! ```text +//! cargo run --release --bin generate-baseline +//! ``` +//! +//! ## Configuration via environment +//! +//! - `VARDIFF_BASELINE_TRIALS` — trials per cell (default 1000). Set to a +//! small number like 50 for fast local iteration. +//! - `VARDIFF_BASELINE_SEED` — base seed for trial derivation (default +//! `0xDEAD_BEEF_CAFE_F00D`). Change only when re-running for variance +//! inspection; the checked-in baseline assumes the default. +//! - `VARDIFF_BASELINE_OUT_DIR` — directory to write outputs (default `.`). +//! +//! ## Runtime +//! +//! At default 1000 trials per cell × 50 cells × ~360 µs per trial, +//! the sweep is ~18 seconds of wall clock plus a few hundred ms of +//! reporting. Use `--release` — debug-mode is 5–10× slower. + +use std::env; +use std::fs; +use std::path::PathBuf; +use std::time::Instant; + +use vardiff_sim::baseline::{ + default_cells, run_baseline, serialize_markdown, serialize_toml, DEFAULT_BASELINE_SEED, + DEFAULT_TRIAL_COUNT, +}; + +fn main() -> std::io::Result<()> { + let trial_count = env_or("VARDIFF_BASELINE_TRIALS", DEFAULT_TRIAL_COUNT); + let base_seed = env_or_seed("VARDIFF_BASELINE_SEED", DEFAULT_BASELINE_SEED); + let out_dir = env::var("VARDIFF_BASELINE_OUT_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(".")); + + let cells = default_cells(); + eprintln!( + "Running baseline: {} cells × {} trials = {} total trials, base_seed = {:#x}", + cells.len(), + trial_count, + cells.len() * trial_count, + base_seed, + ); + eprintln!("Output directory: {}", out_dir.display()); + + let started = Instant::now(); + let results = run_baseline(&cells, trial_count, base_seed); + let elapsed = started.elapsed(); + eprintln!("Baseline run complete in {:.2}s", elapsed.as_secs_f64()); + + let toml = serialize_toml(&results, "VardiffState", trial_count, base_seed); + let md = serialize_markdown(&results, "VardiffState", trial_count, base_seed); + + fs::create_dir_all(&out_dir)?; + let toml_path = out_dir.join("vardiff_baseline.toml"); + let md_path = out_dir.join("vardiff_baseline.md"); + fs::write(&toml_path, toml)?; + fs::write(&md_path, md)?; + eprintln!("Wrote {}", toml_path.display()); + eprintln!("Wrote {}", md_path.display()); + + Ok(()) +} + +fn env_or(var: &str, default: T) -> T { + env::var(var) + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(default) +} + +fn env_or_seed(var: &str, default: u64) -> u64 { + if let Ok(s) = env::var(var) { + // Accept either decimal or 0x-prefixed hex. + if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) { + return u64::from_str_radix(hex, 16).unwrap_or(default); + } + return s.parse().unwrap_or(default); + } + default +} diff --git a/sv2/channels-sv2/sim/src/lib.rs b/sv2/channels-sv2/sim/src/lib.rs new file mode 100644 index 0000000000..34785f016f --- /dev/null +++ b/sv2/channels-sv2/sim/src/lib.rs @@ -0,0 +1,58 @@ +//! # Vardiff simulation framework +//! +//! Deterministic in-process simulation harness for characterizing the behavioral +//! attributes of any [`channels_sv2::vardiff::Vardiff`] implementation. +//! +//! The framework's purpose is to surface the operationally-important properties +//! of the vardiff algorithm in concrete, measurable terms so that any future +//! algorithmic improvement can be evaluated against a checked-in baseline. See +//! `VARDIFF_SIMULATION_FRAMEWORK.md` alongside this crate's `Cargo.toml` for +//! the full design. +//! +//! ## What this crate provides +//! +//! - [`run_trial`]: drives a single [`channels_sv2::vardiff::Vardiff`] +//! implementation through `duration_secs` of simulated time against a +//! Poisson share stream and a programmable hashrate schedule, recording +//! every retarget event. +//! - [`HashrateSchedule`]: step-function description of the miner's true +//! hashrate over time — supports stable, step-change, and arbitrary +//! piecewise-constant scenarios. +//! - [`XorShift64`]: deterministic RNG used for share-arrival sampling. +//! Trials are fully reproducible from a `(config, schedule, seed)` triple. +//! - [`metrics`]: distribution-computing functions over `Vec` — +//! convergence time, settled accuracy, steady-state jitter, reaction time, +//! reaction sensitivity. Each metric returns a [`Distribution`] supporting +//! percentile queries (p10–p99), mean, and count. +//! +//! ## Quickstart +//! +//! ```ignore +//! use std::sync::Arc; +//! use channels_sv2::vardiff::MockClock; +//! use channels_sv2::VardiffState; +//! use vardiff_sim::{run_trial, HashrateSchedule, TrialConfig}; +//! +//! let clock = Arc::new(MockClock::new(0)); +//! let vardiff = VardiffState::new_with_clock(1.0, clock.clone()).unwrap(); +//! let schedule = HashrateSchedule::stable(1.0e15); // 1 PH/s constant +//! let config = TrialConfig::default(); +//! let trial = run_trial(vardiff, clock, config, &schedule, /* seed */ 0xDEADBEEF); +//! println!("Algorithm fired {} times", trial.fires.len()); +//! ``` + +pub mod baseline; +pub mod metrics; +pub mod regression; +pub mod rng; +pub mod schedule; +pub mod trial; + +pub use metrics::{ + convergence_time_distribution, convergence_time_for_trial, jitter_distribution, + jitter_for_trial, reaction_sensitivity, reaction_time_distribution, reaction_time_for_trial, + settled_accuracy_distribution, settled_accuracy_for_trial, Distribution, +}; +pub use rng::{sample_exponential, sample_poisson, XorShift64}; +pub use schedule::HashrateSchedule; +pub use trial::{run_trial, FireEvent, Trial, TrialConfig}; diff --git a/sv2/channels-sv2/sim/src/metrics.rs b/sv2/channels-sv2/sim/src/metrics.rs new file mode 100644 index 0000000000..41364d2e05 --- /dev/null +++ b/sv2/channels-sv2/sim/src/metrics.rs @@ -0,0 +1,553 @@ +//! Metric computation over collections of [`Trial`] results. +//! +//! Each metric is computed as a distribution across many independent trials, +//! exposing percentiles (`p10` through `p99`), mean, and trial count. Where a +//! metric can fail (a trial that does not converge, does not react, etc.) the +//! function additionally reports the *rate* of successful trials — typically +//! both numbers matter and an algorithm regression usually shows up first in +//! the rate or in a tail percentile. +//! +//! See `VARDIFF_SIMULATION_FRAMEWORK.md` § "The five metrics" for the +//! definitional details of each. Implementations here follow those definitions +//! precisely. + +use crate::trial::{FireEvent, Trial}; + +/// A sorted collection of numeric trial-derived values, plus accessors for +/// summary statistics. +/// +/// Internally stores all values sorted ascending so percentile queries are +/// `O(1)`. Construction is `O(n log n)`. Designed for n in the low thousands — +/// not optimized for streaming. +#[derive(Clone)] +pub struct Distribution { + /// Sorted ascending. NaN values are filtered out at construction time. + sorted: Vec, +} + +impl Distribution { + /// Constructs a distribution from a vector of values. NaN values are + /// silently dropped (they would otherwise corrupt percentile ordering). + pub fn new(values: Vec) -> Self { + let mut sorted: Vec = values.into_iter().filter(|x| !x.is_nan()).collect(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + Self { sorted } + } + + /// Returns the value at the given percentile rank `p ∈ [0, 100]` using + /// nearest-rank interpolation. `None` if the distribution is empty. + /// + /// Nearest-rank rather than linear interpolation because the use cases are + /// (a) reporting, where readability of integer-positional values is fine, + /// and (b) regression assertions, where interpolation noise across runs + /// would muddy the comparison. + pub fn percentile(&self, p: f64) -> Option { + if self.sorted.is_empty() { + return None; + } + let n = self.sorted.len(); + let idx = ((p / 100.0) * (n as f64 - 1.0)).round() as usize; + Some(self.sorted[idx.min(n - 1)]) + } + + /// Returns the arithmetic mean. `None` if the distribution is empty. + pub fn mean(&self) -> Option { + if self.sorted.is_empty() { + return None; + } + Some(self.sorted.iter().sum::() / self.sorted.len() as f64) + } + + /// Returns the number of values in the distribution. + pub fn count(&self) -> usize { + self.sorted.len() + } + + pub fn p10(&self) -> Option { + self.percentile(10.0) + } + pub fn p25(&self) -> Option { + self.percentile(25.0) + } + pub fn p50(&self) -> Option { + self.percentile(50.0) + } + pub fn p75(&self) -> Option { + self.percentile(75.0) + } + pub fn p90(&self) -> Option { + self.percentile(90.0) + } + pub fn p95(&self) -> Option { + self.percentile(95.0) + } + pub fn p99(&self) -> Option { + self.percentile(99.0) + } +} + +impl std::fmt::Debug for Distribution { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Distribution(n={}", self.count())?; + if let Some(p) = self.p50() { + write!(f, " p50={p:.3}")?; + } + if let Some(p) = self.p90() { + write!(f, " p90={p:.3}")?; + } + if let Some(p) = self.p99() { + write!(f, " p99={p:.3}")?; + } + if let Some(m) = self.mean() { + write!(f, " mean={m:.3}")?; + } + write!(f, ")") + } +} + +// ============================================================================ +// Convergence time +// ============================================================================ + +/// Per-trial convergence time. Returns `Some(t_secs)` if the trial converged +/// (i.e., some fire `f` exists such that no subsequent fire occurs within +/// `[f.at_secs, f.at_secs + quiet_window_secs]` and that window fits inside +/// the trial). A trial with zero fires is treated as converged at time 0. +/// Returns `None` for trials whose fires never quiet down before the trial +/// duration ends. +/// +/// The reported time is the timestamp of the *last fire* before the quiet +/// window — not when the quiet-detector concludes. A single late re-fire +/// pushes convergence time forward to that fire's timestamp; the trial is +/// still considered converged provided the late fire is followed by enough +/// silence within the trial. +pub fn convergence_time_for_trial(trial: &Trial, quiet_window_secs: u64) -> Option { + if trial.fires.is_empty() { + return Some(0); + } + for (i, fire) in trial.fires.iter().enumerate() { + let quiet_end = fire.at_secs.saturating_add(quiet_window_secs); + if quiet_end > trial.config.duration_secs { + // No fire from here forward can satisfy the quiet-window-fits-in-trial + // constraint, so neither can any later fire — stop scanning. + break; + } + let has_subsequent_in_window = trial.fires[i + 1..] + .iter() + .any(|f2| f2.at_secs <= quiet_end); + if !has_subsequent_in_window { + return Some(fire.at_secs); + } + } + None +} + +/// Convergence-time distribution across a set of trials. +/// +/// Returns `(convergence_rate, time_distribution_secs)`: +/// +/// - `convergence_rate ∈ [0, 1]` is the fraction of trials that converged +/// (i.e., had at least one fire-followed-by-quiet-window or zero fires). +/// - `time_distribution_secs` contains the per-trial convergence times in +/// seconds for the converged trials only. DNF trials are excluded. +pub fn convergence_time_distribution( + trials: &[Trial], + quiet_window_secs: u64, +) -> (f64, Distribution) { + if trials.is_empty() { + return (0.0, Distribution::new(vec![])); + } + let mut times: Vec = Vec::with_capacity(trials.len()); + let mut converged = 0usize; + for trial in trials { + if let Some(t) = convergence_time_for_trial(trial, quiet_window_secs) { + converged += 1; + times.push(t as f64); + } + } + let rate = converged as f64 / trials.len() as f64; + (rate, Distribution::new(times)) +} + +// ============================================================================ +// Settled accuracy +// ============================================================================ + +/// Relative error between the algorithm's final hashrate estimate and the true +/// miner hashrate at trial end. +/// +/// `accuracy = |final_hashrate / true_hashrate - 1|`. A perfectly accurate +/// final estimate yields 0; a 50% over- or under-estimate yields 0.5. +/// +/// Returns `None` if the trial's true hashrate is zero or negative, which +/// would make the relative error undefined. +pub fn settled_accuracy_for_trial(trial: &Trial) -> Option { + let true_h = trial.true_hashrate_at_end as f64; + if true_h <= 0.0 { + return None; + } + let final_h = trial.final_hashrate as f64; + Some((final_h / true_h - 1.0).abs()) +} + +/// Distribution of settled accuracy errors across a set of trials. Trials with +/// non-positive true hashrate are silently dropped. +pub fn settled_accuracy_distribution(trials: &[Trial]) -> Distribution { + let values: Vec = trials + .iter() + .filter_map(settled_accuracy_for_trial) + .collect(); + Distribution::new(values) +} + +// ============================================================================ +// Steady-state jitter +// ============================================================================ + +/// Per-trial steady-state jitter: count of fires per minute during the +/// settled period of the trial. +/// +/// The settled period is `[convergence_time + settle_buffer_secs, trial_end]`. +/// `settle_buffer_secs` keeps any post-convergence transient fires from +/// polluting the count (these can happen right after the convergence +/// fire if the algorithm settles in a small oscillation). +/// +/// Returns `None` if: +/// - The trial did not converge (DNF). +/// - The settled window is shorter than `min_settled_window_secs` — too +/// little data to be meaningful. +pub fn jitter_for_trial( + trial: &Trial, + quiet_window_secs: u64, + settle_buffer_secs: u64, + min_settled_window_secs: u64, +) -> Option { + let convergence_time = convergence_time_for_trial(trial, quiet_window_secs)?; + let start = convergence_time.saturating_add(settle_buffer_secs); + let end = trial.config.duration_secs; + if end < start.saturating_add(min_settled_window_secs) { + return None; + } + let settled_fires = trial + .fires + .iter() + .filter(|f| f.at_secs >= start && f.at_secs <= end) + .count(); + let window_minutes = (end - start) as f64 / 60.0; + if window_minutes <= 0.0 { + return None; + } + Some(settled_fires as f64 / window_minutes) +} + +/// Distribution of steady-state jitter (fires per minute) across trials. +/// Trials that did not converge or had too-short settled windows are dropped. +pub fn jitter_distribution( + trials: &[Trial], + quiet_window_secs: u64, + settle_buffer_secs: u64, + min_settled_window_secs: u64, +) -> Distribution { + let values: Vec = trials + .iter() + .filter_map(|t| { + jitter_for_trial( + t, + quiet_window_secs, + settle_buffer_secs, + min_settled_window_secs, + ) + }) + .collect(); + Distribution::new(values) +} + +// ============================================================================ +// Reaction time and sensitivity +// ============================================================================ + +/// Per-trial reaction time: seconds from a scheduled event at +/// `event_at_secs` to the first fire occurring after that event, provided +/// the fire happens within `react_window_secs` of the event. +/// +/// Returns `None` if no fire occurs in `[event_at_secs, event_at_secs + +/// react_window_secs]`. The trial schedule itself is not inspected — the +/// caller is responsible for configuring trials with the relevant step +/// change at `event_at_secs`. +pub fn reaction_time_for_trial( + trial: &Trial, + event_at_secs: u64, + react_window_secs: u64, +) -> Option { + let window_end = event_at_secs.saturating_add(react_window_secs); + let first_post_event_fire: Option<&FireEvent> = trial + .fires + .iter() + .find(|f| f.at_secs > event_at_secs && f.at_secs <= window_end); + first_post_event_fire.map(|f| f.at_secs - event_at_secs) +} + +/// Distribution of reaction times across trials. Returns +/// `(reaction_rate, time_distribution_secs)`: +/// +/// - `reaction_rate ∈ [0, 1]` is the fraction of trials that produced any +/// fire in the reaction window. This is exactly the **reaction +/// sensitivity** of the algorithm at whatever step magnitude the trials +/// were configured with — see [`reaction_sensitivity`] for an explicit +/// alias. +/// - `time_distribution_secs` contains per-trial reaction times for trials +/// that reacted. +pub fn reaction_time_distribution( + trials: &[Trial], + event_at_secs: u64, + react_window_secs: u64, +) -> (f64, Distribution) { + if trials.is_empty() { + return (0.0, Distribution::new(vec![])); + } + let mut times: Vec = Vec::with_capacity(trials.len()); + let mut reacted = 0usize; + for trial in trials { + if let Some(t) = reaction_time_for_trial(trial, event_at_secs, react_window_secs) { + reacted += 1; + times.push(t as f64); + } + } + let rate = reacted as f64 / trials.len() as f64; + (rate, Distribution::new(times)) +} + +/// Reaction sensitivity: probability of *any* fire within `react_window_secs` +/// of a scheduled event at `event_at_secs`. Returns a value in `[0, 1]`. +/// +/// Identical to the `reaction_rate` component of [`reaction_time_distribution`]; +/// kept as a separate function because it's the metric the baseline tables +/// report and the assertion policy operates on. +pub fn reaction_sensitivity(trials: &[Trial], event_at_secs: u64, react_window_secs: u64) -> f64 { + let (rate, _) = reaction_time_distribution(trials, event_at_secs, react_window_secs); + rate +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use crate::trial::{FireEvent, TrialConfig}; + + fn trial_with_fires(duration_secs: u64, fire_times: &[u64]) -> Trial { + let config = TrialConfig { + duration_secs, + ..TrialConfig::default() + }; + let fires = fire_times + .iter() + .map(|&t| FireEvent { + at_secs: t, + old_hashrate: 1.0, + new_hashrate: 1.0, + }) + .collect(); + Trial { + config, + seed: 0, + fires, + final_hashrate: 1.0e15, + true_hashrate_at_end: 1.0e15, + } + } + + fn trial_with_final_and_true(final_h: f32, true_h: f32) -> Trial { + let config = TrialConfig::default(); + Trial { + config, + seed: 0, + fires: vec![], + final_hashrate: final_h, + true_hashrate_at_end: true_h, + } + } + + // ---- Distribution ---- + + #[test] + fn distribution_empty_returns_none_for_stats() { + let d = Distribution::new(vec![]); + assert!(d.percentile(50.0).is_none()); + assert!(d.mean().is_none()); + assert_eq!(d.count(), 0); + } + + #[test] + fn distribution_percentiles_use_nearest_rank() { + // Sorted values: 1 .. 100. Length 100. Indices 0..99. + let values: Vec = (1..=100).map(|i| i as f64).collect(); + let d = Distribution::new(values); + assert_eq!(d.count(), 100); + // p50 → index round((50/100) * 99) = round(49.5) = 50 → value 51. + // p50 in nearest-rank can land at 50 or 51 depending on rounding; verify + // the rounding direction. + let p50 = d.p50().unwrap(); + assert!(p50 == 50.0 || p50 == 51.0, "p50 was {p50}"); + // p99 → round(0.99 * 99) = round(98.01) = 98 → value 99. + assert_eq!(d.p99().unwrap(), 99.0); + // p10 → round(0.10 * 99) = round(9.9) = 10 → value 11. + assert_eq!(d.p10().unwrap(), 11.0); + } + + #[test] + fn distribution_mean_is_arithmetic_mean() { + let d = Distribution::new(vec![1.0, 2.0, 3.0, 4.0, 5.0]); + assert_eq!(d.mean().unwrap(), 3.0); + } + + #[test] + fn distribution_filters_nan() { + let d = Distribution::new(vec![1.0, f64::NAN, 2.0]); + assert_eq!(d.count(), 2); + assert_eq!(d.mean().unwrap(), 1.5); + } + + // ---- Convergence ---- + + #[test] + fn convergence_no_fires_means_converged_at_zero() { + let t = trial_with_fires(1800, &[]); + assert_eq!(convergence_time_for_trial(&t, 300), Some(0)); + } + + #[test] + fn convergence_picks_first_fire_followed_by_quiet_window() { + // Fires at 60, 300, 360, 420. quiet_window = 600. + // 60 followed by 300 (≤ 60+600=660): not converged. + // 300 followed by 360 (≤ 900): not converged. + // 360 followed by 420 (≤ 960): not converged. + // 420 followed by nothing (trial duration 1800, window 1020 fits): CONVERGED at 420. + let t = trial_with_fires(1800, &[60, 300, 360, 420]); + assert_eq!(convergence_time_for_trial(&t, 600), Some(420)); + } + + #[test] + fn convergence_returns_none_when_fires_never_quiet_down() { + // Fires every minute throughout the trial; never a 600s gap. + let fires: Vec = (0..30).map(|i| (i + 1) * 60).collect(); + let t = trial_with_fires(1800, &fires); + assert_eq!(convergence_time_for_trial(&t, 600), None); + } + + #[test] + fn convergence_late_fire_too_close_to_trial_end_is_dnf() { + // Fire at 1700, trial ends at 1800. quiet_window=600. 1700+600=2300 > 1800. + // No room to verify quietude → DNF. + let t = trial_with_fires(1800, &[1700]); + assert_eq!(convergence_time_for_trial(&t, 600), None); + } + + #[test] + fn convergence_rate_aggregates_correctly() { + let trials = vec![ + trial_with_fires(1800, &[]), // converged at 0 + trial_with_fires(1800, &[60, 1100]), // converged at 1100 (quiet through end) + { + // never quiets + let fires: Vec = (0..30).map(|i| (i + 1) * 60).collect(); + trial_with_fires(1800, &fires) + }, + ]; + let (rate, dist) = convergence_time_distribution(&trials, 600); + assert!((rate - 2.0 / 3.0).abs() < 1e-9); + assert_eq!(dist.count(), 2); + } + + // ---- Settled accuracy ---- + + #[test] + fn settled_accuracy_perfect_estimate_is_zero() { + let t = trial_with_final_and_true(1.0e15, 1.0e15); + assert_eq!(settled_accuracy_for_trial(&t).unwrap(), 0.0); + } + + #[test] + fn settled_accuracy_50_percent_over_is_half() { + let t = trial_with_final_and_true(1.5e15, 1.0e15); + assert!((settled_accuracy_for_trial(&t).unwrap() - 0.5).abs() < 1e-6); + } + + #[test] + fn settled_accuracy_50_percent_under_is_half() { + let t = trial_with_final_and_true(0.5e15, 1.0e15); + assert!((settled_accuracy_for_trial(&t).unwrap() - 0.5).abs() < 1e-6); + } + + #[test] + fn settled_accuracy_zero_truth_returns_none() { + let t = trial_with_final_and_true(1.0e15, 0.0); + assert!(settled_accuracy_for_trial(&t).is_none()); + } + + // ---- Jitter ---- + + #[test] + fn jitter_counts_post_settle_fires_per_minute() { + // Trial: duration 1800. quiet_window=300. settle_buffer=120. min_settled=600. + // Fires: 60 (settle), 700, 1000, 1500. 60 followed by 700 (>360=quiet_end), CONVERGED at 60. + // Settled window: [180, 1800] = 1620s = 27 min. Fires in window: 700, 1000, 1500 → 3 fires. + // Jitter: 3 / 27 ≈ 0.111. + let t = trial_with_fires(1800, &[60, 700, 1000, 1500]); + let j = jitter_for_trial(&t, 300, 120, 600).unwrap(); + assert!((j - 3.0 / 27.0).abs() < 1e-6, "jitter = {j}"); + } + + #[test] + fn jitter_zero_when_no_settled_fires() { + // Single fire then complete silence. + let t = trial_with_fires(1800, &[60]); + let j = jitter_for_trial(&t, 300, 120, 600).unwrap(); + assert_eq!(j, 0.0); + } + + #[test] + fn jitter_none_when_trial_did_not_converge() { + let fires: Vec = (0..30).map(|i| (i + 1) * 60).collect(); + let t = trial_with_fires(1800, &fires); + assert!(jitter_for_trial(&t, 600, 120, 600).is_none()); + } + + // ---- Reaction ---- + + #[test] + fn reaction_time_finds_first_post_event_fire_in_window() { + // Event at 900. Fires before and after. + let t = trial_with_fires(1800, &[60, 500, 950, 1100]); + assert_eq!(reaction_time_for_trial(&t, 900, 300), Some(50)); + } + + #[test] + fn reaction_time_none_when_no_fire_in_window() { + // Event at 900, react_window=120. Next fire at 1100 (delta 200, > window). + let t = trial_with_fires(1800, &[60, 1100]); + assert!(reaction_time_for_trial(&t, 900, 120).is_none()); + } + + #[test] + fn reaction_time_ignores_fires_at_or_before_event() { + // Fire exactly at event time: not a reaction (must be > event_at_secs). + let t = trial_with_fires(1800, &[900, 1500]); + // 900 itself doesn't count; 1500 is way past react_window=300 from 900. + assert!(reaction_time_for_trial(&t, 900, 300).is_none()); + } + + #[test] + fn reaction_sensitivity_aggregates_to_fraction() { + let trials = vec![ + trial_with_fires(1800, &[1000]), // reacts + trial_with_fires(1800, &[1100]), // reacts + trial_with_fires(1800, &[60]), // no post-event fire + trial_with_fires(1800, &[]), // no fires at all + ]; + let sensitivity = reaction_sensitivity(&trials, 900, 300); + assert!((sensitivity - 0.5).abs() < 1e-9); + } +} diff --git a/sv2/channels-sv2/sim/src/regression.rs b/sv2/channels-sv2/sim/src/regression.rs new file mode 100644 index 0000000000..d27fd15f62 --- /dev/null +++ b/sv2/channels-sv2/sim/src/regression.rs @@ -0,0 +1,728 @@ +//! Regression testing against a checked-in baseline. +//! +//! Loads the committed `vardiff_baseline.toml`, re-runs the same characterization +//! grid against the current algorithm, and asserts each metric is within +//! tolerance of the recorded baseline. Catches: +//! +//! - Changes to `VardiffState` that affect behavior +//! - Changes to the simulation that affect trial outcomes +//! - Changes to metric computation that change reported values +//! - Changes to the cell grid in [`crate::baseline`] that perturb measurements +//! +//! ## Tolerance policy +//! +//! The thresholds derive from the design proposal's CI assertion policy: +//! +//! - **Convergence rate**: `current >= baseline - 0.01` +//! - **Convergence p90**: `current <= baseline * 1.10` +//! - **Settled accuracy p50 / p90**: `current <= baseline * 1.15` +//! - **Jitter p50**: `current <= baseline + 0.02` (absolute; baseline can be near zero) +//! - **Jitter p95**: `current <= baseline * 1.25` +//! - **Reaction rate**: `current >= baseline - 0.02` +//! - **Reaction p50**: `current <= baseline * 1.20` +//! - **Sensitivity at large |Δ| (|Δ| >= 50%)**: `current >= baseline - 0.02` +//! - **Sensitivity at small |Δ| (|Δ| <= 5%)**: `current <= baseline + 0.05` +//! +//! Mid-range deltas (10-25%) are reported in the baseline but not asserted on +//! — they're where legitimate algorithmic tradeoffs live, and a reviewer +//! should look at the full delta in PR review rather than the test pretending +//! these are pass/fail. +//! +//! ## Running +//! +//! The regression test is `#[ignore]`-d by default because it runs the full +//! ~5-second baseline sweep on every invocation. Run it explicitly: +//! +//! ```text +//! cargo test --release -- --ignored +//! ``` + +use std::collections::HashMap; + +/// Parsed baseline document — the in-memory representation of +/// `vardiff_baseline.toml`. +#[derive(Debug, Clone)] +pub struct BaselineDoc { + pub meta: BaselineMeta, + /// Keyed by the full cell key (e.g., `spm_12.stable_1ph`). + pub cells: HashMap, +} + +#[derive(Debug, Clone)] +pub struct BaselineMeta { + pub algorithm: String, + pub trial_count: usize, + pub base_seed: u64, +} + +/// Subset of `CellResult` fields tracked in the baseline. All metric values +/// are `Option` because the emitter omits absent fields (e.g., +/// reaction_* on non-Step scenarios, percentile fields on empty distributions). +#[derive(Debug, Clone, Default)] +pub struct CellBaseline { + pub shares_per_minute: f32, + pub scenario: String, + pub convergence_rate: Option, + pub convergence_p90_secs: Option, + pub settled_accuracy_p50: Option, + pub settled_accuracy_p90: Option, + pub jitter_p50_per_min: Option, + pub jitter_p95_per_min: Option, + pub reaction_rate: Option, + pub reaction_p50_secs: Option, +} + +/// A single tolerance violation found by comparing a current measurement +/// against the baseline. +#[derive(Debug, Clone)] +pub struct Discrepancy { + pub cell_key: String, + pub metric: String, + pub baseline: f64, + pub current: f64, + pub tolerance: String, +} + +impl std::fmt::Display for Discrepancy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}: {} = {:.4} vs baseline {:.4} (tolerance: {})", + self.cell_key, self.metric, self.current, self.baseline, self.tolerance, + ) + } +} + +/// Result of comparing a fresh baseline run against the checked-in document. +#[derive(Debug, Default)] +pub struct ComparisonReport { + pub failures: Vec, + pub baseline_cells_not_in_current: Vec, + pub current_cells_not_in_baseline: Vec, +} + +impl ComparisonReport { + pub fn is_clean(&self) -> bool { + self.failures.is_empty() + && self.baseline_cells_not_in_current.is_empty() + && self.current_cells_not_in_baseline.is_empty() + } +} + +// ============================================================================ +// TOML parsing +// ============================================================================ + +/// Parses a baseline TOML document. +/// +/// Implements the small subset of TOML our serializer emits: top-level +/// `[meta]` section, per-cell `[cell.spm_X.SCENARIO]` sections, key-value +/// pairs with integer / float / quoted-string / hex-integer values, and +/// `#` comments. Not a general TOML parser — intentionally limited to keep +/// the regression-testing infrastructure dependency-free. +pub fn parse_baseline_toml(input: &str) -> Result { + let mut current_section: Option = None; + let mut meta_kv: HashMap = HashMap::new(); + let mut cells_kv: HashMap> = HashMap::new(); + + for (line_no, raw_line) in input.lines().enumerate() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some(section) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { + current_section = Some(section.to_string()); + continue; + } + let (key, raw_value) = line + .split_once('=') + .ok_or_else(|| ParseError::MalformedLine(line_no + 1, line.to_string()))?; + let key = key.trim().to_string(); + let value = parse_value(raw_value.trim()) + .ok_or_else(|| ParseError::MalformedValue(line_no + 1, raw_value.trim().to_string()))?; + match current_section.as_deref() { + Some("meta") => { + meta_kv.insert(key, value); + } + Some(s) if s.starts_with("cell.") => { + let cell_key = s.strip_prefix("cell.").unwrap().to_string(); + cells_kv.entry(cell_key).or_default().insert(key, value); + } + Some(other) => { + return Err(ParseError::UnknownSection(line_no + 1, other.to_string())); + } + None => { + return Err(ParseError::OrphanKey(line_no + 1, key)); + } + } + } + + let meta = BaselineMeta { + algorithm: meta_kv + .get("algorithm") + .and_then(RawValue::as_string) + .ok_or(ParseError::MissingMetaKey("algorithm"))?, + trial_count: meta_kv + .get("trial_count") + .and_then(RawValue::as_int) + .ok_or(ParseError::MissingMetaKey("trial_count"))? as usize, + base_seed: meta_kv + .get("base_seed") + .and_then(RawValue::as_u64) + .ok_or(ParseError::MissingMetaKey("base_seed"))?, + }; + + let mut cells = HashMap::with_capacity(cells_kv.len()); + for (key, kv) in cells_kv { + let cell = CellBaseline { + shares_per_minute: kv + .get("shares_per_minute") + .and_then(RawValue::as_float) + .ok_or(ParseError::MissingCellKey(key.clone(), "shares_per_minute"))? + as f32, + scenario: kv + .get("scenario") + .and_then(RawValue::as_string) + .ok_or(ParseError::MissingCellKey(key.clone(), "scenario"))?, + convergence_rate: kv.get("convergence_rate").and_then(RawValue::as_float), + convergence_p90_secs: kv.get("convergence_p90_secs").and_then(RawValue::as_float), + settled_accuracy_p50: kv.get("settled_accuracy_p50").and_then(RawValue::as_float), + settled_accuracy_p90: kv.get("settled_accuracy_p90").and_then(RawValue::as_float), + jitter_p50_per_min: kv.get("jitter_p50_per_min").and_then(RawValue::as_float), + jitter_p95_per_min: kv.get("jitter_p95_per_min").and_then(RawValue::as_float), + reaction_rate: kv.get("reaction_rate").and_then(RawValue::as_float), + reaction_p50_secs: kv.get("reaction_p50_secs").and_then(RawValue::as_float), + }; + cells.insert(key, cell); + } + + Ok(BaselineDoc { meta, cells }) +} + +#[derive(Debug, Clone)] +enum RawValue { + Int(i64), + /// Unsigned integer wider than `i64::MAX` (e.g., `base_seed` which can be + /// any `u64`). Stored separately so `as_u64` is exact rather than going + /// through `f64`, which loses precision past 2^53. + Uint(u64), + Float(f64), + Str(String), +} + +impl RawValue { + fn as_float(&self) -> Option { + match self { + RawValue::Int(i) => Some(*i as f64), + RawValue::Uint(u) => Some(*u as f64), + RawValue::Float(f) => Some(*f), + RawValue::Str(_) => None, + } + } + fn as_int(&self) -> Option { + match self { + RawValue::Int(i) => Some(*i), + RawValue::Uint(u) if *u <= i64::MAX as u64 => Some(*u as i64), + _ => None, + } + } + fn as_u64(&self) -> Option { + match self { + RawValue::Uint(u) => Some(*u), + RawValue::Int(i) if *i >= 0 => Some(*i as u64), + _ => None, + } + } + fn as_string(&self) -> Option { + match self { + RawValue::Str(s) => Some(s.clone()), + _ => None, + } + } +} + +fn parse_value(s: &str) -> Option { + if let Some(quoted) = s.strip_prefix('"').and_then(|q| q.strip_suffix('"')) { + return Some(RawValue::Str(quoted.to_string())); + } + if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) { + // Use u64::from_str_radix so values up to u64::MAX (e.g., seeds with + // the high bit set) parse exactly. Conversion to i64 happens later + // only if a caller specifically requests `as_int`. + return u64::from_str_radix(hex, 16).ok().map(RawValue::Uint); + } + if let Ok(i) = s.parse::() { + return Some(RawValue::Int(i)); + } + // Decimal integer that exceeds i64::MAX but fits in u64 (e.g., the default + // base_seed when emitted as decimal). + if let Ok(u) = s.parse::() { + return Some(RawValue::Uint(u)); + } + if let Ok(f) = s.parse::() { + return Some(RawValue::Float(f)); + } + None +} + +#[derive(Debug, Clone)] +pub enum ParseError { + MalformedLine(usize, String), + MalformedValue(usize, String), + UnknownSection(usize, String), + OrphanKey(usize, String), + MissingMetaKey(&'static str), + MissingCellKey(String, &'static str), +} + +impl std::fmt::Display for ParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ParseError::MalformedLine(l, s) => write!(f, "line {l}: malformed line {s:?}"), + ParseError::MalformedValue(l, s) => write!(f, "line {l}: malformed value {s:?}"), + ParseError::UnknownSection(l, s) => write!(f, "line {l}: unknown section [{s}]"), + ParseError::OrphanKey(l, k) => write!(f, "line {l}: key {k:?} outside any section"), + ParseError::MissingMetaKey(k) => write!(f, "missing required meta key {k:?}"), + ParseError::MissingCellKey(c, k) => { + write!(f, "cell {c:?}: missing required key {k:?}") + } + } + } +} + +impl std::error::Error for ParseError {} + +// ============================================================================ +// Comparison +// ============================================================================ + +use crate::baseline::CellResult; + +/// Compares a fresh baseline run against the checked-in baseline. Per-metric +/// tolerances are encoded directly in this function — they implement the +/// policy documented at the top of this module. +pub fn compare_to_baseline(current: &[CellResult], baseline: &BaselineDoc) -> ComparisonReport { + let mut report = ComparisonReport::default(); + + let current_keys: HashMap = current + .iter() + .map(|r| { + ( + format!("spm_{}.{}", r.shares_per_minute as u32, r.scenario_key), + r, + ) + }) + .collect(); + + for (key, b) in &baseline.cells { + let Some(c) = current_keys.get(key) else { + report.baseline_cells_not_in_current.push(key.clone()); + continue; + }; + + // Convergence + compare_min( + &mut report, + key, + "convergence_rate", + b.convergence_rate, + Some(c.convergence_rate), + 0.01, + "current >= baseline - 0.01", + ); + compare_max_mul( + &mut report, + key, + "convergence_p90_secs", + b.convergence_p90_secs, + c.convergence_p90_secs, + 1.10, + "current <= baseline * 1.10", + ); + + // Settled accuracy + compare_max_mul( + &mut report, + key, + "settled_accuracy_p50", + b.settled_accuracy_p50, + c.settled_accuracy_p50, + 1.15, + "current <= baseline * 1.15", + ); + compare_max_mul( + &mut report, + key, + "settled_accuracy_p90", + b.settled_accuracy_p90, + c.settled_accuracy_p90, + 1.15, + "current <= baseline * 1.15", + ); + + // Jitter — absolute tolerance on p50 (baseline can be near zero), + // multiplicative on p95. + compare_max_abs( + &mut report, + key, + "jitter_p50_per_min", + b.jitter_p50_per_min, + c.jitter_p50_per_min, + 0.02, + "current <= baseline + 0.02", + ); + compare_max_mul( + &mut report, + key, + "jitter_p95_per_min", + b.jitter_p95_per_min, + c.jitter_p95_per_min, + 1.25, + "current <= baseline * 1.25", + ); + + // Reaction (only present on Step scenarios) + if let (Some(_), Some(_)) = (b.reaction_rate, c.reaction_rate) { + // Sensitivity assertions split by Δ magnitude. + // + // For |Δ| >= 50% (the "must respond" floor) we assert the rate + // is at least baseline - 0.02 — catches an algorithm that fails + // to fire on genuine large changes. + // + // For |Δ| <= 5% (the "must not fire on noise" ceiling) we assert + // the rate is at most baseline + 0.05 — catches an algorithm that + // fires too eagerly on noise. + // + // For mid-range Δ (10-25%) we don't assert — that's where the + // legitimate algorithmic tradeoffs live and a reviewer should + // judge by looking at the full delta. + let abs_delta = extract_delta_magnitude(&b.scenario); + match abs_delta { + Some(d) if d >= 50 => { + compare_min( + &mut report, + key, + "reaction_rate", + b.reaction_rate, + c.reaction_rate, + 0.02, + "current >= baseline - 0.02 (|Δ|>=50%)", + ); + } + Some(d) if d <= 5 => { + compare_max_abs( + &mut report, + key, + "reaction_rate", + b.reaction_rate, + c.reaction_rate, + 0.05, + "current <= baseline + 0.05 (|Δ|<=5%)", + ); + } + _ => { /* mid-range: not asserted */ } + } + + // Reaction time p50 — slower is a regression. + compare_max_mul( + &mut report, + key, + "reaction_p50_secs", + b.reaction_p50_secs, + c.reaction_p50_secs, + 1.20, + "current <= baseline * 1.20", + ); + } + } + + for key in current_keys.keys() { + if !baseline.cells.contains_key(key) { + report.current_cells_not_in_baseline.push(key.clone()); + } + } + + report +} + +/// Asserts `current >= baseline - abs_tolerance`. Records a discrepancy if not. +fn compare_min( + report: &mut ComparisonReport, + cell_key: &str, + metric: &str, + baseline: Option, + current: Option, + abs_tolerance: f64, + tolerance_desc: &str, +) { + if let (Some(b), Some(c)) = (baseline, current) { + if c < b - abs_tolerance { + report.failures.push(Discrepancy { + cell_key: cell_key.to_string(), + metric: metric.to_string(), + baseline: b, + current: c, + tolerance: tolerance_desc.to_string(), + }); + } + } +} + +/// Asserts `current <= baseline * mul_tolerance`. Skips if baseline is zero +/// (multiplicative tolerance is meaningless there — use `compare_max_abs`). +fn compare_max_mul( + report: &mut ComparisonReport, + cell_key: &str, + metric: &str, + baseline: Option, + current: Option, + mul_tolerance: f64, + tolerance_desc: &str, +) { + if let (Some(b), Some(c)) = (baseline, current) { + if b == 0.0 { + // Fall back to a small absolute tolerance — multiplying zero by + // any factor still yields zero. + if c > 0.01 { + report.failures.push(Discrepancy { + cell_key: cell_key.to_string(), + metric: metric.to_string(), + baseline: b, + current: c, + tolerance: format!( + "{} (baseline was 0; current must be ≤ 0.01)", + tolerance_desc + ), + }); + } + return; + } + if c > b * mul_tolerance { + report.failures.push(Discrepancy { + cell_key: cell_key.to_string(), + metric: metric.to_string(), + baseline: b, + current: c, + tolerance: tolerance_desc.to_string(), + }); + } + } +} + +/// Asserts `current <= baseline + abs_tolerance`. +fn compare_max_abs( + report: &mut ComparisonReport, + cell_key: &str, + metric: &str, + baseline: Option, + current: Option, + abs_tolerance: f64, + tolerance_desc: &str, +) { + if let (Some(b), Some(c)) = (baseline, current) { + if c > b + abs_tolerance { + report.failures.push(Discrepancy { + cell_key: cell_key.to_string(), + metric: metric.to_string(), + baseline: b, + current: c, + tolerance: tolerance_desc.to_string(), + }); + } + } +} + +/// Extracts the absolute delta from a `step_plus_NN_at_15min` or +/// `step_minus_NN_at_15min` scenario key. Returns `None` for non-step +/// scenarios. +fn extract_delta_magnitude(scenario_key: &str) -> Option { + let rest = scenario_key + .strip_prefix("step_plus_") + .or_else(|| scenario_key.strip_prefix("step_minus_"))?; + let num = rest.split('_').next()?; + num.parse::().ok() +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use crate::baseline::{default_cells, run_baseline}; + + #[test] + fn parser_handles_minimal_document() { + let toml = r#" +# A comment +[meta] +algorithm = "VardiffState" +trial_count = 1000 +base_seed = 0xDEADBEEF + +[cell.spm_12.stable_1ph] +shares_per_minute = 12 +scenario = "stable_1ph" +convergence_rate = 0.95 +jitter_p50_per_min = 0.04 + "#; + let doc = parse_baseline_toml(toml).expect("should parse"); + assert_eq!(doc.meta.algorithm, "VardiffState"); + assert_eq!(doc.meta.trial_count, 1000); + assert_eq!(doc.meta.base_seed, 0xDEADBEEF); + let cell = &doc.cells["spm_12.stable_1ph"]; + assert_eq!(cell.shares_per_minute, 12.0); + assert_eq!(cell.scenario, "stable_1ph"); + assert_eq!(cell.convergence_rate, Some(0.95)); + assert_eq!(cell.jitter_p50_per_min, Some(0.04)); + assert_eq!(cell.reaction_rate, None); + } + + #[test] + fn parser_handles_u64_base_seed_beyond_i64_max() { + // 0xDEADBEEFCAFEF00D = 16045690984503111693, exceeds i64::MAX. + // Tests both the hex path and the decimal path. + let hex_toml = r#" +[meta] +algorithm = "VardiffState" +trial_count = 1000 +base_seed = 0xDEADBEEFCAFEF00D + "#; + let doc = parse_baseline_toml(hex_toml).expect("hex base_seed should parse"); + assert_eq!(doc.meta.base_seed, 0xDEAD_BEEF_CAFE_F00D); + + let decimal_toml = r#" +[meta] +algorithm = "VardiffState" +trial_count = 1000 +base_seed = 16045690984503111693 + "#; + let doc = parse_baseline_toml(decimal_toml).expect("large decimal base_seed should parse"); + assert_eq!(doc.meta.base_seed, 16045690984503111693u64); + } + + #[test] + fn parser_rejects_orphan_key() { + let toml = "orphan = 1\n"; + assert!(matches!( + parse_baseline_toml(toml).unwrap_err(), + ParseError::OrphanKey(_, _) + )); + } + + #[test] + fn parser_rejects_missing_required_meta_keys() { + let toml = "[meta]\nalgorithm = \"X\"\n"; + assert!(matches!( + parse_baseline_toml(toml).unwrap_err(), + ParseError::MissingMetaKey("trial_count") + )); + } + + #[test] + fn extract_delta_magnitude_parses_step_scenarios() { + assert_eq!(extract_delta_magnitude("step_minus_50_at_15min"), Some(50)); + assert_eq!(extract_delta_magnitude("step_plus_10_at_15min"), Some(10)); + assert_eq!(extract_delta_magnitude("stable_1ph"), None); + assert_eq!(extract_delta_magnitude("cold_start_10gh_to_1ph"), None); + } + + #[test] + fn comparison_reports_clean_for_identical_run() { + // Tiny synthetic baseline; current with identical numbers should not + // produce failures. + let toml = r#" +[meta] +algorithm = "VardiffState" +trial_count = 1 +base_seed = 1 + +[cell.spm_12.stable_1ph] +shares_per_minute = 12 +scenario = "stable_1ph" +convergence_rate = 0.95 +jitter_p50_per_min = 0.04 +jitter_p95_per_min = 0.20 + "#; + let baseline = parse_baseline_toml(toml).unwrap(); + let current = vec![CellResult { + shares_per_minute: 12.0, + scenario_key: "stable_1ph".to_string(), + convergence_rate: 0.95, + jitter_p50_per_min: Some(0.04), + jitter_p95_per_min: Some(0.20), + ..Default::default() + }]; + let report = compare_to_baseline(¤t, &baseline); + assert!(report.is_clean(), "Expected clean report, got {report:#?}"); + } + + #[test] + fn comparison_flags_convergence_rate_drop() { + let toml = r#" +[meta] +algorithm = "VardiffState" +trial_count = 1 +base_seed = 1 + +[cell.spm_12.stable_1ph] +shares_per_minute = 12 +scenario = "stable_1ph" +convergence_rate = 0.95 + "#; + let baseline = parse_baseline_toml(toml).unwrap(); + let current = vec![CellResult { + shares_per_minute: 12.0, + scenario_key: "stable_1ph".to_string(), + convergence_rate: 0.80, // dropped by 15pp + ..Default::default() + }]; + let report = compare_to_baseline(¤t, &baseline); + assert_eq!(report.failures.len(), 1); + assert_eq!(report.failures[0].metric, "convergence_rate"); + } + + /// The slow regression test. Runs the full 50-cell baseline at the same + /// seed / trial_count as the checked-in baseline and asserts every cell + /// is within tolerance. Marked `#[ignore]` because it runs ~5-15s in + /// release mode; CI should invoke this via `cargo test --release -- + /// --ignored`. + #[test] + #[ignore = "slow regression test; run with `cargo test --release -- --ignored`"] + fn classic_algorithm_no_regression() { + let baseline_str = include_str!("../vardiff_baseline.toml"); + let baseline = parse_baseline_toml(baseline_str).expect("baseline parses"); + + let cells = default_cells(); + let current = run_baseline(&cells, baseline.meta.trial_count, baseline.meta.base_seed); + + let report = compare_to_baseline(¤t, &baseline); + + if !report.is_clean() { + let mut msg = String::new(); + if !report.failures.is_empty() { + msg.push_str(&format!( + "\n{} tolerance failures:\n", + report.failures.len() + )); + for d in &report.failures { + msg.push_str(&format!(" {}\n", d)); + } + } + if !report.baseline_cells_not_in_current.is_empty() { + msg.push_str("\nbaseline cells not in current:\n"); + for k in &report.baseline_cells_not_in_current { + msg.push_str(&format!(" {}\n", k)); + } + } + if !report.current_cells_not_in_baseline.is_empty() { + msg.push_str("\ncurrent cells not in baseline:\n"); + for k in &report.current_cells_not_in_baseline { + msg.push_str(&format!(" {}\n", k)); + } + } + panic!("Regression detected:{}", msg); + } + } +} diff --git a/sv2/channels-sv2/sim/src/rng.rs b/sv2/channels-sv2/sim/src/rng.rs new file mode 100644 index 0000000000..6582aaa813 --- /dev/null +++ b/sv2/channels-sv2/sim/src/rng.rs @@ -0,0 +1,245 @@ +//! Deterministic RNG and exponential-distribution sampling for trial simulation. +//! +//! Uses XorShift64 rather than the `rand` crate because: +//! - Reproducibility across crate versions: we hand-roll the bit operations +//! so two runs of the framework, possibly years apart with different +//! transitive dependency versions, produce identical share-arrival streams +//! from the same seed. +//! - Minimal dependency footprint for the sim crate. +//! +//! XorShift64 is statistically adequate for sampling Poisson inter-arrival +//! times in this context. It is not cryptographically secure and not +//! recommended for any use beyond simulation. + +/// XorShift64 pseudo-random number generator. +/// +/// Algorithm from George Marsaglia, "Xorshift RNGs" (2003). 64-bit state, +/// period 2^64 - 1. +#[derive(Debug, Clone)] +pub struct XorShift64 { + state: u64, +} + +impl XorShift64 { + /// Constructs a new generator from the given seed. Zero is replaced with + /// a non-zero constant since XorShift cannot have a zero state. + pub fn new(seed: u64) -> Self { + Self { + state: if seed == 0 { + 0xDEAD_BEEF_CAFE_F00D + } else { + seed + }, + } + } + + /// Generates the next u64 in the stream. + pub fn next_u64(&mut self) -> u64 { + let mut x = self.state; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + self.state = x; + x + } + + /// Returns a uniformly-distributed f64 in the open interval (0, 1). + /// + /// Never returns exactly 0 (which would make `ln(u)` undefined for + /// exponential sampling) and never returns exactly 1. + pub fn next_f64(&mut self) -> f64 { + // Take 53 bits of randomness (f64 mantissa precision). + let bits = self.next_u64() >> 11; // 53 bits, range [0, 2^53) + // Map [0, 2^53) → (0, 1): add 1 to numerator to exclude 0, divide by + // (2^53 + 1) to keep result strictly < 1. + ((bits as f64) + 1.0) / ((1u64 << 53) as f64 + 1.0) + } +} + +/// Samples one value from `Exp(rate)` — the exponential distribution with the +/// given rate parameter (events per unit time). +/// +/// Returns the inter-arrival time. If `rate` is non-positive or non-finite, +/// returns `f64::INFINITY` (interpreted by callers as "next event never +/// happens within the trial window"). +/// +/// Uses inverse-CDF sampling: `T = -ln(U) / λ` where `U ~ Uniform(0, 1)`. +pub fn sample_exponential(rng: &mut XorShift64, rate: f64) -> f64 { + if rate <= 0.0 || !rate.is_finite() { + return f64::INFINITY; + } + -rng.next_f64().ln() / rate +} + +/// Samples one value from `Poisson(λ)` — the number of events in a unit time +/// interval given an average rate of `lambda` events per interval. +/// +/// Uses Knuth's algorithm for `λ < 30` (exact, but slow for large `λ`) and a +/// normal approximation for `λ >= 30` (accurate to within ~1% of the true +/// distribution's variance, very fast). The threshold is conservative — Knuth +/// is fine well past 30 — and chosen so the normal approximation's tail +/// behavior is reliable for our use cases (share counts during long ticks). +/// +/// Returns 0 for non-positive, NaN, or infinite `lambda`. Saturates at +/// `u32::MAX` for absurdly large `lambda` (the trial-loop math can produce +/// extreme values during early cold-start ticks before the algorithm reacts). +pub fn sample_poisson(rng: &mut XorShift64, lambda: f64) -> u32 { + if !lambda.is_finite() || lambda <= 0.0 { + return 0; + } + if lambda < 30.0 { + poisson_knuth(rng, lambda) + } else { + poisson_normal(rng, lambda) + } +} + +/// Knuth's algorithm for Poisson sampling: multiply uniforms until the running +/// product falls below `e^(-λ)`. The number of multiplications minus one is +/// the sample. O(λ) expected work. +fn poisson_knuth(rng: &mut XorShift64, lambda: f64) -> u32 { + let l = (-lambda).exp(); + if l == 0.0 { + // Numerically underflowed (λ very large) — should have gone via normal + // approximation, but defend against the boundary. + return poisson_normal(rng, lambda); + } + let mut k = 0u32; + let mut p = 1.0; + loop { + // Pathological cap: stops runaway loops if `e^(-λ)` is denormalized. + if k >= 10_000 { + return k; + } + p *= rng.next_f64(); + if p <= l { + return k; + } + k = k.saturating_add(1); + } +} + +/// Normal approximation for Poisson: `λ + sqrt(λ) * Z` where `Z ~ N(0, 1)`, +/// rounded and clamped to non-negative. Accurate for `λ >= ~10`; we use 30 as +/// the threshold to give the approximation some margin. O(1). +fn poisson_normal(rng: &mut XorShift64, lambda: f64) -> u32 { + // Box-Muller transform for one N(0, 1) sample. + let u1 = rng.next_f64(); + let u2 = rng.next_f64(); + let z = (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos(); + let raw = lambda + lambda.sqrt() * z; + if !raw.is_finite() || raw <= 0.0 { + return 0; + } + if raw >= u32::MAX as f64 { + return u32::MAX; + } + raw.round() as u32 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn xorshift64_is_deterministic_from_seed() { + let mut a = XorShift64::new(42); + let mut b = XorShift64::new(42); + for _ in 0..1000 { + assert_eq!(a.next_u64(), b.next_u64()); + } + } + + #[test] + fn xorshift64_zero_seed_is_remapped() { + let mut zero = XorShift64::new(0); + // First output should not be zero (state was remapped, not left as 0). + assert_ne!(zero.next_u64(), 0); + } + + #[test] + fn next_f64_is_in_open_unit_interval() { + let mut rng = XorShift64::new(1); + for _ in 0..10_000 { + let u = rng.next_f64(); + assert!(u > 0.0 && u < 1.0, "next_f64 returned {u}, expected (0, 1)"); + } + } + + #[test] + fn exponential_mean_approximates_one_over_rate() { + let mut rng = XorShift64::new(7); + let rate = 0.5; // Exp(0.5) has mean 2.0 + let n = 100_000; + let mean: f64 = (0..n) + .map(|_| sample_exponential(&mut rng, rate)) + .sum::() + / n as f64; + // Standard error of mean for Exp(0.5) over n samples is 2 / sqrt(n) ≈ 0.0063. + // 6-sigma envelope is ±0.04 — very forgiving. + assert!( + (mean - 2.0).abs() < 0.04, + "Exp(0.5) sample mean {mean} should be near 2.0" + ); + } + + #[test] + fn exponential_nonpositive_rate_returns_infinity() { + let mut rng = XorShift64::new(1); + assert!(sample_exponential(&mut rng, 0.0).is_infinite()); + assert!(sample_exponential(&mut rng, -1.0).is_infinite()); + assert!(sample_exponential(&mut rng, f64::NAN).is_infinite()); + } + + #[test] + fn poisson_zero_or_negative_lambda_returns_zero() { + let mut rng = XorShift64::new(1); + assert_eq!(sample_poisson(&mut rng, 0.0), 0); + assert_eq!(sample_poisson(&mut rng, -1.0), 0); + assert_eq!(sample_poisson(&mut rng, f64::NAN), 0); + assert_eq!(sample_poisson(&mut rng, f64::INFINITY), 0); + } + + #[test] + fn poisson_small_lambda_mean_approximates_lambda() { + // Knuth path: λ = 5.0 + let mut rng = XorShift64::new(42); + let n = 50_000; + let sum: u64 = (0..n).map(|_| sample_poisson(&mut rng, 5.0) as u64).sum(); + let mean = sum as f64 / n as f64; + // SE of mean for Poisson(5) over n=50k samples is sqrt(5/50000) ≈ 0.010. + // 5-sigma envelope is ±0.05. + assert!( + (mean - 5.0).abs() < 0.05, + "Poisson(5) sample mean {mean} should be near 5.0" + ); + } + + #[test] + fn poisson_large_lambda_mean_approximates_lambda() { + // Normal-approximation path: λ = 1000.0 + let mut rng = XorShift64::new(42); + let n = 10_000; + let sum: u64 = (0..n) + .map(|_| sample_poisson(&mut rng, 1000.0) as u64) + .sum(); + let mean = sum as f64 / n as f64; + // SE of mean for Poisson(1000) over n=10k samples is sqrt(1000/10000) ≈ 0.32. + // 5-sigma envelope is ±1.6. + assert!( + (mean - 1000.0).abs() < 1.6, + "Poisson(1000) sample mean {mean} should be near 1000.0" + ); + } + + #[test] + fn poisson_is_deterministic_from_seed() { + let mut a = XorShift64::new(123); + let mut b = XorShift64::new(123); + for _ in 0..1000 { + // Mix of small and large λ to exercise both paths + assert_eq!(sample_poisson(&mut a, 3.0), sample_poisson(&mut b, 3.0)); + assert_eq!(sample_poisson(&mut a, 500.0), sample_poisson(&mut b, 500.0)); + } + } +} diff --git a/sv2/channels-sv2/sim/src/schedule.rs b/sv2/channels-sv2/sim/src/schedule.rs new file mode 100644 index 0000000000..4b7aca244d --- /dev/null +++ b/sv2/channels-sv2/sim/src/schedule.rs @@ -0,0 +1,128 @@ +//! Hashrate schedule describing the miner's true hashrate over simulated time. +//! +//! A trial's miner is modeled as a step function over time. The simplest case +//! is a constant hashrate ([`HashrateSchedule::stable`]); other scenarios +//! exercise the algorithm's response to genuine load changes via step +//! transitions, brief throttling episodes, etc. + +/// Specifies the miner's true hashrate as a piecewise-constant step function +/// over simulated time. +/// +/// Segments are stored sorted by start time. A query at time `t` returns the +/// hashrate of the most recent segment with `start_secs <= t`. The schedule +/// must contain at least one segment starting at time 0. +#[derive(Debug, Clone)] +pub struct HashrateSchedule { + /// Sorted ascending by `start_secs`. First entry always has `start_secs == 0`. + segments: Vec<(u64, f32)>, +} + +impl HashrateSchedule { + /// Constructs a schedule from `segments`, which need not be sorted. The + /// schedule is normalized: segments are sorted ascending by start time + /// and a `(0, _)` entry is required. + /// + /// # Panics + /// Panics if `segments` is empty or does not contain a segment starting + /// at time 0. + pub fn new(mut segments: Vec<(u64, f32)>) -> Self { + assert!( + !segments.is_empty(), + "HashrateSchedule must contain at least one segment" + ); + segments.sort_by_key(|&(t, _)| t); + assert_eq!( + segments[0].0, 0, + "HashrateSchedule must contain a segment starting at time 0" + ); + Self { segments } + } + + /// A schedule with a single constant hashrate for the entire trial. + pub fn stable(hashrate: f32) -> Self { + Self::new(vec![(0, hashrate)]) + } + + /// A schedule that holds `before` until `change_at_secs`, then `after` + /// thereafter. Convenient for "miner doubled hashrate" or "miner halved + /// hashrate" scenarios. + pub fn step(before: f32, after: f32, change_at_secs: u64) -> Self { + Self::new(vec![(0, before), (change_at_secs, after)]) + } + + /// A schedule that drops to `during` between `start_secs` and `end_secs`, + /// then recovers to `baseline`. Models a transient throttle or partial + /// failure that resolves itself. + pub fn throttle(baseline: f32, during: f32, start_secs: u64, end_secs: u64) -> Self { + Self::new(vec![ + (0, baseline), + (start_secs, during), + (end_secs, baseline), + ]) + } + + /// Returns the hashrate at simulated time `t`. + pub fn at(&self, t: u64) -> f32 { + let mut current = self.segments[0].1; + for &(start, h) in &self.segments { + if t >= start { + current = h; + } else { + break; + } + } + current + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stable_returns_same_hashrate_at_any_time() { + let s = HashrateSchedule::stable(1.0e15); + assert_eq!(s.at(0), 1.0e15); + assert_eq!(s.at(60), 1.0e15); + assert_eq!(s.at(3600), 1.0e15); + } + + #[test] + fn step_changes_at_the_right_time() { + let s = HashrateSchedule::step(1.0e15, 5.0e14, 900); + assert_eq!(s.at(0), 1.0e15); + assert_eq!(s.at(899), 1.0e15); + assert_eq!(s.at(900), 5.0e14); + assert_eq!(s.at(1800), 5.0e14); + } + + #[test] + fn throttle_dips_and_recovers() { + let s = HashrateSchedule::throttle(1.0e15, 7.0e14, 900, 1200); + assert_eq!(s.at(0), 1.0e15); + assert_eq!(s.at(900), 7.0e14); + assert_eq!(s.at(1199), 7.0e14); + assert_eq!(s.at(1200), 1.0e15); + assert_eq!(s.at(1800), 1.0e15); + } + + #[test] + fn segments_passed_unsorted_are_normalized() { + let s = HashrateSchedule::new(vec![(900, 5.0e14), (0, 1.0e15), (1800, 1.0e15)]); + assert_eq!(s.at(0), 1.0e15); + assert_eq!(s.at(900), 5.0e14); + assert_eq!(s.at(1800), 1.0e15); + } + + #[test] + #[should_panic(expected = "starting at time 0")] + fn missing_zero_segment_panics() { + let _ = HashrateSchedule::new(vec![(60, 1.0e15)]); + } + + #[test] + #[should_panic(expected = "at least one segment")] + fn empty_schedule_panics() { + let _ = HashrateSchedule::new(vec![]); + } +} diff --git a/sv2/channels-sv2/sim/src/trial.rs b/sv2/channels-sv2/sim/src/trial.rs new file mode 100644 index 0000000000..b2e5d9e8ca --- /dev/null +++ b/sv2/channels-sv2/sim/src/trial.rs @@ -0,0 +1,331 @@ +//! Single-trial simulation execution. +//! +//! A trial drives a vardiff implementation through `duration_secs` of +//! simulated time, recording every retarget event. The simulation steps in +//! `tick_interval_secs` increments (matching the algorithm's ticker cadence +//! in production); at each tick a Poisson-distributed share count is sampled +//! and added to the vardiff state, then `try_vardiff` is called. +//! +//! This per-tick model is preferred over individual share-arrival sampling +//! for two reasons: +//! +//! 1. **Rate independence.** Poisson sampling works for any λ from near-zero +//! to millions; inter-arrival sampling would need sub-second time +//! resolution to model high share rates accurately. +//! 2. **Algorithm fidelity.** The algorithm only acts at tick boundaries, so +//! within-tick share timing is irrelevant to its behavior. Per-tick +//! sampling produces identical algorithm outcomes at a fraction of the +//! cost. +//! +//! ## Schedule-change approximation +//! +//! The expected share count for a tick is computed from the [`HashrateSchedule`] +//! evaluated at the tick interval's **midpoint**. For schedules whose +//! transitions land at tick boundaries (the default scenarios in +//! [`crate::baseline`] all do — step at `15min = 900s = 15 × tick_interval`), +//! the midpoint correctly identifies the segment that covers the entire tick +//! and the simulation is exact. +//! +//! For schedules that transition *mid-tick* (a custom scenario where the +//! change time isn't a multiple of `tick_interval_secs`), the midpoint +//! approximation treats the entire tick as if it were at the rate of +//! whichever segment contains the midpoint. The error is bounded — at most +//! one tick's worth of shares charged to the wrong segment — but if a +//! caller cares about sub-tick-precision schedule changes, they should +//! either align changes to tick boundaries or extend `run_trial` to split +//! intervals at schedule boundaries. +//! +//! See [`run_trial`] for the entry point. + +use crate::rng::{sample_poisson, XorShift64}; +use crate::schedule::HashrateSchedule; +use bitcoin::Target; +use channels_sv2::target::hash_rate_to_target; +use channels_sv2::vardiff::{MockClock, Vardiff}; +use std::sync::Arc; + +/// Configuration parameters for a single simulation trial. +#[derive(Debug, Clone)] +pub struct TrialConfig { + /// Duration of the trial in simulated seconds. + pub duration_secs: u64, + /// The algorithm's initial belief about miner hashrate (H/s). Determines + /// the initial target. If far from the schedule's true hashrate, the + /// trial exercises convergence (Phase 1). + pub initial_hashrate: f32, + /// Configured shares-per-minute target the algorithm tries to achieve. + pub shares_per_minute: f32, + /// Cadence at which the algorithm's tick fires, in simulated seconds. + /// Defaults to 60; should match production for realistic measurement. + pub tick_interval_secs: u64, +} + +impl Default for TrialConfig { + fn default() -> Self { + Self { + // 30 simulated minutes is enough to observe Phase 1 + Phase 3 in + // the most common scenarios; long enough to be statistically useful, + // short enough that 1000 trials complete in seconds of wall clock. + duration_secs: 30 * 60, + initial_hashrate: 1.0e10, + shares_per_minute: 12.0, + tick_interval_secs: 60, + } + } +} + +/// A single retarget event captured during a trial. +#[derive(Debug, Clone)] +pub struct FireEvent { + /// Simulated time at which `try_vardiff` returned `Some(new_hashrate)`. + pub at_secs: u64, + /// The algorithm's hashrate estimate prior to this fire. + pub old_hashrate: f32, + /// The algorithm's hashrate estimate after this fire. + pub new_hashrate: f32, +} + +/// Result of running a single trial. +#[derive(Debug, Clone)] +pub struct Trial { + /// The configuration the trial was run under. + pub config: TrialConfig, + /// The seed that produced this trial. Carried in the result so that a + /// failing trial can be reproduced in isolation by replaying with the + /// same `(config, schedule, seed)` triple. + pub seed: u64, + /// All retarget events, in chronological order. + pub fires: Vec, + /// The algorithm's hashrate estimate at trial end. + pub final_hashrate: f32, + /// The true miner hashrate (from the schedule) at trial end. + pub true_hashrate_at_end: f32, +} + +/// Runs one simulation trial against a vardiff implementation. +/// +/// `vardiff` is moved into the function and driven through the trial; its +/// final state is not returned (the necessary information is captured in +/// `Trial.fires` and `Trial.final_hashrate`). `clock` is the same +/// [`MockClock`] that `vardiff` was constructed with — the simulation +/// advances it forward via `clock.set()` as it processes each tick. +/// +/// The trial loop steps in `tick_interval_secs` increments. At each tick: +/// +/// 1. Compute the expected share count for the interval as +/// `λ = (true_hashrate / estimated_hashrate) * shares_per_minute * +/// (tick_interval_secs / 60)`. The schedule is evaluated at the interval +/// midpoint, which is exact for schedules that change at tick boundaries +/// (the default scenarios in [`crate::baseline`]) and a small +/// approximation otherwise. +/// 2. Sample `n_shares ~ Poisson(λ)` via [`sample_poisson`]. +/// 3. Bulk-add the count to the vardiff state via +/// [`Vardiff::add_shares`]. +/// 4. Advance the clock and call `try_vardiff`. If it returns +/// `Some(new_hashrate)`, record a [`FireEvent`], update the estimate, and +/// derive a new target via [`channels_sv2::target::hash_rate_to_target`]. +/// +/// Given a fixed `(config, schedule, seed)` triple, the trial is fully +/// reproducible — the same algorithm produces an identical fire timeline +/// every time. +pub fn run_trial( + mut vardiff: V, + clock: Arc, + config: TrialConfig, + schedule: &HashrateSchedule, + seed: u64, +) -> Trial { + clock.set(0); + + let mut rng = XorShift64::new(seed); + let mut current_hashrate = config.initial_hashrate; + let mut current_target = hashrate_to_target_safe(current_hashrate, config.shares_per_minute); + let mut fires: Vec = Vec::new(); + + let mut last_tick_at: u64 = 0; + let mut tick_at: u64 = config.tick_interval_secs; + + while tick_at <= config.duration_secs { + // Sample share count for this tick interval using the true hashrate + // at the interval midpoint. For schedules that change at a tick + // boundary (the default scenarios), the midpoint correctly identifies + // the segment that covers the entire interval. + let interval_midpoint = (last_tick_at + tick_at) / 2; + let true_h = schedule.at(interval_midpoint) as f64; + let est_h = current_hashrate as f64; + let interval_secs = (tick_at - last_tick_at) as f64; + + let lambda = if est_h > 0.0 { + (true_h / est_h) * (config.shares_per_minute as f64) * (interval_secs / 60.0) + } else { + 0.0 + }; + let n_shares = sample_poisson(&mut rng, lambda); + vardiff.add_shares(n_shares); + + // Advance the clock to the tick boundary and invoke the algorithm. + clock.set(tick_at); + if let Ok(Some(new_h)) = + vardiff.try_vardiff(current_hashrate, ¤t_target, config.shares_per_minute) + { + fires.push(FireEvent { + at_secs: tick_at, + old_hashrate: current_hashrate, + new_hashrate: new_h, + }); + current_hashrate = new_h; + current_target = hashrate_to_target_safe(new_h, config.shares_per_minute); + } + + last_tick_at = tick_at; + tick_at = tick_at.saturating_add(config.tick_interval_secs); + } + + Trial { + true_hashrate_at_end: schedule.at(config.duration_secs), + config, + seed, + fires, + final_hashrate: current_hashrate, + } +} + +/// Wraps `hash_rate_to_target` with floor values on inputs so degenerate edge +/// cases (zero or negative hashrate or share rate, which can arise from +/// extreme algorithm output during testing) don't propagate errors. +/// +/// Hashrate is floored at 1.0 H/s — the smallest physically meaningful value +/// and well below any miner's true rate, so this clamp can't influence +/// realistic trials. Share rate is floored at 0.001 spm to prevent division +/// by zero in the underlying conversion. +fn hashrate_to_target_safe(hashrate: f32, shares_per_minute: f32) -> Target { + hash_rate_to_target( + hashrate.max(1.0) as f64, + shares_per_minute.max(0.001) as f64, + ) + .expect("hash_rate_to_target with positive inputs should not fail") +} + +#[cfg(test)] +mod tests { + use super::*; + use channels_sv2::VardiffState; + + fn make_vardiff() -> (VardiffState, Arc) { + let clock = Arc::new(MockClock::new(0)); + let vardiff = + VardiffState::new_with_clock(1.0, clock.clone()).expect("vardiff state should build"); + (vardiff, clock) + } + + #[test] + fn smoke_test_stable_load_produces_a_trial() { + let (vardiff, clock) = make_vardiff(); + let config = TrialConfig::default(); + let schedule = HashrateSchedule::stable(1.0e15); + let trial = run_trial(vardiff, clock, config, &schedule, 0xCAFE); + assert_eq!(trial.true_hashrate_at_end, 1.0e15); + // Phase 1 ramp-up from default 1e10 → 1e15 should produce multiple fires. + assert!( + !trial.fires.is_empty(), + "Expected at least one fire during Phase 1 ramp, got {}", + trial.fires.len() + ); + } + + #[test] + fn trial_is_deterministic_for_same_seed() { + let (v1, c1) = make_vardiff(); + let (v2, c2) = make_vardiff(); + let config = TrialConfig::default(); + let schedule = HashrateSchedule::stable(1.0e15); + let t1 = run_trial(v1, c1, config.clone(), &schedule, 12345); + let t2 = run_trial(v2, c2, config, &schedule, 12345); + assert_eq!(t1.fires.len(), t2.fires.len()); + for (a, b) in t1.fires.iter().zip(t2.fires.iter()) { + assert_eq!(a.at_secs, b.at_secs); + // Float bit-equality holds for deterministic execution paths in + // the same toolchain. + assert_eq!(a.new_hashrate.to_bits(), b.new_hashrate.to_bits()); + } + assert_eq!(t1.final_hashrate.to_bits(), t2.final_hashrate.to_bits()); + } + + #[test] + fn different_seeds_produce_different_trials() { + let (v1, c1) = make_vardiff(); + let (v2, c2) = make_vardiff(); + let config = TrialConfig::default(); + let schedule = HashrateSchedule::stable(1.0e15); + let t1 = run_trial(v1, c1, config.clone(), &schedule, 1); + let t2 = run_trial(v2, c2, config, &schedule, 2); + let same = t1.fires.len() == t2.fires.len() + && t1 + .fires + .iter() + .zip(t2.fires.iter()) + .all(|(a, b)| a.at_secs == b.at_secs); + assert!( + !same, + "Two seeds produced identical fire timelines; RNG broken?" + ); + } + + #[test] + fn step_change_in_schedule_is_observable() { + let (vardiff, clock) = make_vardiff(); + let config = TrialConfig { + duration_secs: 30 * 60, + initial_hashrate: 1.0e15, // start aligned with the pre-change rate + shares_per_minute: 12.0, + tick_interval_secs: 60, + }; + // Halve hashrate at 15 min — algorithm should respond. + let schedule = HashrateSchedule::step(1.0e15, 5.0e14, 15 * 60); + let trial = run_trial(vardiff, clock, config, &schedule, 9001); + let post_step_fires = trial.fires.iter().filter(|f| f.at_secs > 15 * 60).count(); + assert!( + post_step_fires >= 1, + "Expected at least one fire after 50% load drop at 15 min; got {} post-step fires (total {})", + post_step_fires, + trial.fires.len() + ); + } + + /// Regression test for the share-rate-cap bug fixed in Phase 4.5. At high + /// share rates and a cold start, the previous inter-arrival simulation + /// produced ~1 share/sec regardless of configured rate, leaving the + /// algorithm permanently stuck at the initial estimate. With per-tick + /// Poisson sampling the algorithm should converge correctly. + #[test] + fn high_share_rate_cold_start_converges() { + let (vardiff, clock) = make_vardiff(); + let config = TrialConfig { + duration_secs: 30 * 60, + initial_hashrate: 1.0e10, // 10 GH/s + shares_per_minute: 120.0, // high rate where the old simulation broke + tick_interval_secs: 60, + }; + let schedule = HashrateSchedule::stable(1.0e15); // 1 PH/s + let trial = run_trial(vardiff, clock, config, &schedule, 0xC0FFEE); + + // The algorithm should fire multiple times during Phase 1 ramp. + assert!( + trial.fires.len() >= 5, + "Expected ≥ 5 fires during 5-order-of-magnitude ramp; got {}", + trial.fires.len() + ); + + // The final estimate should be within an order of magnitude of truth. + // Tight accuracy is a separate concern characterized by the metric + // layer; here we just verify the algorithm got close. + let ratio = trial.final_hashrate as f64 / trial.true_hashrate_at_end as f64; + assert!( + ratio > 0.1 && ratio < 10.0, + "Final estimate {} is more than 10× off from truth {}; ratio = {}", + trial.final_hashrate, + trial.true_hashrate_at_end, + ratio, + ); + } +} diff --git a/sv2/channels-sv2/sim/vardiff_baseline.md b/sv2/channels-sv2/sim/vardiff_baseline.md new file mode 100644 index 0000000000..93f8a8ad81 --- /dev/null +++ b/sv2/channels-sv2/sim/vardiff_baseline.md @@ -0,0 +1,61 @@ +# Vardiff baseline characterization — `VardiffState` + +*Generated by `cargo run --release --bin generate-baseline` from the vardiff_sim crate. 1000 trials per cell, base seed `0xdeadbeefcafef00d`.* + +## Convergence time (cold start: 10 GH/s → 1 PH/s) + +| share/min | rate | p10 | p50 | p90 | p99 | +| --- | --- | --- | --- | --- | --- | +| 6 | 83.3% | 10m | 12m | 21m | 25m | +| 12 | 95.4% | 10m | 10m | 20m | 25m | +| 30 | 99.5% | 10m | 10m | 15m | 25m | +| 60 | 100.0% | 10m | 10m | 10m | 20m | +| 120 | 100.0% | 10m | 10m | 10m | 15m | + +## Settled accuracy (stable load, post-convergence) + +`|final_hashrate / true_hashrate - 1|` at trial end. Smaller is better. + +| share/min | p10 | p50 | p90 | p99 | +| --- | --- | --- | --- | --- | +| 6 | 0.0% | 4.9% | 23.6% | 70.3% | +| 12 | 0.0% | 0.0% | 12.3% | 26.9% | +| 30 | 0.0% | 0.0% | 0.8% | 15.6% | +| 60 | 0.0% | 0.0% | 0.0% | 3.1% | +| 120 | 0.0% | 0.0% | 0.0% | 0.0% | + +## Steady-state jitter (fires per minute) + +Post-convergence rate of vardiff fires. Smaller is better — ideal is zero under stable load. + +| share/min | p50 | p90 | p99 | mean | +| --- | --- | --- | --- | --- | +| 6 | 0.000 | 0.200 | 0.385 | 0.059 | +| 12 | 0.000 | 0.077 | 0.217 | 0.019 | +| 30 | 0.000 | 0.000 | 0.067 | 0.002 | +| 60 | 0.000 | 0.000 | 0.000 | 0.000 | +| 120 | 0.000 | 0.000 | 0.000 | 0.000 | + +## Reaction time to a 50% drop (step at 15 min) + +| share/min | reacted | p10 | p50 | p90 | p99 | +| --- | --- | --- | --- | --- | --- | +| 6 | 69.7% | 1m | 3m | 5m | 5m | +| 12 | 54.8% | 1m | 3m | 5m | 5m | +| 30 | 32.6% | 2m | 4m | 5m | 5m | +| 60 | 16.3% | 3m | 5m | 5m | 5m | +| 120 | 8.6% | 4m | 5m | 5m | 5m | + +## Reaction sensitivity (P[fire within 5 min of step change]) + +| Δ% | 6 | 12 | 30 | 60 | 120 | +| --- | --- | --- | --- | --- | --- | +| -50% | 0.70 | 0.55 | 0.33 | 0.16 | 0.09 | +| -25% | 0.44 | 0.23 | 0.08 | 0.00 | 0.00 | +| -10% | 0.39 | 0.15 | 0.02 | 0.00 | 0.00 | +| -5% | 0.40 | 0.15 | 0.02 | 0.00 | 0.00 | +| +5% | 0.39 | 0.13 | 0.02 | 0.00 | 0.00 | +| +10% | 0.42 | 0.17 | 0.03 | 0.00 | 0.00 | +| +25% | 0.48 | 0.23 | 0.07 | 0.01 | 0.00 | +| +50% | 0.64 | 0.47 | 0.32 | 0.22 | 0.29 | + diff --git a/sv2/channels-sv2/sim/vardiff_baseline.toml b/sv2/channels-sv2/sim/vardiff_baseline.toml new file mode 100644 index 0000000000..1e3f578411 --- /dev/null +++ b/sv2/channels-sv2/sim/vardiff_baseline.toml @@ -0,0 +1,1198 @@ +# Vardiff baseline characterization. Regenerate with +# `cargo run --release --bin generate-baseline` from the sim crate. + +[meta] +algorithm = "VardiffState" +trial_count = 1000 +base_seed = 16045690984503111693 +quiet_window_secs = 300 +settle_buffer_secs = 120 +min_settled_window_secs = 600 +react_window_secs = 300 +step_event_at_secs = 900 +trial_duration_secs = 1800 + +[cell.spm_6.cold_start_10gh_to_1ph] +shares_per_minute = 6 +scenario = "cold_start_10gh_to_1ph" +convergence_rate = 0.833 +convergence_p10_secs = 600 +convergence_p50_secs = 720 +convergence_p90_secs = 1260 +convergence_p95_secs = 1380 +convergence_p99_secs = 1500 +settled_accuracy_p10 = 0.017392805835733216 +settled_accuracy_p50 = 0.10204178050287493 +settled_accuracy_p90 = 0.2802274936832103 +settled_accuracy_p95 = 0.41859060512130564 +settled_accuracy_p99 = 0.7137733428958992 +jitter_p50_per_min = 0.05555555555555555 +jitter_p90_per_min = 0.23076923076923078 +jitter_p95_per_min = 0.3 +jitter_p99_per_min = 0.3888888888888889 +jitter_mean_per_min = 0.08327595657079602 + +[cell.spm_6.stable_1ph] +shares_per_minute = 6 +scenario = "stable_1ph" +convergence_rate = 0.929 +convergence_p10_secs = 0 +convergence_p50_secs = 360 +convergence_p90_secs = 1080 +convergence_p95_secs = 1260 +convergence_p99_secs = 1500 +settled_accuracy_p10 = 0 +settled_accuracy_p50 = 0.048780360134354606 +settled_accuracy_p90 = 0.23621233271689368 +settled_accuracy_p95 = 0.34419673742200363 +settled_accuracy_p99 = 0.7033031184617091 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0.2 +jitter_p95_per_min = 0.2727272727272727 +jitter_p99_per_min = 0.38461538461538464 +jitter_mean_per_min = 0.05880223644608064 + +[cell.spm_6.step_minus_50_at_15min] +shares_per_minute = 6 +scenario = "step_minus_50_at_15min" +convergence_rate = 0.653 +convergence_p10_secs = 300 +convergence_p50_secs = 600 +convergence_p90_secs = 1380 +convergence_p95_secs = 1440 +convergence_p99_secs = 1500 +settled_accuracy_p10 = 0.026051460017108785 +settled_accuracy_p50 = 0.13123078127939158 +settled_accuracy_p90 = 0.5024288375919166 +settled_accuracy_p95 = 0.6806722643960428 +settled_accuracy_p99 = 1 +jitter_p50_per_min = 0.17391304347826086 +jitter_p90_per_min = 0.3181818181818182 +jitter_p95_per_min = 0.34782608695652173 +jitter_p99_per_min = 0.43478260869565216 +jitter_mean_per_min = 0.17898510491933192 +reaction_rate = 0.697 +reaction_p10_secs = 60 +reaction_p50_secs = 180 +reaction_p90_secs = 300 +reaction_p99_secs = 300 + +[cell.spm_6.step_minus_25_at_15min] +shares_per_minute = 6 +scenario = "step_minus_25_at_15min" +convergence_rate = 0.849 +convergence_p10_secs = 0 +convergence_p50_secs = 480 +convergence_p90_secs = 1200 +convergence_p95_secs = 1380 +convergence_p99_secs = 1500 +settled_accuracy_p10 = 0.034067143402066735 +settled_accuracy_p50 = 0.15598569453781275 +settled_accuracy_p90 = 0.33333330350717194 +settled_accuracy_p95 = 0.4533119610990499 +settled_accuracy_p99 = 0.8392876939971838 +jitter_p50_per_min = 0.038461538461538464 +jitter_p90_per_min = 0.25 +jitter_p95_per_min = 0.3 +jitter_p99_per_min = 0.375 +jitter_mean_per_min = 0.08176219210346358 +reaction_rate = 0.44 +reaction_p10_secs = 60 +reaction_p50_secs = 180 +reaction_p90_secs = 300 +reaction_p99_secs = 300 + +[cell.spm_6.step_minus_10_at_15min] +shares_per_minute = 6 +scenario = "step_minus_10_at_15min" +convergence_rate = 0.903 +convergence_p10_secs = 0 +convergence_p50_secs = 360 +convergence_p90_secs = 1080 +convergence_p95_secs = 1260 +convergence_p99_secs = 1440 +settled_accuracy_p10 = 0.02818184563544457 +settled_accuracy_p50 = 0.11111110282606607 +settled_accuracy_p90 = 0.2889058235208368 +settled_accuracy_p95 = 0.3717421722170047 +settled_accuracy_p99 = 0.6419767437958788 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0.2222222222222222 +jitter_p95_per_min = 0.2727272727272727 +jitter_p99_per_min = 0.3888888888888889 +jitter_mean_per_min = 0.06113518014082941 +reaction_rate = 0.385 +reaction_p10_secs = 60 +reaction_p50_secs = 180 +reaction_p90_secs = 300 +reaction_p99_secs = 300 + +[cell.spm_6.step_minus_5_at_15min] +shares_per_minute = 6 +scenario = "step_minus_5_at_15min" +convergence_rate = 0.915 +convergence_p10_secs = 0 +convergence_p50_secs = 360 +convergence_p90_secs = 1080 +convergence_p95_secs = 1320 +convergence_p99_secs = 1500 +settled_accuracy_p10 = 0.027683466275994162 +settled_accuracy_p50 = 0.05449430538899014 +settled_accuracy_p90 = 0.2694757872276008 +settled_accuracy_p95 = 0.3628521917366665 +settled_accuracy_p99 = 0.6512093759136961 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0.21739130434782608 +jitter_p95_per_min = 0.26666666666666666 +jitter_p99_per_min = 0.4090909090909091 +jitter_mean_per_min = 0.06477973351917536 +reaction_rate = 0.395 +reaction_p10_secs = 60 +reaction_p50_secs = 120 +reaction_p90_secs = 300 +reaction_p99_secs = 300 + +[cell.spm_6.step_plus_5_at_15min] +shares_per_minute = 6 +scenario = "step_plus_5_at_15min" +convergence_rate = 0.909 +convergence_p10_secs = 0 +convergence_p50_secs = 360 +convergence_p90_secs = 1080 +convergence_p95_secs = 1260 +convergence_p99_secs = 1440 +settled_accuracy_p10 = 0.028956774090709958 +settled_accuracy_p50 = 0.051419454848449675 +settled_accuracy_p90 = 0.2539018694485786 +settled_accuracy_p95 = 0.37404604742181147 +settled_accuracy_p99 = 0.7172208644926206 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0.21739130434782608 +jitter_p95_per_min = 0.2777777777777778 +jitter_p99_per_min = 0.3888888888888889 +jitter_mean_per_min = 0.06619242896848444 +reaction_rate = 0.385 +reaction_p10_secs = 60 +reaction_p50_secs = 180 +reaction_p90_secs = 300 +reaction_p99_secs = 300 + +[cell.spm_6.step_plus_10_at_15min] +shares_per_minute = 6 +scenario = "step_plus_10_at_15min" +convergence_rate = 0.91 +convergence_p10_secs = 0 +convergence_p50_secs = 420 +convergence_p90_secs = 1140 +convergence_p95_secs = 1320 +convergence_p99_secs = 1440 +settled_accuracy_p10 = 0.031233075983036596 +settled_accuracy_p50 = 0.09090908536290365 +settled_accuracy_p90 = 0.2424242479704296 +settled_accuracy_p95 = 0.3644045549349205 +settled_accuracy_p99 = 0.6726012256397107 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0.22727272727272727 +jitter_p95_per_min = 0.2777777777777778 +jitter_p99_per_min = 0.4 +jitter_mean_per_min = 0.06914424267773281 +reaction_rate = 0.421 +reaction_p10_secs = 60 +reaction_p50_secs = 120 +reaction_p90_secs = 300 +reaction_p99_secs = 300 + +[cell.spm_6.step_plus_25_at_15min] +shares_per_minute = 6 +scenario = "step_plus_25_at_15min" +convergence_rate = 0.849 +convergence_p10_secs = 0 +convergence_p50_secs = 480 +convergence_p90_secs = 1260 +convergence_p95_secs = 1380 +convergence_p99_secs = 1500 +settled_accuracy_p10 = 0.028984800755398776 +settled_accuracy_p50 = 0.1315277327104486 +settled_accuracy_p90 = 0.2865853357365107 +settled_accuracy_p95 = 0.43067826336438086 +settled_accuracy_p99 = 0.6686597103946581 +jitter_p50_per_min = 0.045454545454545456 +jitter_p90_per_min = 0.2608695652173913 +jitter_p95_per_min = 0.3 +jitter_p99_per_min = 0.4090909090909091 +jitter_mean_per_min = 0.08913843842938901 +reaction_rate = 0.482 +reaction_p10_secs = 60 +reaction_p50_secs = 180 +reaction_p90_secs = 300 +reaction_p99_secs = 300 + +[cell.spm_6.step_plus_50_at_15min] +shares_per_minute = 6 +scenario = "step_plus_50_at_15min" +convergence_rate = 0.732 +convergence_p10_secs = 240 +convergence_p50_secs = 660 +convergence_p90_secs = 1380 +convergence_p95_secs = 1440 +convergence_p99_secs = 1500 +settled_accuracy_p10 = 0.027620487029601515 +settled_accuracy_p50 = 0.14562784412955376 +settled_accuracy_p90 = 0.3685343182647689 +settled_accuracy_p95 = 0.537869934253894 +settled_accuracy_p99 = 0.9118109004658339 +jitter_p50_per_min = 0.13043478260869565 +jitter_p90_per_min = 0.29411764705882354 +jitter_p95_per_min = 0.34782608695652173 +jitter_p99_per_min = 0.4117647058823529 +jitter_mean_per_min = 0.13822865782269927 +reaction_rate = 0.636 +reaction_p10_secs = 60 +reaction_p50_secs = 120 +reaction_p90_secs = 240 +reaction_p99_secs = 300 + +[cell.spm_12.cold_start_10gh_to_1ph] +shares_per_minute = 12 +scenario = "cold_start_10gh_to_1ph" +convergence_rate = 0.954 +convergence_p10_secs = 600 +convergence_p50_secs = 600 +convergence_p90_secs = 1200 +convergence_p95_secs = 1260 +convergence_p99_secs = 1500 +settled_accuracy_p10 = 0.013368421427028432 +settled_accuracy_p50 = 0.05822579864750144 +settled_accuracy_p90 = 0.15884614628350102 +settled_accuracy_p95 = 0.2024201335721425 +settled_accuracy_p99 = 0.34364926330236956 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0.1111111111111111 +jitter_p95_per_min = 0.16666666666666666 +jitter_p99_per_min = 0.2777777777777778 +jitter_mean_per_min = 0.038339751691429745 + +[cell.spm_12.stable_1ph] +shares_per_minute = 12 +scenario = "stable_1ph" +convergence_rate = 0.985 +convergence_p10_secs = 0 +convergence_p50_secs = 0 +convergence_p90_secs = 780 +convergence_p95_secs = 960 +convergence_p99_secs = 1320 +settled_accuracy_p10 = 0 +settled_accuracy_p50 = 0 +settled_accuracy_p90 = 0.122954781845522 +settled_accuracy_p95 = 0.16822427460518008 +settled_accuracy_p99 = 0.2690354798528787 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0.07692307692307693 +jitter_p95_per_min = 0.13333333333333333 +jitter_p99_per_min = 0.21739130434782608 +jitter_mean_per_min = 0.018598036849075145 + +[cell.spm_12.step_minus_50_at_15min] +shares_per_minute = 12 +scenario = "step_minus_50_at_15min" +convergence_rate = 0.54 +convergence_p10_secs = 300 +convergence_p50_secs = 1020 +convergence_p90_secs = 1500 +convergence_p95_secs = 1500 +convergence_p99_secs = 1500 +settled_accuracy_p10 = 0.023166718351677384 +settled_accuracy_p50 = 0.10805466768663197 +settled_accuracy_p90 = 0.32108216265833245 +settled_accuracy_p95 = 0.515567813809944 +settled_accuracy_p99 = 0.6977928095669861 +jitter_p50_per_min = 0.15789473684210525 +jitter_p90_per_min = 0.22727272727272727 +jitter_p95_per_min = 0.2608695652173913 +jitter_p99_per_min = 0.3333333333333333 +jitter_mean_per_min = 0.1488152731948901 +reaction_rate = 0.548 +reaction_p10_secs = 60 +reaction_p50_secs = 180 +reaction_p90_secs = 300 +reaction_p99_secs = 300 + +[cell.spm_12.step_minus_25_at_15min] +shares_per_minute = 12 +scenario = "step_minus_25_at_15min" +convergence_rate = 0.853 +convergence_p10_secs = 0 +convergence_p50_secs = 0 +convergence_p90_secs = 1140 +convergence_p95_secs = 1380 +convergence_p99_secs = 1500 +settled_accuracy_p10 = 0.04456395389674772 +settled_accuracy_p50 = 0.22282871733218834 +settled_accuracy_p90 = 0.33333330350717194 +settled_accuracy_p95 = 0.33333330350717194 +settled_accuracy_p99 = 0.3973603489159816 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0.13043478260869565 +jitter_p95_per_min = 0.17647058823529413 +jitter_p99_per_min = 0.2222222222222222 +jitter_mean_per_min = 0.03617437696199711 +reaction_rate = 0.234 +reaction_p10_secs = 60 +reaction_p50_secs = 180 +reaction_p90_secs = 300 +reaction_p99_secs = 300 + +[cell.spm_12.step_minus_10_at_15min] +shares_per_minute = 12 +scenario = "step_minus_10_at_15min" +convergence_rate = 0.974 +convergence_p10_secs = 0 +convergence_p50_secs = 0 +convergence_p90_secs = 840 +convergence_p95_secs = 1080 +convergence_p99_secs = 1440 +settled_accuracy_p10 = 0.051957174105373594 +settled_accuracy_p50 = 0.11111110282606607 +settled_accuracy_p90 = 0.12503574479095403 +settled_accuracy_p95 = 0.1763348791984517 +settled_accuracy_p99 = 0.31523561363040686 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0.05555555555555555 +jitter_p95_per_min = 0.1111111111111111 +jitter_p99_per_min = 0.2 +jitter_mean_per_min = 0.014777027569168424 +reaction_rate = 0.151 +reaction_p10_secs = 60 +reaction_p50_secs = 180 +reaction_p90_secs = 300 +reaction_p99_secs = 300 + +[cell.spm_12.step_minus_5_at_15min] +shares_per_minute = 12 +scenario = "step_minus_5_at_15min" +convergence_rate = 0.975 +convergence_p10_secs = 0 +convergence_p50_secs = 0 +convergence_p90_secs = 780 +convergence_p95_secs = 900 +convergence_p99_secs = 1320 +settled_accuracy_p10 = 0.028756219137427874 +settled_accuracy_p50 = 0.05263157522942574 +settled_accuracy_p90 = 0.12941598404589172 +settled_accuracy_p95 = 0.17575677430434067 +settled_accuracy_p99 = 0.28379088510446704 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0.06666666666666667 +jitter_p95_per_min = 0.13043478260869565 +jitter_p99_per_min = 0.22727272727272727 +jitter_mean_per_min = 0.018132735498094105 +reaction_rate = 0.153 +reaction_p10_secs = 60 +reaction_p50_secs = 180 +reaction_p90_secs = 300 +reaction_p99_secs = 300 + +[cell.spm_12.step_plus_5_at_15min] +shares_per_minute = 12 +scenario = "step_plus_5_at_15min" +convergence_rate = 0.981 +convergence_p10_secs = 0 +convergence_p50_secs = 0 +convergence_p90_secs = 780 +convergence_p95_secs = 900 +convergence_p99_secs = 1440 +settled_accuracy_p10 = 0.04379416163062533 +settled_accuracy_p50 = 0.047618983705838724 +settled_accuracy_p90 = 0.11058301757012456 +settled_accuracy_p95 = 0.14926226900328254 +settled_accuracy_p99 = 0.2550928281837821 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0.05555555555555555 +jitter_p95_per_min = 0.1111111111111111 +jitter_p99_per_min = 0.21739130434782608 +jitter_mean_per_min = 0.01545833674498921 +reaction_rate = 0.133 +reaction_p10_secs = 60 +reaction_p50_secs = 120 +reaction_p90_secs = 300 +reaction_p99_secs = 300 + +[cell.spm_12.step_plus_10_at_15min] +shares_per_minute = 12 +scenario = "step_plus_10_at_15min" +convergence_rate = 0.981 +convergence_p10_secs = 0 +convergence_p50_secs = 0 +convergence_p90_secs = 780 +convergence_p95_secs = 1020 +convergence_p99_secs = 1320 +settled_accuracy_p10 = 0.040348839202705156 +settled_accuracy_p50 = 0.09090908536290365 +settled_accuracy_p90 = 0.11135929189653737 +settled_accuracy_p95 = 0.1415247878490492 +settled_accuracy_p99 = 0.2295839427275861 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0.07692307692307693 +jitter_p95_per_min = 0.13636363636363635 +jitter_p99_per_min = 0.2222222222222222 +jitter_mean_per_min = 0.019825426544745464 +reaction_rate = 0.17 +reaction_p10_secs = 60 +reaction_p50_secs = 180 +reaction_p90_secs = 300 +reaction_p99_secs = 300 + +[cell.spm_12.step_plus_25_at_15min] +shares_per_minute = 12 +scenario = "step_plus_25_at_15min" +convergence_rate = 0.861 +convergence_p10_secs = 0 +convergence_p50_secs = 300 +convergence_p90_secs = 1260 +convergence_p95_secs = 1380 +convergence_p99_secs = 1500 +settled_accuracy_p10 = 0.04328650459035355 +settled_accuracy_p50 = 0.1629122926899873 +settled_accuracy_p90 = 0.20000003221225382 +settled_accuracy_p95 = 0.2068980179370714 +settled_accuracy_p99 = 0.42559806881095774 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0.11538461538461539 +jitter_p95_per_min = 0.16666666666666666 +jitter_p99_per_min = 0.23529411764705882 +jitter_mean_per_min = 0.03022414640456836 +reaction_rate = 0.23 +reaction_p10_secs = 60 +reaction_p50_secs = 180 +reaction_p90_secs = 300 +reaction_p99_secs = 300 + +[cell.spm_12.step_plus_50_at_15min] +shares_per_minute = 12 +scenario = "step_plus_50_at_15min" +convergence_rate = 0.566 +convergence_p10_secs = 300 +convergence_p50_secs = 840 +convergence_p90_secs = 1440 +convergence_p95_secs = 1500 +convergence_p99_secs = 1500 +settled_accuracy_p10 = 0.021186894515872634 +settled_accuracy_p50 = 0.10058947530800966 +settled_accuracy_p90 = 0.24273096924615545 +settled_accuracy_p95 = 0.33333334824641403 +settled_accuracy_p99 = 0.5637513621980783 +jitter_p50_per_min = 0.1 +jitter_p90_per_min = 0.2 +jitter_p95_per_min = 0.23529411764705882 +jitter_p99_per_min = 0.2777777777777778 +jitter_mean_per_min = 0.10037627822152538 +reaction_rate = 0.472 +reaction_p10_secs = 60 +reaction_p50_secs = 180 +reaction_p90_secs = 300 +reaction_p99_secs = 300 + +[cell.spm_30.cold_start_10gh_to_1ph] +shares_per_minute = 30 +scenario = "cold_start_10gh_to_1ph" +convergence_rate = 0.995 +convergence_p10_secs = 600 +convergence_p50_secs = 600 +convergence_p90_secs = 900 +convergence_p95_secs = 1200 +convergence_p99_secs = 1500 +settled_accuracy_p10 = 0.009384637881571845 +settled_accuracy_p50 = 0.04641148431320219 +settled_accuracy_p90 = 0.11531839700275692 +settled_accuracy_p95 = 0.12519997602871347 +settled_accuracy_p99 = 0.17809370692659443 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0.05555555555555555 +jitter_p95_per_min = 0.05555555555555555 +jitter_p99_per_min = 0.15384615384615385 +jitter_mean_per_min = 0.00974099298481681 + +[cell.spm_30.stable_1ph] +shares_per_minute = 30 +scenario = "stable_1ph" +convergence_rate = 1 +convergence_p10_secs = 0 +convergence_p50_secs = 0 +convergence_p90_secs = 300 +convergence_p95_secs = 600 +convergence_p99_secs = 900 +settled_accuracy_p10 = 0 +settled_accuracy_p50 = 0 +settled_accuracy_p90 = 0.00793649568647703 +settled_accuracy_p95 = 0.04621660016961093 +settled_accuracy_p99 = 0.1560693827816504 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0 +jitter_p95_per_min = 0 +jitter_p99_per_min = 0.06666666666666667 +jitter_mean_per_min = 0.002363754642453629 + +[cell.spm_30.step_minus_50_at_15min] +shares_per_minute = 30 +scenario = "step_minus_50_at_15min" +convergence_rate = 0.388 +convergence_p10_secs = 540 +convergence_p50_secs = 1380 +convergence_p90_secs = 1500 +convergence_p95_secs = 1500 +convergence_p99_secs = 1500 +settled_accuracy_p10 = 0.013003684746443533 +settled_accuracy_p50 = 0.07262689128719568 +settled_accuracy_p90 = 0.17702452849143757 +settled_accuracy_p95 = 0.22324159842310265 +settled_accuracy_p99 = 0.6806722643960428 +jitter_p50_per_min = 0.13043478260869565 +jitter_p90_per_min = 0.17391304347826086 +jitter_p95_per_min = 0.21052631578947367 +jitter_p99_per_min = 0.23529411764705882 +jitter_mean_per_min = 0.12840427110728322 +reaction_rate = 0.326 +reaction_p10_secs = 120 +reaction_p50_secs = 240 +reaction_p90_secs = 300 +reaction_p99_secs = 300 + +[cell.spm_30.step_minus_25_at_15min] +shares_per_minute = 30 +scenario = "step_minus_25_at_15min" +convergence_rate = 0.847 +convergence_p10_secs = 0 +convergence_p50_secs = 0 +convergence_p90_secs = 600 +convergence_p95_secs = 1140 +convergence_p99_secs = 1500 +settled_accuracy_p10 = 0.11415700843361565 +settled_accuracy_p50 = 0.33333330350717194 +settled_accuracy_p90 = 0.33333330350717194 +settled_accuracy_p95 = 0.33333330350717194 +settled_accuracy_p99 = 0.33333330350717194 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0.05555555555555555 +jitter_p95_per_min = 0.09090909090909091 +jitter_p99_per_min = 0.13043478260869565 +jitter_mean_per_min = 0.01070065599912901 +reaction_rate = 0.082 +reaction_p10_secs = 60 +reaction_p50_secs = 240 +reaction_p90_secs = 300 +reaction_p99_secs = 300 + +[cell.spm_30.step_minus_10_at_15min] +shares_per_minute = 30 +scenario = "step_minus_10_at_15min" +convergence_rate = 1 +convergence_p10_secs = 0 +convergence_p50_secs = 0 +convergence_p90_secs = 0 +convergence_p95_secs = 540 +convergence_p99_secs = 720 +settled_accuracy_p10 = 0.11111110282606607 +settled_accuracy_p50 = 0.11111110282606607 +settled_accuracy_p90 = 0.11111110282606607 +settled_accuracy_p95 = 0.11111110282606607 +settled_accuracy_p99 = 0.1431183774269642 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0 +jitter_p95_per_min = 0 +jitter_p99_per_min = 0.05555555555555555 +jitter_mean_per_min = 0.0023418985794316787 +reaction_rate = 0.017 +reaction_p10_secs = 60 +reaction_p50_secs = 180 +reaction_p90_secs = 300 +reaction_p99_secs = 300 + +[cell.spm_30.step_minus_5_at_15min] +shares_per_minute = 30 +scenario = "step_minus_5_at_15min" +convergence_rate = 1 +convergence_p10_secs = 0 +convergence_p50_secs = 0 +convergence_p90_secs = 300 +convergence_p95_secs = 600 +convergence_p99_secs = 900 +settled_accuracy_p10 = 0.05263157522942574 +settled_accuracy_p50 = 0.05263157522942574 +settled_accuracy_p90 = 0.05263157522942574 +settled_accuracy_p95 = 0.07206029795064361 +settled_accuracy_p99 = 0.21691513547195873 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0 +jitter_p95_per_min = 0 +jitter_p99_per_min = 0.05263157894736842 +jitter_mean_per_min = 0.0022189340312080493 +reaction_rate = 0.02 +reaction_p10_secs = 60 +reaction_p50_secs = 180 +reaction_p90_secs = 300 +reaction_p99_secs = 300 + +[cell.spm_30.step_plus_5_at_15min] +shares_per_minute = 30 +scenario = "step_plus_5_at_15min" +convergence_rate = 1 +convergence_p10_secs = 0 +convergence_p50_secs = 0 +convergence_p90_secs = 300 +convergence_p95_secs = 600 +convergence_p99_secs = 900 +settled_accuracy_p10 = 0.047618983705838724 +settled_accuracy_p50 = 0.047618983705838724 +settled_accuracy_p90 = 0.047618983705838724 +settled_accuracy_p95 = 0.07016743598996256 +settled_accuracy_p99 = 0.12044827702132244 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0 +jitter_p95_per_min = 0 +jitter_p99_per_min = 0.05555555555555555 +jitter_mean_per_min = 0.001672440768225553 +reaction_rate = 0.016 +reaction_p10_secs = 60 +reaction_p50_secs = 120 +reaction_p90_secs = 240 +reaction_p99_secs = 300 + +[cell.spm_30.step_plus_10_at_15min] +shares_per_minute = 30 +scenario = "step_plus_10_at_15min" +convergence_rate = 0.996 +convergence_p10_secs = 0 +convergence_p50_secs = 0 +convergence_p90_secs = 300 +convergence_p95_secs = 600 +convergence_p99_secs = 900 +settled_accuracy_p10 = 0.09090908536290365 +settled_accuracy_p50 = 0.09090908536290365 +settled_accuracy_p90 = 0.09090908536290365 +settled_accuracy_p95 = 0.09090908536290365 +settled_accuracy_p99 = 0.12112924453659102 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0 +jitter_p95_per_min = 0 +jitter_p99_per_min = 0.07142857142857142 +jitter_mean_per_min = 0.0026432504441086485 +reaction_rate = 0.025 +reaction_p10_secs = 60 +reaction_p50_secs = 180 +reaction_p90_secs = 300 +reaction_p99_secs = 300 + +[cell.spm_30.step_plus_25_at_15min] +shares_per_minute = 30 +scenario = "step_plus_25_at_15min" +convergence_rate = 0.85 +convergence_p10_secs = 0 +convergence_p50_secs = 0 +convergence_p90_secs = 900 +convergence_p95_secs = 1380 +convergence_p99_secs = 1500 +settled_accuracy_p10 = 0.069767446854613 +settled_accuracy_p50 = 0.20000003221225382 +settled_accuracy_p90 = 0.20000003221225382 +settled_accuracy_p95 = 0.20000003221225382 +settled_accuracy_p99 = 0.20000003221225382 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0 +jitter_p95_per_min = 0.08695652173913043 +jitter_p99_per_min = 0.15384615384615385 +jitter_mean_per_min = 0.009017390664853306 +reaction_rate = 0.073 +reaction_p10_secs = 60 +reaction_p50_secs = 180 +reaction_p90_secs = 300 +reaction_p99_secs = 300 + +[cell.spm_30.step_plus_50_at_15min] +shares_per_minute = 30 +scenario = "step_plus_50_at_15min" +convergence_rate = 0.391 +convergence_p10_secs = 540 +convergence_p50_secs = 1380 +convergence_p90_secs = 1500 +convergence_p95_secs = 1500 +convergence_p99_secs = 1500 +settled_accuracy_p10 = 0.014095903576553548 +settled_accuracy_p50 = 0.05956305329580136 +settled_accuracy_p90 = 0.21568627626428405 +settled_accuracy_p95 = 0.22928709538612246 +settled_accuracy_p99 = 0.3021640103039843 +jitter_p50_per_min = 0.1111111111111111 +jitter_p90_per_min = 0.16666666666666666 +jitter_p95_per_min = 0.18181818181818182 +jitter_p99_per_min = 0.2222222222222222 +jitter_mean_per_min = 0.09614260449236312 +reaction_rate = 0.315 +reaction_p10_secs = 120 +reaction_p50_secs = 240 +reaction_p90_secs = 300 +reaction_p99_secs = 300 + +[cell.spm_60.cold_start_10gh_to_1ph] +shares_per_minute = 60 +scenario = "cold_start_10gh_to_1ph" +convergence_rate = 1 +convergence_p10_secs = 600 +convergence_p50_secs = 600 +convergence_p90_secs = 600 +convergence_p95_secs = 900 +convergence_p99_secs = 1200 +settled_accuracy_p10 = 0.015849973032302622 +settled_accuracy_p50 = 0.035947333231283185 +settled_accuracy_p90 = 0.09349996285524331 +settled_accuracy_p95 = 0.10404692627641565 +settled_accuracy_p99 = 0.15782347429170107 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0 +jitter_p95_per_min = 0 +jitter_p99_per_min = 0.07692307692307693 +jitter_mean_per_min = 0.0027466348316550746 + +[cell.spm_60.stable_1ph] +shares_per_minute = 60 +scenario = "stable_1ph" +convergence_rate = 1 +convergence_p10_secs = 0 +convergence_p50_secs = 0 +convergence_p90_secs = 0 +convergence_p95_secs = 0 +convergence_p99_secs = 600 +settled_accuracy_p10 = 0 +settled_accuracy_p50 = 0 +settled_accuracy_p90 = 0 +settled_accuracy_p95 = 0 +settled_accuracy_p99 = 0.031007718123440164 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0 +jitter_p95_per_min = 0 +jitter_p99_per_min = 0 +jitter_mean_per_min = 0.00022395628825880632 + +[cell.spm_60.step_minus_50_at_15min] +shares_per_minute = 60 +scenario = "step_minus_50_at_15min" +convergence_rate = 0.327 +convergence_p10_secs = 1380 +convergence_p50_secs = 1500 +convergence_p90_secs = 1500 +convergence_p95_secs = 1500 +convergence_p99_secs = 1500 +settled_accuracy_p10 = 0.012453190727890195 +settled_accuracy_p50 = 0.05274240040759248 +settled_accuracy_p90 = 0.1299434990333974 +settled_accuracy_p95 = 0.16090095261704773 +settled_accuracy_p99 = 0.24294039907360243 +jitter_p50_per_min = 0.11764705882352941 +jitter_p90_per_min = 0.16666666666666666 +jitter_p95_per_min = 0.17391304347826086 +jitter_p99_per_min = 0.17391304347826086 +jitter_mean_per_min = 0.12388013939366749 +reaction_rate = 0.163 +reaction_p10_secs = 180 +reaction_p50_secs = 300 +reaction_p90_secs = 300 +reaction_p99_secs = 300 + +[cell.spm_60.step_minus_25_at_15min] +shares_per_minute = 60 +scenario = "step_minus_25_at_15min" +convergence_rate = 0.907 +convergence_p10_secs = 0 +convergence_p50_secs = 0 +convergence_p90_secs = 0 +convergence_p95_secs = 0 +convergence_p99_secs = 1440 +settled_accuracy_p10 = 0.12994351849623165 +settled_accuracy_p50 = 0.33333330350717194 +settled_accuracy_p90 = 0.33333330350717194 +settled_accuracy_p95 = 0.33333330350717194 +settled_accuracy_p99 = 0.33333330350717194 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0 +jitter_p95_per_min = 0 +jitter_p99_per_min = 0 +jitter_mean_per_min = 0.0008094190669098905 +reaction_rate = 0.004 +reaction_p10_secs = 180 +reaction_p50_secs = 240 +reaction_p90_secs = 300 +reaction_p99_secs = 300 + +[cell.spm_60.step_minus_10_at_15min] +shares_per_minute = 60 +scenario = "step_minus_10_at_15min" +convergence_rate = 1 +convergence_p10_secs = 0 +convergence_p50_secs = 0 +convergence_p90_secs = 0 +convergence_p95_secs = 0 +convergence_p99_secs = 600 +settled_accuracy_p10 = 0.11111110282606607 +settled_accuracy_p50 = 0.11111110282606607 +settled_accuracy_p90 = 0.11111110282606607 +settled_accuracy_p95 = 0.11111110282606607 +settled_accuracy_p99 = 0.11111110282606607 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0 +jitter_p95_per_min = 0 +jitter_p99_per_min = 0.043478260869565216 +jitter_mean_per_min = 0.0006881462513295719 +reaction_rate = 0.003 +reaction_p10_secs = 60 +reaction_p50_secs = 180 +reaction_p90_secs = 240 +reaction_p99_secs = 240 + +[cell.spm_60.step_minus_5_at_15min] +shares_per_minute = 60 +scenario = "step_minus_5_at_15min" +convergence_rate = 1 +convergence_p10_secs = 0 +convergence_p50_secs = 0 +convergence_p90_secs = 0 +convergence_p95_secs = 0 +convergence_p99_secs = 600 +settled_accuracy_p10 = 0.05263157522942574 +settled_accuracy_p50 = 0.05263157522942574 +settled_accuracy_p90 = 0.05263157522942574 +settled_accuracy_p95 = 0.05263157522942574 +settled_accuracy_p99 = 0.06197701443681214 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0 +jitter_p95_per_min = 0 +jitter_p99_per_min = 0 +jitter_mean_per_min = 0.00026284584980237153 +reaction_rate = 0.002 +reaction_p10_secs = 60 +reaction_p50_secs = 60 +reaction_p90_secs = 60 +reaction_p99_secs = 60 + +[cell.spm_60.step_plus_5_at_15min] +shares_per_minute = 60 +scenario = "step_plus_5_at_15min" +convergence_rate = 1 +convergence_p10_secs = 0 +convergence_p50_secs = 0 +convergence_p90_secs = 0 +convergence_p95_secs = 0 +convergence_p99_secs = 300 +settled_accuracy_p10 = 0.047618983705838724 +settled_accuracy_p50 = 0.047618983705838724 +settled_accuracy_p90 = 0.047618983705838724 +settled_accuracy_p95 = 0.047618983705838724 +settled_accuracy_p99 = 0.047618983705838724 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0 +jitter_p95_per_min = 0 +jitter_p99_per_min = 0 +jitter_mean_per_min = 0.00015230179028132994 +reaction_rate = 0.002 +reaction_p10_secs = 120 +reaction_p50_secs = 180 +reaction_p90_secs = 180 +reaction_p99_secs = 180 + +[cell.spm_60.step_plus_10_at_15min] +shares_per_minute = 60 +scenario = "step_plus_10_at_15min" +convergence_rate = 0.999 +convergence_p10_secs = 0 +convergence_p50_secs = 0 +convergence_p90_secs = 0 +convergence_p95_secs = 0 +convergence_p99_secs = 360 +settled_accuracy_p10 = 0.09090908536290365 +settled_accuracy_p50 = 0.09090908536290365 +settled_accuracy_p90 = 0.09090908536290365 +settled_accuracy_p95 = 0.09090908536290365 +settled_accuracy_p99 = 0.09090908536290365 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0 +jitter_p95_per_min = 0 +jitter_p99_per_min = 0 +jitter_mean_per_min = 0.00032297743968224516 +reaction_rate = 0.003 +reaction_p10_secs = 60 +reaction_p50_secs = 120 +reaction_p90_secs = 240 +reaction_p99_secs = 240 + +[cell.spm_60.step_plus_25_at_15min] +shares_per_minute = 60 +scenario = "step_plus_25_at_15min" +convergence_rate = 0.864 +convergence_p10_secs = 0 +convergence_p50_secs = 0 +convergence_p90_secs = 0 +convergence_p95_secs = 300 +convergence_p99_secs = 1440 +settled_accuracy_p10 = 0.069767446854613 +settled_accuracy_p50 = 0.20000003221225382 +settled_accuracy_p90 = 0.20000003221225382 +settled_accuracy_p95 = 0.20000003221225382 +settled_accuracy_p99 = 0.20000003221225382 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0 +jitter_p95_per_min = 0 +jitter_p99_per_min = 0 +jitter_mean_per_min = 0.0005751661278322071 +reaction_rate = 0.005 +reaction_p10_secs = 60 +reaction_p50_secs = 120 +reaction_p90_secs = 300 +reaction_p99_secs = 300 + +[cell.spm_60.step_plus_50_at_15min] +shares_per_minute = 60 +scenario = "step_plus_50_at_15min" +convergence_rate = 0.3 +convergence_p10_secs = 1320 +convergence_p50_secs = 1440 +convergence_p90_secs = 1500 +convergence_p95_secs = 1500 +convergence_p99_secs = 1500 +settled_accuracy_p10 = 0.006745246075227529 +settled_accuracy_p50 = 0.033591744213940844 +settled_accuracy_p90 = 0.09182228391863156 +settled_accuracy_p95 = 0.1399908785632904 +settled_accuracy_p99 = 0.2248061918395443 +jitter_p50_per_min = 0.1111111111111111 +jitter_p90_per_min = 0.13636363636363635 +jitter_p95_per_min = 0.13636363636363635 +jitter_p99_per_min = 0.15 +jitter_mean_per_min = 0.09225356705333823 +reaction_rate = 0.218 +reaction_p10_secs = 180 +reaction_p50_secs = 300 +reaction_p90_secs = 300 +reaction_p99_secs = 300 + +[cell.spm_120.cold_start_10gh_to_1ph] +shares_per_minute = 120 +scenario = "cold_start_10gh_to_1ph" +convergence_rate = 1 +convergence_p10_secs = 600 +convergence_p50_secs = 600 +convergence_p90_secs = 600 +convergence_p95_secs = 600 +convergence_p99_secs = 900 +settled_accuracy_p10 = 0.015849973032302622 +settled_accuracy_p50 = 0.09349996285524331 +settled_accuracy_p90 = 0.09349996285524331 +settled_accuracy_p95 = 0.09349996285524331 +settled_accuracy_p99 = 0.09349996285524331 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0 +jitter_p95_per_min = 0 +jitter_p99_per_min = 0.05555555555555555 +jitter_mean_per_min = 0.00116081101996595 + +[cell.spm_120.stable_1ph] +shares_per_minute = 120 +scenario = "stable_1ph" +convergence_rate = 1 +convergence_p10_secs = 0 +convergence_p50_secs = 0 +convergence_p90_secs = 0 +convergence_p95_secs = 0 +convergence_p99_secs = 0 +settled_accuracy_p10 = 0 +settled_accuracy_p50 = 0 +settled_accuracy_p90 = 0 +settled_accuracy_p95 = 0 +settled_accuracy_p99 = 0 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0 +jitter_p95_per_min = 0 +jitter_p99_per_min = 0 +jitter_mean_per_min = 0 + +[cell.spm_120.step_minus_50_at_15min] +shares_per_minute = 120 +scenario = "step_minus_50_at_15min" +convergence_rate = 0.304 +convergence_p10_secs = 1440 +convergence_p50_secs = 1500 +convergence_p90_secs = 1500 +convergence_p95_secs = 1500 +convergence_p99_secs = 1500 +settled_accuracy_p10 = 0.008878167278375093 +settled_accuracy_p50 = 0.0369799373350842 +settled_accuracy_p90 = 0.10059437650529368 +settled_accuracy_p95 = 0.11507586556510607 +settled_accuracy_p99 = 0.16666667785147748 +jitter_p50_per_min = 0.1111111111111111 +jitter_p90_per_min = 0.1111111111111111 +jitter_p95_per_min = 0.1111111111111111 +jitter_p99_per_min = 0.1111111111111111 +jitter_mean_per_min = 0.1111111111111111 +reaction_rate = 0.086 +reaction_p10_secs = 240 +reaction_p50_secs = 300 +reaction_p90_secs = 300 +reaction_p99_secs = 300 + +[cell.spm_120.step_minus_25_at_15min] +shares_per_minute = 120 +scenario = "step_minus_25_at_15min" +convergence_rate = 0.952 +convergence_p10_secs = 0 +convergence_p50_secs = 0 +convergence_p90_secs = 0 +convergence_p95_secs = 0 +convergence_p99_secs = 0 +settled_accuracy_p10 = 0.33333330350717194 +settled_accuracy_p50 = 0.33333330350717194 +settled_accuracy_p90 = 0.33333330350717194 +settled_accuracy_p95 = 0.33333330350717194 +settled_accuracy_p99 = 0.33333330350717194 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0 +jitter_p95_per_min = 0 +jitter_p99_per_min = 0 +jitter_mean_per_min = 0.00015715722580350204 +reaction_rate = 0.002 +reaction_p10_secs = 60 +reaction_p50_secs = 300 +reaction_p90_secs = 300 +reaction_p99_secs = 300 + +[cell.spm_120.step_minus_10_at_15min] +shares_per_minute = 120 +scenario = "step_minus_10_at_15min" +convergence_rate = 1 +convergence_p10_secs = 0 +convergence_p50_secs = 0 +convergence_p90_secs = 0 +convergence_p95_secs = 0 +convergence_p99_secs = 0 +settled_accuracy_p10 = 0.11111110282606607 +settled_accuracy_p50 = 0.11111110282606607 +settled_accuracy_p90 = 0.11111110282606607 +settled_accuracy_p95 = 0.11111110282606607 +settled_accuracy_p99 = 0.11111110282606607 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0 +jitter_p95_per_min = 0 +jitter_p99_per_min = 0 +jitter_mean_per_min = 0 +reaction_rate = 0 + +[cell.spm_120.step_minus_5_at_15min] +shares_per_minute = 120 +scenario = "step_minus_5_at_15min" +convergence_rate = 1 +convergence_p10_secs = 0 +convergence_p50_secs = 0 +convergence_p90_secs = 0 +convergence_p95_secs = 0 +convergence_p99_secs = 0 +settled_accuracy_p10 = 0.05263157522942574 +settled_accuracy_p50 = 0.05263157522942574 +settled_accuracy_p90 = 0.05263157522942574 +settled_accuracy_p95 = 0.05263157522942574 +settled_accuracy_p99 = 0.05263157522942574 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0 +jitter_p95_per_min = 0 +jitter_p99_per_min = 0 +jitter_mean_per_min = 0 +reaction_rate = 0 + +[cell.spm_120.step_plus_5_at_15min] +shares_per_minute = 120 +scenario = "step_plus_5_at_15min" +convergence_rate = 1 +convergence_p10_secs = 0 +convergence_p50_secs = 0 +convergence_p90_secs = 0 +convergence_p95_secs = 0 +convergence_p99_secs = 0 +settled_accuracy_p10 = 0.047618983705838724 +settled_accuracy_p50 = 0.047618983705838724 +settled_accuracy_p90 = 0.047618983705838724 +settled_accuracy_p95 = 0.047618983705838724 +settled_accuracy_p99 = 0.047618983705838724 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0 +jitter_p95_per_min = 0 +jitter_p99_per_min = 0 +jitter_mean_per_min = 0 +reaction_rate = 0 + +[cell.spm_120.step_plus_10_at_15min] +shares_per_minute = 120 +scenario = "step_plus_10_at_15min" +convergence_rate = 1 +convergence_p10_secs = 0 +convergence_p50_secs = 0 +convergence_p90_secs = 0 +convergence_p95_secs = 0 +convergence_p99_secs = 0 +settled_accuracy_p10 = 0.09090908536290365 +settled_accuracy_p50 = 0.09090908536290365 +settled_accuracy_p90 = 0.09090908536290365 +settled_accuracy_p95 = 0.09090908536290365 +settled_accuracy_p99 = 0.09090908536290365 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0 +jitter_p95_per_min = 0 +jitter_p99_per_min = 0 +jitter_mean_per_min = 0 +reaction_rate = 0 + +[cell.spm_120.step_plus_25_at_15min] +shares_per_minute = 120 +scenario = "step_plus_25_at_15min" +convergence_rate = 0.762 +convergence_p10_secs = 0 +convergence_p50_secs = 0 +convergence_p90_secs = 0 +convergence_p95_secs = 0 +convergence_p99_secs = 1500 +settled_accuracy_p10 = 0.069767446854613 +settled_accuracy_p50 = 0.20000003221225382 +settled_accuracy_p90 = 0.20000003221225382 +settled_accuracy_p95 = 0.20000003221225382 +settled_accuracy_p99 = 0.20000003221225382 +jitter_p50_per_min = 0 +jitter_p90_per_min = 0 +jitter_p95_per_min = 0 +jitter_p99_per_min = 0 +jitter_mean_per_min = 0.0001511715797430083 +reaction_rate = 0.001 +reaction_p10_secs = 60 +reaction_p50_secs = 60 +reaction_p90_secs = 60 +reaction_p99_secs = 60 + +[cell.spm_120.step_plus_50_at_15min] +shares_per_minute = 120 +scenario = "step_plus_50_at_15min" +convergence_rate = 0.47 +convergence_p10_secs = 1380 +convergence_p50_secs = 1500 +convergence_p90_secs = 1500 +convergence_p95_secs = 1500 +convergence_p99_secs = 1500 +settled_accuracy_p10 = 0.006161756877830826 +settled_accuracy_p50 = 0.019991819876947314 +settled_accuracy_p90 = 0.07665809671568335 +settled_accuracy_p95 = 0.07665809671568335 +settled_accuracy_p99 = 0.1399908785632904 +jitter_p50_per_min = 0.13043478260869565 +jitter_p90_per_min = 0.13043478260869565 +jitter_p95_per_min = 0.13043478260869565 +jitter_p99_per_min = 0.13043478260869565 +jitter_mean_per_min = 0.13043478260869565 +reaction_rate = 0.295 +reaction_p10_secs = 240 +reaction_p50_secs = 300 +reaction_p90_secs = 300 +reaction_p99_secs = 300 + diff --git a/sv2/channels-sv2/src/vardiff/classic.rs b/sv2/channels-sv2/src/vardiff/classic.rs index 16d3f080d7..d1dc705bc1 100644 --- a/sv2/channels-sv2/src/vardiff/classic.rs +++ b/sv2/channels-sv2/src/vardiff/classic.rs @@ -1,5 +1,7 @@ use crate::target::hash_rate_from_target; +use crate::vardiff::clock::{Clock, SystemClock}; use bitcoin::Target; +use std::sync::Arc; use tracing::debug; /// Default minimum hashrate (H/s) if not specified. @@ -10,6 +12,11 @@ use super::{error::VardiffError, Vardiff}; /// Represents the dynamic state for a variable difficulty (Vardiff) connection. /// /// Tracks performance and adjusts the mining target to achieve a desired share rate. +/// +/// The state holds an `Arc` for reading "current time." In production +/// this is a [`SystemClock`] and behavior is identical to reading +/// `SystemTime::now()` directly. For simulation and high-throughput testing the +/// clock can be replaced with a mock via [`VardiffState::new_with_clock`]. #[derive(Debug)] pub struct VardiffState { /// Count of shares received since the last difficulty adjustment. @@ -18,30 +25,65 @@ pub struct VardiffState { pub timestamp_of_last_update: u64, /// The lowest hashrate (H/s) the system will allow; values below this are clamped. pub min_allowed_hashrate: f32, + /// Source of "current time" for elapsed-time computations. + /// Defaults to [`SystemClock`]; replaceable via [`Self::new_with_clock`]. + /// + /// Public so downstream consumers can continue to construct `VardiffState` + /// via struct literal (e.g., custom impl Clock + Arc::new). The + /// constructors are still the recommended path. + pub clock: Arc, } +// `Arc` does not auto-implement `UnwindSafe` / `RefUnwindSafe` +// because trait objects don't propagate auto-traits unless the bounds are +// included in the trait-object type. We assert them explicitly here: +// `VardiffState`'s scalar fields are trivially unwind-safe, and the contract +// of the `Clock` trait (a single `&self -> u64` read) does not introduce any +// interior mutability that could leave the type inconsistent after a panic. +// +// This preserves the auto-trait impls that `VardiffState` had prior to the +// `Clock` injection refactor — required for semver compatibility. +impl std::panic::UnwindSafe for VardiffState {} +impl std::panic::RefUnwindSafe for VardiffState {} + impl VardiffState { - /// Creates a new `VardiffState` with the default minimum hashrate. - /// - /// # Arguments - /// * `estimated_hashrate` - The initial hashrate estimate. + /// Creates a new `VardiffState` with the default minimum hashrate and the + /// system clock. pub fn new() -> Result { Self::new_with_min(DEFAULT_MIN_HASHRATE) } - /// Creates a new `VardiffState` with a specific minimum hashrate. + /// Creates a new `VardiffState` with a specific minimum hashrate and the + /// system clock. /// /// # Arguments /// * `min_allowed_hashrate` - The minimum hashrate to enforce. pub fn new_with_min(min_allowed_hashrate: f32) -> Result { - let timestamp_secs = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_secs(); + Self::new_with_clock(min_allowed_hashrate, Arc::new(SystemClock)) + } + + /// Creates a new `VardiffState` with a specific minimum hashrate and a + /// custom clock implementation. + /// + /// Primarily intended for simulation and testing, where a + /// [`MockClock`](super::clock::MockClock) lets the algorithm run against + /// controlled time. In production code prefer [`Self::new`] or + /// [`Self::new_with_min`]. + /// + /// # Arguments + /// * `min_allowed_hashrate` - The minimum hashrate to enforce. + /// * `clock` - The clock implementation to read current time from. + pub fn new_with_clock( + min_allowed_hashrate: f32, + clock: Arc, + ) -> Result { + let timestamp_secs = clock.now_secs(); Ok(VardiffState { shares_since_last_update: 0, timestamp_of_last_update: timestamp_secs, min_allowed_hashrate, + clock, }) } @@ -74,11 +116,17 @@ impl Vardiff for VardiffState { self.shares_since_last_update += 1; } + /// Bulk-adds `n` shares with a single saturating add. Overrides the default + /// trait implementation (which calls increment `n` times) for performance + /// — the simulation framework calls this with `n` values into the millions + /// during cold-start ticks, where the loop overhead would dominate. + fn add_shares(&mut self, n: u32) { + self.shares_since_last_update = self.shares_since_last_update.saturating_add(n); + } + /// Resets the share counter and updates the timestamp to now. fn reset_counter(&mut self) -> Result<(), VardiffError> { - let timestamp_secs = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_secs(); + let timestamp_secs = self.clock.now_secs(); self.set_timestamp_of_last_update(timestamp_secs); self.set_shares_since_last_update(0); Ok(()) @@ -99,12 +147,8 @@ impl Vardiff for VardiffState { target: &Target, shares_per_minute: f32, ) -> Result, VardiffError> { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_err(VardiffError::TimeError)? - .as_secs(); - - let delta_time = now - self.timestamp_of_last_update; + let now = self.clock.now_secs(); + let delta_time = now.saturating_sub(self.timestamp_of_last_update); if delta_time <= 15 { return Ok(None); diff --git a/sv2/channels-sv2/src/vardiff/clock.rs b/sv2/channels-sv2/src/vardiff/clock.rs new file mode 100644 index 0000000000..4a413c6691 --- /dev/null +++ b/sv2/channels-sv2/src/vardiff/clock.rs @@ -0,0 +1,127 @@ +//! Time abstraction for the vardiff algorithm. +//! +//! The vardiff algorithm consults "current time" to compute elapsed-time gates +//! between share-rate evaluations. In production this is `SystemTime::now()`. +//! For simulation and high-throughput testing, a mockable [`Clock`] lets the +//! algorithm run against a controlled time source so that thousands of trials +//! of simulated minutes complete in milliseconds of wall clock. +//! +//! The injection mechanism is intentionally simple: [`VardiffState`] holds an +//! `Arc` that defaults to [`SystemClock`]. Production behavior is +//! identical to the pre-injection code; test code constructs a +//! [`VardiffState`] with [`VardiffState::new_with_clock`] passing a +//! [`MockClock`] and drives time forward explicitly. +//! +//! [`VardiffState`]: super::classic::VardiffState +//! [`VardiffState::new_with_clock`]: super::classic::VardiffState::new_with_clock + +use std::{ + fmt::Debug, + sync::atomic::{AtomicU64, Ordering}, +}; + +/// Source of "current time" for the vardiff algorithm. +/// +/// Returns seconds since the UNIX epoch in production, or any +/// monotonically-advancing reference point in test contexts. +/// +/// Implementations must be `Send + Sync` so they can be held by a +/// [`VardiffState`] stored in shared per-channel state across async tasks, +/// and `Debug` so [`VardiffState`] continues to derive `Debug`. +/// +/// [`VardiffState`]: super::classic::VardiffState +pub trait Clock: Debug + Send + Sync { + /// Returns the current time, in seconds. + fn now_secs(&self) -> u64; +} + +/// Production clock — reads from `std::time::SystemTime::now()`. +#[derive(Debug, Default, Clone, Copy)] +pub struct SystemClock; + +impl Clock for SystemClock { + fn now_secs(&self) -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after UNIX_EPOCH") + .as_secs() + } +} + +/// Mock clock for tests and simulation. Time advances only when explicitly +/// requested via [`MockClock::advance`] or [`MockClock::set`]. +/// +/// Internally backed by an [`AtomicU64`] so the clock is `Send + Sync` and +/// can be shared between the algorithm (which reads time) and the test +/// driver (which advances time) without locking. The typical pattern: +/// +/// ```ignore +/// use std::sync::Arc; +/// use channels_sv2::vardiff::clock::MockClock; +/// +/// let clock = Arc::new(MockClock::new(0)); +/// let vardiff = VardiffState::new_with_clock(1.0, clock.clone()).unwrap(); +/// // ... drive a tick ... +/// clock.advance(60); // simulated time moves forward +/// ``` +#[derive(Debug, Default)] +pub struct MockClock { + now: AtomicU64, +} + +impl MockClock { + /// Constructs a new mock clock initialized to `now_secs`. + pub fn new(now_secs: u64) -> Self { + Self { + now: AtomicU64::new(now_secs), + } + } + + /// Advances simulated time by `secs` seconds. + pub fn advance(&self, secs: u64) { + self.now.fetch_add(secs, Ordering::Relaxed); + } + + /// Sets simulated time to exactly `secs` seconds. + pub fn set(&self, secs: u64) { + self.now.store(secs, Ordering::Relaxed); + } +} + +impl Clock for MockClock { + fn now_secs(&self) -> u64 { + self.now.load(Ordering::Relaxed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + #[test] + fn system_clock_returns_recent_time() { + let now = SystemClock.now_secs(); + // Sanity: after 2026, seconds since epoch should be > 1.7B and < 4B. + assert!(now > 1_700_000_000); + assert!(now < 4_000_000_000); + } + + #[test] + fn mock_clock_reflects_advance() { + let clock = MockClock::new(100); + assert_eq!(clock.now_secs(), 100); + clock.advance(50); + assert_eq!(clock.now_secs(), 150); + clock.set(1000); + assert_eq!(clock.now_secs(), 1000); + } + + #[test] + fn mock_clock_shared_via_arc_observes_external_updates() { + let clock = Arc::new(MockClock::new(0)); + let clock_for_reader: Arc = clock.clone(); + clock.advance(42); + assert_eq!(clock_for_reader.now_secs(), 42); + } +} diff --git a/sv2/channels-sv2/src/vardiff/error.rs b/sv2/channels-sv2/src/vardiff/error.rs index 94d8550ef8..780f79e86e 100644 --- a/sv2/channels-sv2/src/vardiff/error.rs +++ b/sv2/channels-sv2/src/vardiff/error.rs @@ -5,10 +5,22 @@ pub enum VardiffError { HashrateToTargetError(String), /// Failed to convert target to a hashrate. TargetToHashrateError(String), - /// System time error occurred. + /// **Unreachable after the Clock-injection refactor.** + /// + /// The algorithm previously called `SystemTime::now().duration_since(...)` + /// which could fail if the system clock was before the UNIX epoch — this + /// variant carried that error. Time access now goes through + /// [`crate::vardiff::Clock::now_secs`], which is infallible, so no path in + /// the algorithm constructs this variant anymore. + /// + /// Retained for backward compatibility (removing an enum variant would + /// break downstream exhaustive matches). Scheduled for removal at the + /// next major version bump. TimeError(std::time::SystemTimeError), } +/// Conversion preserved alongside [`VardiffError::TimeError`] for the same +/// backward-compatibility reason. No code path in this crate exercises it. impl From for VardiffError { fn from(value: std::time::SystemTimeError) -> Self { VardiffError::TimeError(value) diff --git a/sv2/channels-sv2/src/vardiff/mod.rs b/sv2/channels-sv2/src/vardiff/mod.rs index 0993496b52..0310baeed0 100644 --- a/sv2/channels-sv2/src/vardiff/mod.rs +++ b/sv2/channels-sv2/src/vardiff/mod.rs @@ -3,10 +3,13 @@ use error::VardiffError; use std::fmt::Debug; pub mod classic; +pub mod clock; pub mod error; #[cfg(test)] pub mod test; +pub use clock::{Clock, MockClock, SystemClock}; + /// Trait defining the interface for a Vardiff implementation. pub trait Vardiff: Debug + Send + Sync { /// Gets the timestamp of the last update. @@ -21,6 +24,21 @@ pub trait Vardiff: Debug + Send + Sync { /// Increments the share count. fn increment_shares_since_last_update(&mut self); + /// Adds `n` shares to the counter in a single operation. + /// + /// Default implementation calls [`Self::increment_shares_since_last_update`] + /// `n` times, which is correct but `O(n)`. Implementors may override with + /// a saturating bulk add for `O(1)` performance at large `n`. The + /// simulation framework uses this method to bulk-add the Poisson-sampled + /// share count per tick, which can reach the millions during cold-start + /// scenarios — calling `increment` that many times would dominate + /// simulation runtime. + fn add_shares(&mut self, n: u32) { + for _ in 0..n { + self.increment_shares_since_last_update(); + } + } + /// Resets share count and timestamp for a new cycle. fn reset_counter(&mut self) -> Result<(), VardiffError>; diff --git a/sv2/channels-sv2/src/vardiff/test/classic.rs b/sv2/channels-sv2/src/vardiff/test/classic.rs index 8aa7689c32..b5cbeaf1e9 100644 --- a/sv2/channels-sv2/src/vardiff/test/classic.rs +++ b/sv2/channels-sv2/src/vardiff/test/classic.rs @@ -1,8 +1,10 @@ +use crate::vardiff::clock::MockClock; /// Classic implementation test suite use crate::vardiff::test::{ simulate_shares_and_wait, TEST_MIN_ALLOWED_HASHRATE, TEST_SHARES_PER_MINUTE, }; use crate::{target::hash_rate_to_target, vardiff::VardiffError, VardiffState}; +use std::sync::Arc; use super::{ test_increment_and_reset_shares, test_try_vardiff_low_hashrate_decrease_target, @@ -115,3 +117,68 @@ fn test_try_vardiff_hashrate_clamps_to_minimum() { ); assert_eq!(vardiff.shares_since_last_update(), 0); } + +// Verifies that `VardiffState::new_with_clock` actually wires the provided clock +// into the algorithm — i.e., timestamps come from the injected clock, not from +// `SystemTime::now()`. This is the integration the simulation framework relies on. +#[test] +fn test_vardiff_state_reads_from_injected_clock() { + let clock = Arc::new(MockClock::new(1_700_000_000)); + let mut vardiff = VardiffState::new_with_clock(TEST_MIN_ALLOWED_HASHRATE, clock.clone()) + .expect("construction with mock clock should succeed"); + + // Initial timestamp must match the mock's starting value, not wall clock. + assert_eq!(vardiff.last_update_timestamp(), 1_700_000_000); + + // Advancing the mock advances what the algorithm sees as "now." + // reset_counter reads the clock and stores the new timestamp. + clock.advance(60); + vardiff + .reset_counter() + .expect("reset_counter should succeed"); + assert_eq!(vardiff.last_update_timestamp(), 1_700_000_060); + + // A larger advance is also observable. + clock.advance(3600); + vardiff + .reset_counter() + .expect("reset_counter should succeed"); + assert_eq!(vardiff.last_update_timestamp(), 1_700_003_660); +} + +// Verifies that `try_vardiff`'s `delta_time` computation reads from the injected +// clock. With the mock clock advanced by exactly 16s after reset, delta_time +// crosses the `delta_time <= 15` early-return guard and the algorithm proceeds +// to evaluate. With zero shares the algorithm hits the realized==0 branch and +// returns a reduced hashrate (or clamps to minimum). +#[test] +fn test_try_vardiff_uses_injected_clock_for_delta_time() { + let clock = Arc::new(MockClock::new(0)); + let initial_hashrate: f32 = 1_000_000.0; + let target = hash_rate_to_target(initial_hashrate.into(), TEST_SHARES_PER_MINUTE.into()) + .unwrap() + .into(); + let mut vardiff = VardiffState::new_with_clock(TEST_MIN_ALLOWED_HASHRATE, clock.clone()) + .expect("construction with mock clock should succeed"); + + // Advance below the 15s early-return threshold; algorithm should return None. + clock.advance(10); + let result = vardiff + .try_vardiff(initial_hashrate, &target, TEST_SHARES_PER_MINUTE) + .expect("try_vardiff failed"); + assert!( + result.is_none(), + "try_vardiff should early-return when delta_time <= 15s" + ); + + // Advance past the threshold; algorithm should now proceed and (with + // 0 shares observed) fire a downward adjustment via the realized==0 branch. + clock.advance(60); // total 70s elapsed since last reset + let result = vardiff + .try_vardiff(initial_hashrate, &target, TEST_SHARES_PER_MINUTE) + .expect("try_vardiff failed"); + assert!( + result.is_some(), + "try_vardiff should fire with delta_time > 15s and realized rate == 0" + ); +}