diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6fa6725..f244af8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,64 +1,43 @@ name: "Rust checks" - on: pull_request: - branches: [ main ] - + branches: [main] permissions: contents: read actions: read - +env: + RUSTFLAGS: "-D warnings" # Make all warnings deny. jobs: check: # Target self-hosted runner by label - runs-on: [ nixos ] - + runs-on: [nixos] # SECURITY: Require manual approval for external PRs if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} - steps: - name: checkout-code uses: actions/checkout@v4 - + - name: nix-flake-check + run: nix flake check + - name: yamlfmt + run: nix develop --command yamlfmt -lint . + - name: nix-deadnix + run: nix develop --command deadnix + - name: nix-statix + run: nix develop --command statix check + - name: nix-alejandra + run: nix develop --command alejandra --check . + - name: taplo-fmt + run: nix develop --command taplo fmt --check + - name: cargo-fmt + run: nix develop --command cargo fmt --check - name: cargo-check - env: - RUSTFLAGS: "-D warnings" # Make all warnings deny. run: nix develop --command cargo check - - name: cargo-test - env: - RUSTFLAGS: "-D warnings" # Make all warnings deny. run: nix develop --command cargo test - # TODO: make clippy work # - name: cargo-clippy - # env: - # RUSTFLAGS: "-D warnings" # Make all warnings deny. # run: nix develop --command cargo clippy - - name: cargo-bench run: nix develop --command cargo bench --no-run # Just to make sure it compiles - - - name: cargo-fmt - run: nix develop --command cargo fmt --check - - name: cargo-doc run: nix develop --command cargo doc - - - name: nix-flake-check - run: nix flake check - - - name: nix-deadnix - run: nix develop --command deadnix - - - name: nix-statix - run: nix develop --command statix check - - - name: nix-alejandra - run: nix develop --command alejandra --check . - - # TODO: enable hongdown - # - name: markdown-formatting - # run: nix develop --command hongdown --check . - - diff --git a/Cargo.lock b/Cargo.lock index d819f4e..4bff427 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,15 +43,27 @@ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "ballpark" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "774d962b50f4c48f992d781f993fd00dbc0a64e648a7de7ef7125df37cd71327" [[package]] name = "bitflags" @@ -61,15 +73,15 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" [[package]] name = "bumpalo" -version = "3.20.0" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c81d250916401487680ed13b8b675660281dcfc3ab0121fe44c94bcab9eae2fb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" @@ -91,9 +103,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.56" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "shlex", @@ -105,11 +117,22 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core 0.10.1", +] + [[package]] name = "chrono" -version = "0.4.43" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", @@ -147,18 +170,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.59" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5caf74d17c3aec5495110c34cc3f78644bfa89af6c8993ed4de2790e49b6499" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.5.59" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "370daa45065b80218950227371916a1633217ae42b2715b2287b606dcd618e24" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstyle", "clap_lex", @@ -166,9 +189,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "color_quant" @@ -228,6 +251,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -326,9 +358,9 @@ dependencies = [ [[package]] name = "dlib" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ "libloading", ] @@ -347,9 +379,15 @@ dependencies = [ [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "fdeflate" @@ -382,13 +420,19 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "font-kit" version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c7e611d49285d4c4b2e1727b72cf05353558885cc5252f93707b845dfcaf3d3" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.12.1", "byteorder", "core-foundation", "core-graphics", @@ -445,6 +489,30 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -464,10 +532,24 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + [[package]] name = "getset" version = "0.1.6" @@ -501,6 +583,27 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -525,6 +628,12 @@ dependencies = [ "cc", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "image" version = "0.24.9" @@ -539,6 +648,18 @@ dependencies = [ "png", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + [[package]] name = "itertools" version = "0.13.0" @@ -550,9 +671,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jpeg-decoder" @@ -562,10 +683,12 @@ checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -576,11 +699,17 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" -version = "0.2.182" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libloading" @@ -600,25 +729,24 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.12" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ - "bitflags 2.11.0", "libc", ] [[package]] name = "log" -version = "0.4.29" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "miniz_oxide" @@ -706,9 +834,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "oorandom" @@ -722,6 +850,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "3.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1e1c390732d15f1d48471625cd92d154e66db2c56645e29a9cd26f4699f72dc" +dependencies = [ + "num-traits", + "rand 0.8.6", + "serde", +] + [[package]] name = "page_size" version = "0.6.0" @@ -744,18 +883,24 @@ dependencies = [ [[package]] name = "pathfinder_simd" -version = "0.5.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf9027960355bf3afff9841918474a81a5f972ac6d226d518060bba758b5ad57" +checksum = "4500030c302e4af1d423f36f3b958d1aecb6c04184356ed5a833bf6b60435777" dependencies = [ "rustc_version", ] +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plotters" @@ -817,12 +962,13 @@ dependencies = [ ] [[package]] -name = "ppv-lite86" -version = "0.2.21" +name = "prettyplease" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ - "zerocopy", + "proc-macro2", + "syn", ] [[package]] @@ -858,9 +1004,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.44" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -871,50 +1017,63 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" -version = "0.9.2" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ - "rand_chacha", - "rand_core", + "rand_core 0.6.4", + "serde", ] [[package]] -name = "rand_chacha" -version = "0.9.0" +name = "rand" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ - "ppv-lite86", - "rand_core", + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", ] [[package]] name = "rand_core" -version = "0.9.5" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.3.4", + "serde", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_distr" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" +checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8" dependencies = [ "num-traits", - "rand", + "rand 0.10.1", ] [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -966,9 +1125,18 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.9" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "romu" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +checksum = "7eb6d76270085d48d0c65f0a78d1255905a6bbe3ff4737b7c591b12eecd87b62" +dependencies = [ + "getrandom 0.3.4", +] [[package]] name = "round" @@ -1002,9 +1170,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -1038,9 +1206,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -1051,34 +1219,43 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "sliding_features" -version = "9.0.0" +version = "9.1.0" dependencies = [ + "ballpark", "criterion", "getset", "num", "plotters", - "rand", + "rand 0.10.1", + "romu", "round", "time_series_generator", + "watermill", ] [[package]] name = "syn" -version = "2.0.116" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -1107,12 +1284,12 @@ dependencies = [ [[package]] name = "time_series_generator" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3098b86531bc0f07e6455f381717a3e1f48e457d8d03cb01a284abc40617964b" +checksum = "d0882209b46f9f4a88a984a9445325665877167f509363feed83a0b2e0cc51bf" dependencies = [ "num-traits", - "rand", + "rand 0.10.1", "rand_distr", ] @@ -1138,6 +1315,12 @@ 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 = "walkdir" version = "2.5.0" @@ -1156,18 +1339,27 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -1178,9 +1370,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1188,9 +1380,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -1201,18 +1393,64 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.12.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "watermill" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "674ae43b249a45b79db22c99564f348da46f16ef243c605db082d0cbe088669a" +dependencies = [ + "num", + "ordered-float", + "serde", + "serde_json", +] + [[package]] name = "web-sys" -version = "0.3.85" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -1337,12 +1575,100 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.12.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "yeslogic-fontconfig-sys" -version = "6.0.0" +version = "6.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503a066b4c037c440169d995b869046827dbc71263f6e8f3be6d77d4f3229dbd" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" dependencies = [ "dlib", "once_cell", @@ -1351,18 +1677,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.39" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.39" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 9a3d1cd..475b5e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ cargo-features = ["edition2024"] [package] name = "sliding_features" -version = "9.0.0" +version = "9.1.0" authors = ["MathisWellmann "] edition = "2024" license-file = "LICENSE" @@ -22,10 +22,13 @@ exclude = ["img/"] [dependencies] getset = "0.1" num = "0.4" +watermill = "0.1.2" [dev-dependencies] +ballpark = "1" time_series_generator = "0.4.1" -rand = "0.9" +rand = "0.10" +romu = "0.8" round = "0.1" plotters = "0.3" criterion = "0.8" @@ -70,6 +73,10 @@ harness = false name = "laguerre_rsi" harness = false +[[bench]] +name = "mad_scaler" +harness = false + [[bench]] name = "my_rsi" harness = false diff --git a/benches/alma.rs b/benches/alma.rs index 7469088..16e74eb 100644 --- a/benches/alma.rs +++ b/benches/alma.rs @@ -9,7 +9,7 @@ use criterion::{ criterion_main, }; use rand::{ - Rng, + RngExt, rng, }; use sliding_features::{ diff --git a/benches/center_of_gravity.rs b/benches/center_of_gravity.rs index c39aafc..61c604e 100644 --- a/benches/center_of_gravity.rs +++ b/benches/center_of_gravity.rs @@ -9,7 +9,7 @@ use criterion::{ criterion_main, }; use rand::{ - Rng, + RngExt, rng, }; use sliding_features::{ diff --git a/benches/correlation_trend_indicator.rs b/benches/correlation_trend_indicator.rs index 981949c..15e1572 100644 --- a/benches/correlation_trend_indicator.rs +++ b/benches/correlation_trend_indicator.rs @@ -9,7 +9,7 @@ use criterion::{ criterion_main, }; use rand::{ - Rng, + RngExt, rng, }; use sliding_features::{ diff --git a/benches/cumulative.rs b/benches/cumulative.rs index f97ad42..e77d94a 100644 --- a/benches/cumulative.rs +++ b/benches/cumulative.rs @@ -9,7 +9,7 @@ use criterion::{ criterion_main, }; use rand::{ - Rng, + RngExt, rng, }; use sliding_features::{ diff --git a/benches/cyber_cycle.rs b/benches/cyber_cycle.rs index 9a28a29..073de48 100644 --- a/benches/cyber_cycle.rs +++ b/benches/cyber_cycle.rs @@ -9,7 +9,7 @@ use criterion::{ criterion_main, }; use rand::{ - Rng, + RngExt, rng, }; use sliding_features::{ diff --git a/benches/ehlers_fisher_transform.rs b/benches/ehlers_fisher_transform.rs index 4106ca5..94eeb4c 100644 --- a/benches/ehlers_fisher_transform.rs +++ b/benches/ehlers_fisher_transform.rs @@ -9,7 +9,7 @@ use criterion::{ criterion_main, }; use rand::{ - Rng, + RngExt, SeedableRng, rng, rngs::SmallRng, diff --git a/benches/ema.rs b/benches/ema.rs index 1f16b85..f4d5c8c 100644 --- a/benches/ema.rs +++ b/benches/ema.rs @@ -9,7 +9,7 @@ use criterion::{ criterion_main, }; use rand::{ - Rng, + RngExt, rng, }; use sliding_features::{ diff --git a/benches/hl_normalizer.rs b/benches/hl_normalizer.rs index 53eb746..0527287 100644 --- a/benches/hl_normalizer.rs +++ b/benches/hl_normalizer.rs @@ -9,7 +9,7 @@ use criterion::{ criterion_main, }; use rand::{ - Rng, + RngExt, rng, }; use sliding_features::{ diff --git a/benches/laguerre_filter.rs b/benches/laguerre_filter.rs index 700b885..d8a95ed 100644 --- a/benches/laguerre_filter.rs +++ b/benches/laguerre_filter.rs @@ -6,7 +6,7 @@ use criterion::{ criterion_main, }; use rand::{ - Rng, + RngExt, rng, }; use sliding_features::{ diff --git a/benches/laguerre_rsi.rs b/benches/laguerre_rsi.rs index f46fd7f..e01d873 100644 --- a/benches/laguerre_rsi.rs +++ b/benches/laguerre_rsi.rs @@ -9,7 +9,7 @@ use criterion::{ criterion_main, }; use rand::{ - Rng, + RngExt, rng, }; use sliding_features::{ diff --git a/benches/mad_scaler.rs b/benches/mad_scaler.rs new file mode 100644 index 0000000..ad7502c --- /dev/null +++ b/benches/mad_scaler.rs @@ -0,0 +1,115 @@ +use std::{ + hint::black_box, + num::NonZeroUsize, +}; + +use criterion::{ + BenchmarkId, + Criterion, + Throughput, + criterion_group, + criterion_main, +}; +use rand::{ + RngExt, + SeedableRng, + rngs::SmallRng, +}; +use sliding_features::{ + View, + pure_functions::Echo, + sliding_windows::MadScaler, +}; + +const N: usize = 100_000; +const WINDOW_LENS: &[usize] = &[128, 256, 512, 1024, 2048, 4096, 8192]; + +fn standard_normal(rng: &mut SmallRng) -> f64 { + // Box-Muller transform. Clamp u1 away from zero so ln(u1) is finite. + let u1 = rng.random::().max(f64::MIN_POSITIVE); + let u2 = rng.random::(); + (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() +} + +fn realistic_market_prices_f64() -> Vec { + let mut rng = SmallRng::seed_from_u64(0); + let mut price: f64 = 100.0; + let mut volatility: f64 = 0.012; + + (0..N) + .map(|i| { + // Slowly varying volatility with clustering after large shocks. + volatility = 0.985 * volatility + 0.015 * 0.012; + let shock = standard_normal(&mut rng); + if shock.abs() > 2.5 { + volatility = (volatility * 1.35).min(0.08); + } + + // Occasional gap/jump events, as seen in real market data. + let jump = if rng.random::() < 0.002 { + standard_normal(&mut rng) * 0.06 + } else { + 0.0 + }; + + // Mild trend plus a daily-ish seasonality component. + let drift = 0.00002; + let seasonality = 0.0015 * ((i as f64 / 390.0) * std::f64::consts::TAU).sin(); + let log_return = drift + seasonality + volatility * shock + jump; + + price *= log_return.exp(); + price + }) + .collect() +} + +fn realistic_market_prices_f32() -> Vec { + realistic_market_prices_f64() + .into_iter() + .map(|v| v as f32) + .collect() +} + +fn criterion_benchmark(c: &mut Criterion) { + let mut group = c.benchmark_group("mad_scaler_100k"); + group.throughput(Throughput::Elements(N as u64)); + + for &window_len in WINDOW_LENS { + group.bench_function( + BenchmarkId::new("realistic_market_prices_f64", window_len), + |b| { + let vals = realistic_market_prices_f64(); + b.iter(|| { + let mut view = MadScaler::::new( + Echo::new(), + NonZeroUsize::new(window_len).unwrap(), + ); + for v in vals.iter() { + view.update(*v); + let _ = black_box(view.last()); + } + }) + }, + ); + + group.bench_function( + BenchmarkId::new("realistic_market_prices_f32", window_len), + |b| { + let vals = realistic_market_prices_f32(); + b.iter(|| { + let mut view = MadScaler::::new( + Echo::new(), + NonZeroUsize::new(window_len).unwrap(), + ); + for v in vals.iter() { + view.update(*v); + let _ = black_box(view.last()); + } + }) + }, + ); + } +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); diff --git a/benches/my_rsi.rs b/benches/my_rsi.rs index 66c8c09..81fed2f 100644 --- a/benches/my_rsi.rs +++ b/benches/my_rsi.rs @@ -9,7 +9,7 @@ use criterion::{ criterion_main, }; use rand::{ - Rng, + RngExt, rng, }; use sliding_features::{ diff --git a/benches/noise_elimination_technology.rs b/benches/noise_elimination_technology.rs index 896f1e0..5af7f46 100644 --- a/benches/noise_elimination_technology.rs +++ b/benches/noise_elimination_technology.rs @@ -9,7 +9,7 @@ use criterion::{ criterion_main, }; use rand::{ - Rng, + RngExt, rng, }; use sliding_features::{ diff --git a/benches/polarized_fractal_efficiency.rs b/benches/polarized_fractal_efficiency.rs index abc8831..53278b2 100644 --- a/benches/polarized_fractal_efficiency.rs +++ b/benches/polarized_fractal_efficiency.rs @@ -9,7 +9,7 @@ use criterion::{ criterion_main, }; use rand::{ - Rng, + RngExt, rng, }; use sliding_features::{ diff --git a/benches/re_flex.rs b/benches/re_flex.rs index 0a4f78c..44582cb 100644 --- a/benches/re_flex.rs +++ b/benches/re_flex.rs @@ -9,7 +9,7 @@ use criterion::{ criterion_main, }; use rand::{ - Rng, + RngExt, rng, }; use sliding_features::{ diff --git a/benches/roc.rs b/benches/roc.rs index 2a66d7f..de59ffb 100644 --- a/benches/roc.rs +++ b/benches/roc.rs @@ -9,7 +9,7 @@ use criterion::{ criterion_main, }; use rand::{ - Rng, + RngExt, rng, }; use sliding_features::{ diff --git a/benches/roofing_filter.rs b/benches/roofing_filter.rs index 1dfde6f..9f44432 100644 --- a/benches/roofing_filter.rs +++ b/benches/roofing_filter.rs @@ -9,7 +9,7 @@ use criterion::{ criterion_main, }; use rand::{ - Rng, + RngExt, rng, }; use sliding_features::{ diff --git a/benches/rsi.rs b/benches/rsi.rs index 0d5d629..4c6d0b4 100644 --- a/benches/rsi.rs +++ b/benches/rsi.rs @@ -9,7 +9,7 @@ use criterion::{ criterion_main, }; use rand::{ - Rng, + RngExt, rng, }; use sliding_features::{ diff --git a/benches/sma.rs b/benches/sma.rs index 9a075f9..6062a40 100644 --- a/benches/sma.rs +++ b/benches/sma.rs @@ -9,7 +9,7 @@ use criterion::{ criterion_main, }; use rand::{ - Rng, + RngExt, rng, }; use sliding_features::{ diff --git a/benches/super_smoother.rs b/benches/super_smoother.rs index f236ab3..d3a3857 100644 --- a/benches/super_smoother.rs +++ b/benches/super_smoother.rs @@ -9,7 +9,7 @@ use criterion::{ criterion_main, }; use rand::{ - Rng, + RngExt, rng, }; use sliding_features::{ diff --git a/benches/trend_flex.rs b/benches/trend_flex.rs index 22583ea..802f530 100644 --- a/benches/trend_flex.rs +++ b/benches/trend_flex.rs @@ -9,7 +9,7 @@ use criterion::{ criterion_main, }; use rand::{ - Rng, + RngExt, rng, }; use sliding_features::{ diff --git a/benches/vsct.rs b/benches/vsct.rs index d66b239..8229d88 100644 --- a/benches/vsct.rs +++ b/benches/vsct.rs @@ -9,7 +9,7 @@ use criterion::{ criterion_main, }; use rand::{ - Rng, + RngExt, rng, }; use sliding_features::{ diff --git a/benches/vst.rs b/benches/vst.rs index 8e797fb..0a3fc23 100644 --- a/benches/vst.rs +++ b/benches/vst.rs @@ -9,7 +9,7 @@ use criterion::{ criterion_main, }; use rand::{ - Rng, + RngExt, rng, }; use sliding_features::{ diff --git a/benches/welford_online.rs b/benches/welford_online.rs index a7dff41..ee051b2 100644 --- a/benches/welford_online.rs +++ b/benches/welford_online.rs @@ -9,7 +9,7 @@ use criterion::{ criterion_main, }; use rand::{ - Rng, + RngExt, rng, }; use sliding_features::{ diff --git a/flake.nix b/flake.nix index 76664d7..924e4d1 100644 --- a/flake.nix +++ b/flake.nix @@ -54,6 +54,7 @@ cmake ]; tools = with pkgs; [ + yamlfmt taplo cargo-semver-checks cargo_upgrades diff --git a/img/trend_flex.png b/img/trend_flex.png index b02620d..e950d8d 100644 Binary files a/img/trend_flex.png and b/img/trend_flex.png differ diff --git a/src/lib.rs b/src/lib.rs index 549e8e1..618c5b0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,4 @@ -#![deny( - missing_docs, - rustdoc::missing_crate_level_docs, - unused_crate_dependencies -)] +#![deny(missing_docs, rustdoc::missing_crate_level_docs)] #![warn(clippy::all)] #![doc = include_str!("../README.md")] diff --git a/src/sliding_windows/alma.rs b/src/sliding_windows/alma.rs index 8b1f694..3eb3451 100644 --- a/src/sliding_windows/alma.rs +++ b/src/sliding_windows/alma.rs @@ -108,7 +108,7 @@ where #[cfg(test)] mod tests { use rand::{ - Rng, + RngExt, rng, }; diff --git a/src/sliding_windows/laguerre_filter.rs b/src/sliding_windows/laguerre_filter.rs index dbb73dd..3250d48 100644 --- a/src/sliding_windows/laguerre_filter.rs +++ b/src/sliding_windows/laguerre_filter.rs @@ -100,7 +100,7 @@ where #[cfg(test)] mod tests { use rand::{ - Rng, + RngExt, rng, }; diff --git a/src/sliding_windows/mad_scaler.rs b/src/sliding_windows/mad_scaler.rs new file mode 100644 index 0000000..eb249e9 --- /dev/null +++ b/src/sliding_windows/mad_scaler.rs @@ -0,0 +1,371 @@ +//! Robust MAD-based (median absolute deviation) scaler over a sliding window. +//! +//! Computes `(x - median) / (1.4826 * MAD)` where `median` and `MAD` +//! (median absolute deviation) are derived from the *previous* sliding +//! window. +//! `MAD = median((x - median(X)).abs())` +//! The constant 1.4826 makes the estimator consistent with the +//! standard deviation for normally distributed data. +//! +//! Unlike `ZScoreStandardization`, the MAD scaler is resistant to outliers +//! and works well for skewed distributions. + +use std::{ + num::NonZeroUsize, + ops::{ + AddAssign, + SubAssign, + }, +}; + +use num::{ + Float, + FromPrimitive, +}; +use watermill::sorted_window::SortedWindow; + +use crate::View; + +/// Robust MAD-based scaler over a sliding window. +/// +/// Computes `(x - median) / (1.4826 * MAD)` using only *past* values so +/// that the transformation does not leak information about the current +/// observation. +/// +/// No output is emitted until the sliding window is fully filled. +/// During warm‑up `last()` returns `None`. +pub struct MadScaler { + view: V, + /// Sliding window length (for warm-up tracking). + window_len: NonZeroUsize, + /// Sliding window storing the sorted values. + sorted: SortedWindow, + /// Cached median of the window (recomputed when the sliding median changes). + cached_median: T, + /// Cached MAD (recomputed when the sliding median changes). + cached_mad: T, + /// Most recent output of the scaler. + out: Option, + /// Running count of values pushed into the sorted window (capped at window_len). + count: usize, + /// Pre-allocated buffer for merging absolute deviations in the hot-path. + mad_buf: Vec, +} + +impl std::fmt::Debug for MadScaler +where + F: Float + FromPrimitive + AddAssign + SubAssign + std::fmt::Debug, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MadScaler") + .field("window_len", &self.window_len) + .field("cached_median", &self.cached_median) + .field("cached_mad", &self.cached_mad) + .field("out", &self.out) + .field("count", &self.count) + .finish() + } +} + +/// MAD consistency constant: ~1.4826 makes MAD ≈ σ for normally distributed data. +const MAD_SCALE: f64 = 1.4826; + +impl MadScaler +where + V: View, + F: Float + FromPrimitive + AddAssign + SubAssign, +{ + /// Create a new `MadScaler` with a chained `View` and a given sliding + /// window length. + #[inline] + pub fn new(view: V, window_len: NonZeroUsize) -> Self { + Self { + view, + window_len, + sorted: SortedWindow::new(window_len.get()), + cached_median: F::zero(), + cached_mad: F::zero(), + out: None, + count: 0, + mad_buf: vec![F::zero(); window_len.get()], + } + } + + /// The sliding window length. + #[inline(always)] + pub fn window_len(&self) -> NonZeroUsize { + self.window_len + } + + /// Recompute median and MAD from the sorted window. + /// + /// Because `self.sorted` is already sorted, the absolute deviations + /// `|s[i] - median|` form two monotonic sequences radiating outward + /// from the median: one increasing to the left, one to the right. + /// We merge these two "tails" in **O(w)** instead of paying the + /// **O(w log w)** cost of a full sort. + fn recompute_cache(&mut self) { + let n = self.sorted.len(); + debug_assert!(n > 0, "recompute_cache called on empty window"); + debug_assert_eq!(n, self.mad_buf.len()); + + // --- median (from already-sorted window) --- + let median = if n % 2 == 0 { + let a = self.sorted[n / 2 - 1]; + let b = self.sorted[n / 2]; + (a + b) / (F::one() + F::one()) + } else { + self.sorted[n / 2] + }; + self.cached_median = median; + + // --- median absolute deviation via O(w) merge --- + // Walk the sorted window outward from the median, merging the + // monotonically-increasing left and right absolute deviations. + let mad_buf = &mut self.mad_buf; + let mid = n / 2; + let mut out_idx = 0usize; + + // For odd n, the median element itself has deviation 0 — emit it first. + if n % 2 == 1 { + mad_buf[0] = F::zero(); + out_idx = 1; + } + + // left walks down from mid-1 to 0, right walks up from mid (even) or mid+1 (odd). + let mut left: isize = mid.checked_sub(1).map_or(-1, |v| v as isize); + let mut right: usize = if n % 2 == 0 { mid } else { mid + 1 }; + + loop { + let l_valid = left >= 0; + let r_valid = right < n; + match (l_valid, r_valid) { + (true, true) => { + let l_abs = (self.sorted[left as usize] - median).abs(); + let r_abs = (self.sorted[right] - median).abs(); + if l_abs <= r_abs { + mad_buf[out_idx] = l_abs; + out_idx += 1; + left -= 1; + } else { + mad_buf[out_idx] = r_abs; + out_idx += 1; + right += 1; + } + } + (true, false) => { + mad_buf[out_idx] = (self.sorted[left as usize] - median).abs(); + out_idx += 1; + left -= 1; + } + (false, true) => { + mad_buf[out_idx] = (self.sorted[right] - median).abs(); + out_idx += 1; + right += 1; + } + (false, false) => break, + } + } + debug_assert_eq!(out_idx, n, "merge must fill entire buffer"); + + self.cached_mad = median_from_sorted(mad_buf); + } +} + +#[inline(always)] +fn median_from_sorted(vals: &[F]) -> F { + let n = vals.len(); + if n % 2 == 0 { + let a = vals[n / 2 - 1]; + let b = vals[n / 2]; + (a + b) / (F::one() + F::one()) + } else { + vals[n / 2] + } +} + +impl View for MadScaler +where + V: View, + T: Float + FromPrimitive + AddAssign + SubAssign, +{ + fn update(&mut self, val: T) { + debug_assert!(val.is_finite(), "value must be finite"); + self.view.update(val); + let Some(val) = self.view.last() else { + return; + }; + debug_assert!(val.is_finite(), "value must be finite"); + + // Only emit output once the window is full (count >= window_len). + // At that point `sorted` contains exactly `window_len` past values. + if self.count >= self.window_len.get() { + // The window (`sorted`) holds the *previous* window of values. + // Normalize `val` against those. + self.recompute_cache(); + + let scale_const = T::from(MAD_SCALE).expect("convert"); + self.out = if self.cached_mad <= T::zero() { + // No dispersion → all values identical. + Some(T::zero()) + } else { + let diff = val - self.cached_median; + Some(diff / (scale_const * self.cached_mad)) + }; + } else { + self.out = None; + } + + // Slide window: push current value into the sorted window. + // This makes it part of the *next* normalization's reference. + self.sorted.push_back(val); + + self.count = (self.count + 1).min(self.window_len.get()); + } + + #[inline] + fn last(&self) -> Option { + self.out + } +} + +#[cfg(test)] +mod tests { + use ballpark::assert_approx_eq; + + use super::*; + use crate::{ + plot::plot_values, + pure_functions::Echo, + test_data::TEST_DATA, + }; + + #[test] + fn mad_scaler_plot() { + let mut ms = MadScaler::new(Echo::new(), NonZeroUsize::new(16).unwrap()); + let mut out: Vec = Vec::with_capacity(TEST_DATA.len()); + for v in &TEST_DATA { + ms.update(*v); + if let Some(val) = ms.last() { + out.push(val); + } + } + let filename = "img/mad_scaler.png"; + plot_values(out, filename).unwrap(); + } + + #[test] + fn mad_scaler_no_lookahead() { + // Window = 3. Warm up with [10, 10, 10], then spike 100. + // The spike must be normalized against [10, 10, 10], + // where median = 10, MAD = 0 → output 0. + let mut ms = MadScaler::new(Echo::new(), NonZeroUsize::new(3).unwrap()); + + ms.update(10.0); + assert!(ms.last().is_none(), "warm-up 1"); + ms.update(10.0); + assert!(ms.last().is_none(), "warm-up 2"); + ms.update(10.0); + assert!(ms.last().is_none(), "warm-up 3"); + + // 4th value: first real output. Window = [10, 10, 10]. + ms.update(100.0); + let got = ms.last().unwrap(); + assert!( + (got - 0.0).abs() < 1e-12, + "spike must not leak into its own reference window, got {got}, expected 0.0" + ); + + // Now window = [10, 10, 100]. Next 10 is normalized against it. + // median of [10, 10, 100] = 10. + // absolute deviations: [0, 0, 90] → sorted [0, 0, 90] → median MAD = 0. + // → output = 0. + ms.update(10.0); + let got = ms.last().unwrap(); + assert!( + (got - 0.0).abs() < 1e-12, + "after spike entered window (MAD=0), got {got}, expected 0.0" + ); + } + + #[test] + fn mad_scaler_disjoint_clean_and_outlier() { + // Values: 5 clean (1,2,3,4,5) then outlier (100). + // Window = 5. + let mut ms = MadScaler::new(Echo::new(), NonZeroUsize::new(5).unwrap()); + + // Warm-up: no output for first 5. + for v in [1.0, 2.0, 3.0, 4.0, 5.0] { + ms.update(v); + assert!(ms.last().is_none(), "warm-up"); + } + + // 6th: 100 normalized against [1,2,3,4,5]. + // median of [1,2,3,4,5] = 3. + // abs devs: [2,1,0,1,2] → sorted [0,1,1,2,2] → MAD = 1. + // → output = (100 - 3) / (1.4826 * 1) ≈ 65.42 + ms.update(100.0); + let got = ms.last().unwrap(); + let expected = (100.0 - 3.0) / (1.4826); + assert!( + (got - expected).abs() < 1e-6, + "outlier scaling: got {got}, expected ~{expected}" + ); + } + + fn median(vals: &mut [f64]) -> f64 { + vals.sort_by(f64::total_cmp); + let n = vals.len(); + if n.is_multiple_of(2) { + let a = vals[n / 2 - 1]; + let b = vals[n / 2]; + (a + b) / 2.0 + } else { + vals[n / 2] + } + } + + fn mad_scaled(vals: &mut [f64], buf: &mut [f64], current: f64) -> f64 { + assert_eq!(vals.len(), buf.len()); + assert_ne!(*vals.last().unwrap(), current); + + let m = median(vals); + dbg!(&m); + buf.iter_mut() + .zip(vals) + .for_each(|(b, v)| *b = (*v - m).abs()); + let mad = median(buf); + dbg!(&mad); + (current - m) / (MAD_SCALE * mad) + } + + #[test] + fn mad_scaler() { + const WINDOW_LEN: usize = 5; + + let r = romu::Rng::from_seed_with_64bit(0); + let vals = Vec::from_iter((0..25).map(|_| r.f64())); + dbg!(&vals); + let mut buf = vec![0.0; WINDOW_LEN]; + + let mut ms = MadScaler::new(Echo::new(), NonZeroUsize::new(WINDOW_LEN).unwrap()); + + // warmup + for v in vals.iter().take(WINDOW_LEN) { + ms.update(*v); + assert!(ms.last().is_none()) + } + + for (i, v) in vals.iter().enumerate().skip(WINDOW_LEN) { + ms.update(*v); + dbg!(&v); + dbg!(&ms); + let start = i - WINDOW_LEN; + let end = i; + let mut window = vals[start..end].to_vec(); + assert_eq!(window.len(), WINDOW_LEN); + let expected = mad_scaled(&mut window, &mut buf, *v); + assert_approx_eq!(ms.last().expect("is warm"), expected); + } + } +} diff --git a/src/sliding_windows/mod.rs b/src/sliding_windows/mod.rs index d9690c8..5e80cc7 100644 --- a/src/sliding_windows/mod.rs +++ b/src/sliding_windows/mod.rs @@ -12,6 +12,7 @@ mod ema; mod lag; mod laguerre_filter; mod laguerre_rsi; +mod mad_scaler; mod max; mod min; mod min_max_normalizer; @@ -40,6 +41,7 @@ pub use ema::Ema; pub use lag::Lag; pub use laguerre_filter::LaguerreFilter; pub use laguerre_rsi::LaguerreRSI; +pub use mad_scaler::MadScaler; pub use max::Max; pub use min::Min; pub use min_max_normalizer::MinMaxNormalizer; diff --git a/src/sliding_windows/sma.rs b/src/sliding_windows/sma.rs index bb96967..63a9c40 100644 --- a/src/sliding_windows/sma.rs +++ b/src/sliding_windows/sma.rs @@ -72,7 +72,7 @@ where #[cfg(test)] mod tests { use rand::{ - Rng, + RngExt, rng, };