diff --git a/.clusterfuzzlite/Dockerfile b/.clusterfuzzlite/Dockerfile new file mode 100644 index 000000000..10024e354 --- /dev/null +++ b/.clusterfuzzlite/Dockerfile @@ -0,0 +1,15 @@ +FROM gcr.io/oss-fuzz-base/base-builder-rust@sha256:a960440a8543df2411ca489eff995366ce8041b086141cc5092664d793f20e9e + +RUN groupadd --gid 1001 fuzzbuilder \ + && useradd --uid 1001 --gid fuzzbuilder --create-home --shell /bin/bash fuzzbuilder \ + && mkdir -p /github/workspace/build-out /rustc \ + && touch /usr/lib/libFuzzingEngine.a \ + && chown fuzzbuilder:fuzzbuilder /usr/lib/libFuzzingEngine.a \ + && chown -R fuzzbuilder:fuzzbuilder /rust /rustc /out /work /src /github \ + && chmod -R a+rwx /github/workspace/build-out + +COPY --chown=fuzzbuilder:fuzzbuilder . $SRC/fast-mlsirm +COPY --chown=fuzzbuilder:fuzzbuilder .clusterfuzzlite/build.sh $SRC/build.sh +RUN chmod +x $SRC/build.sh +WORKDIR $SRC/fast-mlsirm +USER fuzzbuilder diff --git a/.clusterfuzzlite/build.sh b/.clusterfuzzlite/build.sh new file mode 100755 index 000000000..fde040c6e --- /dev/null +++ b/.clusterfuzzlite/build.sh @@ -0,0 +1,9 @@ +#!/bin/bash -eux + +cd "$SRC/fast-mlsirm" +mkdir -p "$OUT" +id +echo "LIB_FUZZING_ENGINE=${LIB_FUZZING_ENGINE:-unset}" +ls -ld "$SRC/fast-mlsirm" "$OUT" +cargo fuzz build -O neg_loglik +cp fuzz/target/x86_64-unknown-linux-gnu/release/neg_loglik "$OUT/" diff --git a/.clusterfuzzlite/project.yaml b/.clusterfuzzlite/project.yaml new file mode 100644 index 000000000..22761ba7e --- /dev/null +++ b/.clusterfuzzlite/project.yaml @@ -0,0 +1 @@ +language: rust diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2f07f4cb1..6acda24ad 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,6 +10,11 @@ updates: schedule: interval: "weekly" open-pull-requests-limit: 5 + - package-ecosystem: "cargo" + directory: "/fuzz" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/.github/workflows/cflite_pr.yml b/.github/workflows/cflite_pr.yml new file mode 100644 index 000000000..a733c3a9b --- /dev/null +++ b/.github/workflows/cflite_pr.yml @@ -0,0 +1,50 @@ +name: ClusterFuzzLite PR fuzzing + +on: + pull_request: + paths: + - ".clusterfuzzlite/**" + - ".github/workflows/cflite_pr.yml" + - "Cargo.toml" + - "crates/**" + - "fuzz/**" + +permissions: + contents: read + +jobs: + PR: + runs-on: ubuntu-24.04 + permissions: + contents: read + security-events: write + concurrency: + group: ${{ github.workflow }}-${{ matrix.sanitizer }}-${{ github.ref }} + cancel-in-progress: true + strategy: + fail-fast: false + matrix: + sanitizer: [address] + steps: + - name: Prepare ClusterFuzzLite output directory + run: | + mkdir -p "$GITHUB_WORKSPACE/build-out" + chmod -R a+rwx "$GITHUB_WORKSPACE/build-out" + echo "Prepared ClusterFuzzLite build-out for non-root fuzzbuilder writes" + ls -ld "$GITHUB_WORKSPACE" "$GITHUB_WORKSPACE/build-out" + - name: Build fuzzers (${{ matrix.sanitizer }}) + id: build + uses: google/clusterfuzzlite/actions/build_fuzzers@82652fb49e77bc29c35da1167bb286e93c6bcc05 + with: + language: rust + github-token: ${{ secrets.GITHUB_TOKEN }} + sanitizer: ${{ matrix.sanitizer }} + - name: Run fuzzers (${{ matrix.sanitizer }}) + id: run + uses: google/clusterfuzzlite/actions/run_fuzzers@82652fb49e77bc29c35da1167bb286e93c6bcc05 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + fuzz-seconds: 120 + mode: code-change + output-sarif: true + sanitizer: ${{ matrix.sanitizer }} diff --git a/crates/mlsirm-core/src/lib.rs b/crates/mlsirm-core/src/lib.rs index b61936f9f..38ec9d8b5 100644 --- a/crates/mlsirm-core/src/lib.rs +++ b/crates/mlsirm-core/src/lib.rs @@ -340,7 +340,8 @@ mod additional_tests { assert_eq!(obj, 0.0); let mask_true = vec![true]; - let (obj_mirt, _, _) = neg_loglik_and_grad(&y, Some(&mask_true), &[0], ¶ms, &config, &penalty); + let (obj_mirt, _, _) = + neg_loglik_and_grad(&y, Some(&mask_true), &[0], ¶ms, &config, &penalty); assert!(obj_mirt > 0.0); } } diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock new file mode 100644 index 000000000..e9e61bb28 --- /dev/null +++ b/fuzz/Cargo.lock @@ -0,0 +1,94 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "cc" +version = "1.2.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "fast-mlsirm-fuzz" +version = "0.0.0" +dependencies = [ + "libfuzzer-sys", + "mlsirm-core", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[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", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "mlsirm-core" +version = "0.1.0" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 000000000..2fc4cbb56 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "fast-mlsirm-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "=0.4.13" +mlsirm-core = { path = "../crates/mlsirm-core" } + +[workspace] + +[[bin]] +name = "neg_loglik" +path = "fuzz_targets/neg_loglik.rs" +test = false +doc = false +bench = false diff --git a/fuzz/fuzz_targets/neg_loglik.rs b/fuzz/fuzz_targets/neg_loglik.rs new file mode 100644 index 000000000..1df3eb8db --- /dev/null +++ b/fuzz/fuzz_targets/neg_loglik.rs @@ -0,0 +1,91 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use mlsirm_core::{neg_loglik_and_grad, ModelConfig, ModelType, Params, PenaltyConfig}; + +fn byte_to_f64(byte: u8) -> f64 { + (byte as f64 / 32.0) - 4.0 +} + +fn next_value(data: &[u8], cursor: &mut usize) -> f64 { + let value = byte_to_f64(data[*cursor % data.len()]); + *cursor += 1; + value +} + +fuzz_target!(|data: &[u8]| { + if data.len() < 8 { + return; + } + + let n_persons = (data[0] as usize % 3) + 1; + let n_items = (data[1] as usize % 3) + 1; + let n_dims = (data[2] as usize % 2) + 1; + let latent_dim = (data[3] as usize % 2) + 1; + let model_type = match data[4] % 5 { + 0 => ModelType::Mirt, + 1 => ModelType::Mls2plm, + 2 => ModelType::Mlsrm, + 3 => ModelType::Uls2plm, + _ => ModelType::Ulsrm, + }; + let eps_distance = 1e-8 + (data[5] as f64 / 255.0) * 1e-3; + let config = ModelConfig { + n_persons, + n_items, + n_dims, + latent_dim, + model_type, + eps_distance, + }; + + let mut cursor = 6; + + let y = (0..n_persons * n_items) + .map(|_| { + if next_value(data, &mut cursor) >= 0.0 { + 1.0 + } else { + 0.0 + } + }) + .collect::>(); + let mask = (0..n_persons * n_items) + .map(|_| next_value(data, &mut cursor) >= -3.5) + .collect::>(); + let factor_id = (0..n_items) + .map(|idx| (idx + data[(cursor + idx) % data.len()] as usize) % n_dims) + .collect::>(); + cursor += n_items; + + let params = Params { + theta: (0..n_persons * n_dims) + .map(|_| next_value(data, &mut cursor)) + .collect(), + alpha: (0..n_items) + .map(|_| next_value(data, &mut cursor)) + .collect(), + b: (0..n_items) + .map(|_| next_value(data, &mut cursor)) + .collect(), + xi: (0..n_persons * latent_dim) + .map(|_| next_value(data, &mut cursor)) + .collect(), + zeta: (0..n_items * latent_dim) + .map(|_| next_value(data, &mut cursor)) + .collect(), + tau: next_value(data, &mut cursor), + }; + let penalty = PenaltyConfig { + lambda_theta: next_value(data, &mut cursor).abs() * 0.01, + lambda_xi: next_value(data, &mut cursor).abs() * 0.01, + lambda_zeta: next_value(data, &mut cursor).abs() * 0.01, + lambda_b: next_value(data, &mut cursor).abs() * 0.01, + lambda_alpha: next_value(data, &mut cursor).abs() * 0.01, + lambda_tau: next_value(data, &mut cursor).abs() * 0.01, + mu_alpha: next_value(data, &mut cursor), + mu_tau: next_value(data, &mut cursor), + }; + + let _ = neg_loglik_and_grad(&y, Some(&mask), &factor_id, ¶ms, &config, &penalty); +}); diff --git a/python/fast_mlsirm/report.py b/python/fast_mlsirm/report.py index 9d70dcbe8..80001689f 100644 --- a/python/fast_mlsirm/report.py +++ b/python/fast_mlsirm/report.py @@ -246,7 +246,12 @@ def _bar_chart(rows: list[dict[str, Any]], value_key: str | None) -> str: return "" if not rows: return "" - values = [float(row[value_key]) for row in rows if _is_number(row.get(value_key))] + numeric_rows = [ + (index, row, float(row[value_key])) + for index, row in enumerate(rows) + if _is_number(row.get(value_key)) + ] + values = [value for _, _, value in numeric_rows] if not values: return "" @@ -254,11 +259,7 @@ def _bar_chart(rows: list[dict[str, Any]], value_key: str | None) -> str: upper = max(values) span = upper - lower chart_rows = [] - for index, row in enumerate(rows[:12]): - raw_value = row.get(value_key) - if not _is_number(raw_value): - continue - value = float(raw_value) + for index, row, value in numeric_rows[:12]: width = 64.0 if span == 0 else 8.0 + ((value - lower) / span) * 92.0 chart_rows.append( "\n".join( diff --git a/tests/test_report.py b/tests/test_report.py index 0b29e0ee1..37f2ae2cd 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -189,6 +189,29 @@ def test_render_table_section_omits_empty_chart_placeholder(tmp_path): assert "No chartable values were recorded for this section." not in html +def test_render_table_section_charts_later_numeric_rows(tmp_path): + source = tmp_path / "fit_diagnostics.json" + out = tmp_path / "report.html" + item_ids = list(range(13)) + outfit = [None] * 12 + [1.2] + source.write_text( + json.dumps( + { + "model_fit": {"loglik": -3.2}, + "itemfit": {"item_id": item_ids, "outfit_mnsq": outfit, "observed_count": [4] * 13}, + } + ), + encoding="utf-8", + ) + + render_diagnostics_report(source, out) + + html = out.read_text(encoding="utf-8") + assert '