diff --git a/.Jules/palette.md b/.Jules/palette.md deleted file mode 100644 index a2e566d..0000000 --- a/.Jules/palette.md +++ /dev/null @@ -1,20 +0,0 @@ -## 2024-07-07 - Add definition lists to Fit Report metric cards - -**Learning:** Key-value pairs for metrics and statistics in HTML reports must use semantic `
`, `
`, and `
` elements to properly associate the label with its value for screen readers. Using generic `
` or `
` with `` and `` separates the semantic relationship. Additionally, default `
` styling varies across browsers, so explicit `font-weight` resets are necessary to maintain cross-browser visual consistency while improving semantics. Also, relying purely on mouse interactions for focused tables (`:focus`) impacts mouse users visually - `:focus-visible` ensures focus outlines appear only during keyboard navigation. Purely visual charting elements (like horizontal bar tracks) inside HTML need `aria-hidden="true"` to prevent redundant, noisy announcements for screen reader users. -**Action:** Always prefer definition lists (`
`) over generic markup for metric pairs, and explicitly reset their font-weight in CSS. Use `:focus-visible` instead of `:focus` for generic focus styles, and add `aria-hidden="true"` to decorative/visual chart tracks. - -## 2025-02-18 - CLI Developer Experience -**Learning:** Even CLI tools benefit greatly from UX improvements (Developer Experience). Adding help strings and success feedback makes the tool much more intuitive. -**Action:** Always check if argparse/CLI tools have descriptive help texts and user feedback on success/failure. - -## 2025-02-18 - CLI Error Handling DX -**Learning:** Raw stack traces from FileNotFoundError or exception in CLI tools provide poor developer experience. -**Action:** Wrap file loading and data processing steps in CLI applications with try-except blocks to output clean, user-friendly error messages to stderr. - -## 2025-02-18 - CLI `simulate` Error Handling DX -**Learning:** Raw stack traces from `ValueError` (like invalid configuration parameters) during the `simulate` CLI command provide poor developer experience. -**Action:** Wrapped configuration validation and simulation execution steps in a try-except block to catch `ValueError` and `OSError`, outputting a clean, user-friendly error message to stderr and returning 1 to prevent raw stack traces. - -## 2025-02-18 - Generic Focusable Container Focus Style -**Learning:** Using `:focus` on generic focusable containers (like scrollable elements with `tabindex="0"`) creates visually jarring outlines for mouse users when clicked. -**Action:** Always use `:focus-visible` instead of `:focus` for generic focusable containers to preserve required focus indicators for keyboard navigation while avoiding jarring visual artifacts for mouse users. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c996a1..c65b17c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,9 +34,43 @@ jobs: with: python-version: "3.12" - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + # `cargo test --workspace` also runs the proptest fuzz harness in + # crates/mlsirm-core/tests/proptest_neg_loglik.rs. - run: cargo test --workspace - run: cargo test --manifest-path crates/fast-mlsirm-py/Cargo.toml + fuzz: + # Short, bounded coverage-guided fuzzing so PR cost stays low. The + # property-based Hypothesis tests already run in the `python` job (via the + # dev extra); this job adds the Atheris coverage-guided harnesses with a + # ~60s-per-target budget. Longer campaigns can be run nightly if desired. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + with: + python-version: "3.12" + # pip is version-pinned so the toolchain used to resolve the fuzz extras + # is reproducible (OpenSSF Scorecard pinned-dependencies hardening). + - run: python -m pip install --upgrade "pip==26.1.2" + # Editable install of the repo-under-test. pip does not support + # --require-hashes for editable/local installs, so the fuzz extras + # (atheris plus test-only hypothesis) are resolved from the pinned pip + # above rather than a hash-locked requirements file. + - run: python -m pip install -e .[fuzz] + - name: Fuzz load_factor_csv (CSV parser) + run: | + python fuzz/atheris/fuzz_load_factor_csv.py \ + -max_total_time=60 -timeout=25 fuzz/corpus/load_factor_csv + - name: Fuzz render_diagnostics_report (JSON -> HTML) + run: | + python fuzz/atheris/fuzz_render_report.py \ + -max_total_time=60 -timeout=25 fuzz/corpus/render_report + - name: Fuzz config validators + run: | + python fuzz/atheris/fuzz_config.py \ + -max_total_time=60 -timeout=25 fuzz/corpus/config + package: runs-on: ubuntu-latest steps: diff --git a/Cargo.lock b/Cargo.lock index cf5b084..565fffb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,15 +38,30 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] + [[package]] name = "bit-set" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" dependencies = [ - "bit-vec", + "bit-vec 0.9.1", ] +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bit-vec" version = "0.9.1" @@ -157,6 +172,28 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.2.0" @@ -187,6 +224,29 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + [[package]] name = "gl_generator" version = "0.14.0" @@ -353,6 +413,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litrs" version = "1.0.0" @@ -380,6 +446,7 @@ version = "0.1.0" dependencies = [ "bytemuck", "pollster", + "proptest", "wgpu", ] @@ -390,7 +457,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23bf0a141a9ab6f07dbb492db53245e464bc9db42f407772d9ae03d83a2c1033" dependencies = [ "arrayvec", - "bit-set", + "bit-set 0.10.0", "bitflags", "cfg-if", "cfg_aliases", @@ -597,6 +664,15 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "presser" version = "0.3.1" @@ -618,6 +694,31 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.46" @@ -627,6 +728,56 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + [[package]] name = "range-alloc" version = "0.1.5" @@ -660,6 +811,12 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -672,12 +829,37 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -761,6 +943,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -790,6 +985,12 @@ dependencies = [ "syn", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -808,6 +1009,24 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -922,8 +1141,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08763620e76fc980bca7bf84de82568614487a53172dd968d89187282eb87fa2" dependencies = [ "arrayvec", - "bit-set", - "bit-vec", + "bit-set 0.10.0", + "bit-vec 0.9.1", "bitflags", "bytemuck", "cfg_aliases", @@ -985,7 +1204,7 @@ dependencies = [ "android_system_properties", "arrayvec", "ash", - "bit-set", + "bit-set 0.10.0", "bitflags", "block2", "bytemuck", @@ -1186,6 +1405,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "xml-rs" version = "0.8.28" @@ -1194,18 +1419,18 @@ checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" [[package]] name = "zerocopy" -version = "0.8.53" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.53" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", diff --git a/crates/mlsirm-core/Cargo.toml b/crates/mlsirm-core/Cargo.toml index 620c91c..7a801a9 100644 --- a/crates/mlsirm-core/Cargo.toml +++ b/crates/mlsirm-core/Cargo.toml @@ -28,3 +28,7 @@ gpu = ["dep:wgpu", "dep:pollster", "dep:bytemuck"] [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ["cfg(coverage)"] } + +[dev-dependencies] +# proptest is dual MIT / Apache-2.0 licensed -- permissive, matches this crate. +proptest = "1" diff --git a/crates/mlsirm-core/tests/proptest_neg_loglik.rs b/crates/mlsirm-core/tests/proptest_neg_loglik.rs new file mode 100644 index 0000000..44c35d7 --- /dev/null +++ b/crates/mlsirm-core/tests/proptest_neg_loglik.rs @@ -0,0 +1,127 @@ +//! Property-based (proptest) fuzz harness for the core numeric kernel. +//! +//! `neg_loglik_and_grad` is the hot path every backend funnels response data, +//! item->factor mappings and parameter vectors through. CodeGraph +//! (`codegraph explore "neg_loglik_and_grad config Params ModelConfig"`) shows +//! it as the single Rust entry point with the widest blast radius. It carries +//! documented length preconditions (`assert_eq!` on `y`, `factor_id`, `mask`); +//! this harness always *satisfies* those preconditions and then fuzzes the +//! numeric content, asserting the invariants that must hold for every well +//! formed problem instance: +//! +//! * no panic / no arithmetic UB on arbitrary finite inputs, +//! * every returned gradient vector has exactly the length the config implies, +//! * with finite inputs the objective and log-likelihood are finite. +//! +//! proptest is MIT / Apache-2.0 licensed, so it is safe for this MIT crate. It +//! runs under the standard `cargo test`, so CI needs no extra toolchain. + +use mlsirm_core::{ModelConfig, ModelType, Params, PenaltyConfig}; +use proptest::prelude::*; + +const MODEL_TYPES: [ModelType; 5] = [ + ModelType::Mirt, + ModelType::Mls2plm, + ModelType::Mlsrm, + ModelType::Uls2plm, + ModelType::Ulsrm, +]; + +/// A structurally-consistent problem instance: all vector lengths agree with +/// the config, so the kernel's own `assert_eq!` preconditions are met and we +/// are fuzzing behaviour, not argument plumbing. +#[derive(Debug, Clone)] +struct Instance { + y: Vec, + mask: Option>, + factor_id: Vec, + params: Params, + config: ModelConfig, + penalty: PenaltyConfig, +} + +fn finite() -> impl Strategy { + // Bounded finite reals: exercises the numerics without trivially + // overflowing exp()/softplus into +inf on every draw. + -50.0f64..50.0f64 +} + +fn instance_strategy() -> impl Strategy { + (1usize..6, 1usize..6, 1usize..4, 1usize..4, 0usize..5).prop_flat_map( + |(n_persons, n_items, n_dims, latent_dim, model_idx)| { + let model_type = MODEL_TYPES[model_idx]; + let config = ModelConfig { + n_persons, + n_items, + n_dims, + latent_dim, + model_type, + eps_distance: 1e-8, + }; + + let y_len = n_persons * n_items; + let theta_len = n_persons * n_dims; + let xi_len = n_persons * latent_dim; + let zeta_len = n_items * latent_dim; + + ( + // Binary-ish responses, but allow arbitrary finite values too. + prop::collection::vec(prop_oneof![Just(0.0f64), Just(1.0f64), finite()], y_len), + prop::option::of(prop::collection::vec(any::(), y_len)), + // factor_id must index theta's dimension: 0..n_dims. + prop::collection::vec(0..n_dims, n_items), + prop::collection::vec(finite(), theta_len), + prop::collection::vec(finite(), n_items), + prop::collection::vec(finite(), n_items), + prop::collection::vec(finite(), xi_len), + prop::collection::vec(finite(), zeta_len), + finite(), + ) + .prop_map( + move |(y, mask, factor_id, theta, alpha, b, xi, zeta, tau)| Instance { + y, + mask, + factor_id, + params: Params { + theta, + alpha, + b, + xi, + zeta, + tau, + }, + config: config.clone(), + penalty: PenaltyConfig::default(), + }, + ) + }, + ) +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 512, ..ProptestConfig::default() })] + + #[test] + fn neg_loglik_and_grad_holds_invariants(inst in instance_strategy()) { + let Instance { y, mask, factor_id, params, config, penalty } = inst; + let mask_ref = mask.as_deref(); + + let (objective, grad, loglik) = + mlsirm_core::neg_loglik_and_grad(&y, mask_ref, &factor_id, ¶ms, &config, &penalty); + + // Gradient shapes must exactly match the configured dimensions. + prop_assert_eq!(grad.theta.len(), config.n_persons * config.n_dims); + prop_assert_eq!(grad.alpha.len(), config.n_items); + prop_assert_eq!(grad.b.len(), config.n_items); + prop_assert_eq!(grad.xi.len(), config.n_persons * config.latent_dim); + prop_assert_eq!(grad.zeta.len(), config.n_items * config.latent_dim); + + // With finite inputs the outputs stay finite (no NaN/inf leakage). + prop_assert!(objective.is_finite(), "objective was not finite: {}", objective); + prop_assert!(loglik.is_finite(), "loglik was not finite: {}", loglik); + for g in grad.theta.iter().chain(&grad.alpha).chain(&grad.b).chain(&grad.xi).chain(&grad.zeta) { + prop_assert!(g.is_finite(), "gradient component was not finite: {}", g); + } + prop_assert!(grad.tau.is_finite(), "grad.tau was not finite: {}", grad.tau); + } +} diff --git a/fuzz/README.md b/fuzz/README.md new file mode 100644 index 0000000..b8d5243 --- /dev/null +++ b/fuzz/README.md @@ -0,0 +1,75 @@ +# Fuzzing fast-mlsirm + +This directory holds the coverage-guided fuzz harnesses for the untrusted-input +surfaces of fast-mlsirm. Property-based tests that run inside the normal +`pytest` / `cargo test` suites live alongside the source +(`tests/test_fuzz_properties.py`, `crates/mlsirm-core/tests/proptest_neg_loglik.rs`). + +## Why these targets + +The surfaces were selected with **CodeGraph** by ranking blast radius on the +parser / loader / validator nodes: + +``` +codegraph explore "parse load config CSV JSON input file deserialization untrusted input" +codegraph explore "neg_loglik_and_grad config Params ModelConfig" +``` + +| Surface | File | Why it is untrusted | +| --- | --- | --- | +| `load_factor_csv` | `python/fast_mlsirm/io.py` | Reads an on-disk item→factor CSV via `numpy.loadtxt`; reached from the CLI. | +| `render_diagnostics_report` | `python/fast_mlsirm/report.py` | Parses an **arbitrary JSON** diagnostics file and renders it to HTML. | +| `MLS2PLMConfig` / `FitConfig` `.validate()` | `python/fast_mlsirm/config.py` | Every CLI / API call funnels user numeric parameters through these validators. | +| `neg_loglik_and_grad` | `crates/mlsirm-core/src/lib.rs` | The core numeric kernel — widest Rust blast radius; consumes response data + parameter vectors. | + +## Tools & Licenses + +| Tool | License | Where | +| --- | --- | --- | +| [Atheris](https://github.com/google/atheris) (coverage-guided) | Apache-2.0 | `fuzz/atheris/*.py` | +| [Hypothesis](https://hypothesis.readthedocs.io/) (property-based) | MPL-2.0, test-only dev dependency | `tests/test_fuzz_properties.py` | +| [proptest](https://github.com/proptest-rs/proptest) (property-based) | MIT / Apache-2.0 | `crates/mlsirm-core/tests/proptest_neg_loglik.rs` | + +## The contract + +Every harness enforces the same invariant: on **arbitrary input** the code +under test either succeeds or fails with a *documented, benign* exception +(`ValueError` / `json.JSONDecodeError` / `UnicodeDecodeError` / `OSError`). An +`AssertionError`, `KeyError`, `IndexError`, `TypeError`, `RecursionError`, panic +or hang is a bug and produces a reproducer. Additional invariants: + +- `render_diagnostics_report`: a planted `" + + +def _decorate(value): + """Recursively append the XSS sentinel to string values so the escaping + invariant is exercised on whatever strings survive into the HTML.""" + if isinstance(value, str): + return value + _XSS_SENTINEL + if isinstance(value, list): + return [_decorate(item) for item in value] + if isinstance(value, dict): + return {str(key): _decorate(item) for key, item in value.items()} + return value + + +def _test_one_input(data: bytes) -> None: + fdp = atheris.FuzzedDataProvider(data) + raw = fdp.ConsumeUnicodeNoSurrogates(fdp.remaining_bytes()) + + try: + payload = json.loads(raw) + except (json.JSONDecodeError, ValueError, RecursionError): + return + if not isinstance(payload, dict): + return + + payload = _decorate(payload) + + in_fd, in_path = tempfile.mkstemp(suffix=".json") + out_dir = tempfile.mkdtemp() + out_path = os.path.join(out_dir, "report.html") + try: + with os.fdopen(in_fd, "w", encoding="utf-8") as handle: + json.dump(payload, handle) + try: + result = render_diagnostics_report(in_path, out_path) + except BENIGN_EXCEPTIONS: + return + + html = result.read_text(encoding="utf-8") + # Escaping invariant: the raw " + +_HYPO_SETTINGS = settings(max_examples=200, deadline=None) + + +# --------------------------------------------------------------------------- +# load_factor_csv: arbitrary CSV bytes must never crash the parser. +# --------------------------------------------------------------------------- +@_HYPO_SETTINGS +@given(st.text(max_size=256)) +def test_load_factor_csv_never_crashes(tmp_path_factory, text): + path = tmp_path_factory.mktemp("csv") / "factor.csv" + path.write_text(text, encoding="utf-8") + try: + result = load_factor_csv(path) + except BENIGN: + return + assert result.ndim == 1 + + +# --------------------------------------------------------------------------- +# render_diagnostics_report: arbitrary JSON payloads -> HTML, always escaped. +# --------------------------------------------------------------------------- +def _json_values(): + return st.recursive( + st.none() + | st.booleans() + | st.integers(min_value=-(10**9), max_value=10**9) + | st.floats(allow_nan=True, allow_infinity=True) + | st.text(max_size=32).map(lambda s: s + _XSS_SENTINEL), + lambda children: st.lists(children, max_size=6) + | st.dictionaries(st.text(max_size=12), children, max_size=6), + max_leaves=25, + ) + + +@_HYPO_SETTINGS +@given(st.dictionaries(st.text(max_size=16), _json_values(), max_size=8)) +def test_render_report_never_crashes_and_escapes(tmp_path_factory, payload): + work = tmp_path_factory.mktemp("report") + in_path = work / "diagnostics.json" + out_path = work / "report.html" + in_path.write_text(json.dumps(payload), encoding="utf-8") + try: + result = render_diagnostics_report(in_path, out_path) + except BENIGN: + return + html = result.read_text(encoding="utf-8") + assert _XSS_SENTINEL not in html, "unescaped