` 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