Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# ferrolearn CI
#
# Gates every push to `main` and every PR targeting `main` on four
# parallel jobs:
#
# - `cargo fmt --check` — strict rustfmt parity with main
# - `cargo clippy -D warnings` — strict lints; the workspace is
# expected to clear with -D warnings
# (math idioms that want range loops
# carry per-function #[allow])
# - `cargo test --release` — full workspace test suite in release
# (release is significantly faster
# for the math-heavy paths)
# - `cargo doc --no-deps` — `RUSTDOCFLAGS=-D warnings` catches
# broken doc links + missing docs
# errors before they ship to docs.rs
#
# Jobs are independent so a failure in one (e.g. fmt drift) doesn't gate
# the others — you see all the breakages in one CI run instead of
# discovering them serially.
#
# Concurrency: a new push to the same ref (branch / PR) cancels any
# in-flight run. Prevents redundant work when contributors push a fix
# while CI is still running on the prior commit.
#
# Permissions: read-only on `contents` — the workflow doesn't push, doesn't
# comment, doesn't deploy. Smallest blast radius.
#
# Caching: Swatinem/rust-cache@v2 keys on Cargo.lock + the runner image.
# First run on a PR pays the cold-compile cost; subsequent pushes to the
# same branch reuse the target/ + ~/.cargo state.
#
# Stable toolchain only. MSRV (`rust-version = "1.85"` in workspace
# Cargo.toml) is not enforced here — add a separate matrix job if MSRV
# regressions become a problem in practice.

name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1

jobs:
fmt:
name: cargo fmt --check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust stable + rustfmt
run: rustup toolchain install stable --component rustfmt --profile minimal --no-self-update
- run: cargo fmt --check

clippy:
name: cargo clippy -D warnings
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust stable + clippy
run: rustup toolchain install stable --component clippy --profile minimal --no-self-update
- name: Cache cargo
uses: Swatinem/rust-cache@v2
with:
shared-key: clippy
- run: cargo clippy --workspace --all-targets -- -D warnings

test:
name: cargo test --release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust stable
run: rustup toolchain install stable --profile minimal --no-self-update
- name: Cache cargo
uses: Swatinem/rust-cache@v2
with:
shared-key: test
# `--release` is faster than dev for the linear-algebra-heavy paths
# and matches the perf characteristics users will hit in practice.
- run: cargo test --workspace --release --lib --tests

doc:
name: cargo doc --no-deps
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust stable
run: rustup toolchain install stable --profile minimal --no-self-update
- name: Cache cargo
uses: Swatinem/rust-cache@v2
with:
shared-key: doc
# Builds docs without strict warnings — the workspace currently has
# several broken intra-doc links (`FerroError::*` variants, etc.)
# that should be cleaned up as a follow-up PR. Tighten to
# `RUSTDOCFLAGS: -D warnings` once those land. For now, this catches
# syntax-level doc errors that would break the docs.rs build.
- run: cargo doc --workspace --no-deps --document-private-items
8 changes: 4 additions & 4 deletions ferrolearn-bayes/src/categorical.rs
Original file line number Diff line number Diff line change
Expand Up @@ -305,8 +305,7 @@ impl<F: Float + Send + Sync + 'static> Fit<Array2<F>, Array1<usize>> for Categor
}

// Per-class, per-category count table.
let mut counts_for_feature: Vec<Vec<usize>> =
vec![vec![0usize; cats.len()]; n_classes];
let mut counts_for_feature: Vec<Vec<usize>> = vec![vec![0usize; cats.len()]; n_classes];
for (ci, indices) in class_indices.iter().enumerate() {
for &sample_idx in indices {
let val = x[[sample_idx, j]].to_usize().unwrap_or(0);
Expand All @@ -321,8 +320,7 @@ impl<F: Float + Send + Sync + 'static> Fit<Array2<F>, Array1<usize>> for Categor
}

// Cached log probabilities derived from counts.
let feature_log_prob =
recompute_feature_log_prob(&category_counts, &class_counts, alpha);
let feature_log_prob = recompute_feature_log_prob(&category_counts, &class_counts, alpha);

// Resolve priors.
let class_log_prior =
Expand All @@ -345,6 +343,7 @@ impl<F: Float + Send + Sync + 'static> Fit<Array2<F>, Array1<usize>> for Categor

/// Recompute `feature_log_prob[j][c][k]` from raw `category_counts` and
/// `class_counts`, using Laplace smoothing.
#[allow(clippy::needless_range_loop)] // matrix-style triple indexing reads cleaner than nested .iter().enumerate()
fn recompute_feature_log_prob<F: Float>(
category_counts: &[Vec<Vec<usize>>],
class_counts: &[usize],
Expand Down Expand Up @@ -374,6 +373,7 @@ fn recompute_feature_log_prob<F: Float>(

/// Resolve `class_log_prior` from `class_counts`, honoring an optional
/// explicit `class_prior` and the `fit_prior` flag.
#[allow(clippy::needless_range_loop)] // index-by-class is the natural loop here
fn resolve_class_log_prior<F: Float>(
class_counts: &[usize],
n_classes: usize,
Expand Down
5 changes: 1 addition & 4 deletions ferrolearn-bayes/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,7 @@ pub(crate) fn log_softmax_rows<F: Float>(jll: &Array2<F>) -> Array2<F> {
let n_classes = jll.ncols();
let mut log_proba = Array2::<F>::zeros((n_samples, n_classes));
for i in 0..n_samples {
let max_score = jll
.row(i)
.iter()
.fold(F::neg_infinity(), |a, &b| a.max(b));
let max_score = jll.row(i).iter().fold(F::neg_infinity(), |a, &b| a.max(b));
let mut sum_exp = F::zero();
for ci in 0..n_classes {
sum_exp = sum_exp + (jll[[i, ci]] - max_score).exp();
Expand Down
45 changes: 36 additions & 9 deletions ferrolearn-bayes/tests/api_proof.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,12 @@ fn assert_log_proba_consistent(log_proba: &Array2<f64>, proba: &Array2<f64>) {
let p = proba[[i, ci]];
// Avoid log(0) at boundaries.
if p > 1e-100 {
assert_relative_eq!(log_proba[[i, ci]], p.ln(), epsilon = 1e-9, max_relative = 1e-9);
assert_relative_eq!(
log_proba[[i, ci]],
p.ln(),
epsilon = 1e-9,
max_relative = 1e-9
);
}
}
}
Expand Down Expand Up @@ -347,16 +352,38 @@ fn api_proof_conjugate_normal_normal() {
// =============================================================================
#[test]
fn api_proof_f32_compiles() {
let x32 = Array2::from_shape_vec((4, 2), vec![1.0f32, 2.0, 1.0, 2.5, 5.0, 6.0, 5.5, 6.0]).unwrap();
let x32 =
Array2::from_shape_vec((4, 2), vec![1.0f32, 2.0, 1.0, 2.5, 5.0, 6.0, 5.5, 6.0]).unwrap();
let y = array![0usize, 0, 1, 1];

let _ = GaussianNB::<f32>::new().fit(&x32, &y).unwrap().predict(&x32).unwrap();
let _ = MultinomialNB::<f32>::new().fit(&x32, &y).unwrap().predict(&x32).unwrap();
let _ = BernoulliNB::<f32>::new().fit(&x32, &y).unwrap().predict(&x32).unwrap();
let _ = ComplementNB::<f32>::new().fit(&x32, &y).unwrap().predict(&x32).unwrap();

let x_cat = Array2::from_shape_vec((4, 2), vec![0.0f32, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0]).unwrap();
let _ = CategoricalNB::<f32>::new().fit(&x_cat, &y).unwrap().predict(&x_cat).unwrap();
let _ = GaussianNB::<f32>::new()
.fit(&x32, &y)
.unwrap()
.predict(&x32)
.unwrap();
let _ = MultinomialNB::<f32>::new()
.fit(&x32, &y)
.unwrap()
.predict(&x32)
.unwrap();
let _ = BernoulliNB::<f32>::new()
.fit(&x32, &y)
.unwrap()
.predict(&x32)
.unwrap();
let _ = ComplementNB::<f32>::new()
.fit(&x32, &y)
.unwrap()
.predict(&x32)
.unwrap();

let x_cat =
Array2::from_shape_vec((4, 2), vec![0.0f32, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0]).unwrap();
let _ = CategoricalNB::<f32>::new()
.fit(&x_cat, &y)
.unwrap()
.predict(&x_cat)
.unwrap();
}

// =============================================================================
Expand Down
15 changes: 8 additions & 7 deletions ferrolearn-bayes/tests/conformance_surface_coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
//! exclusion OR mentioned by leaf name in at least one test source file.
//! Modeled on ferrolearn-linear/tests/conformance_surface_coverage.rs.

use ferrolearn_test_oracle::{assert_surface_covered, SurfaceExclusions, SurfaceInventory};
use ferrolearn_test_oracle::{SurfaceExclusions, SurfaceInventory, assert_surface_covered};
use std::path::{Path, PathBuf};

fn crate_root() -> PathBuf {
Expand All @@ -18,8 +18,12 @@ fn test_dir() -> PathBuf {

#[test]
fn surface_coverage_gate() {
let inv_path = test_dir().join("conformance").join("_surface_inventory.toml");
let exc_path = test_dir().join("conformance").join("_surface_exclusions.toml");
let inv_path = test_dir()
.join("conformance")
.join("_surface_inventory.toml");
let exc_path = test_dir()
.join("conformance")
.join("_surface_exclusions.toml");
let inventory = SurfaceInventory::load(&inv_path);
let exclusions = SurfaceExclusions::load(&exc_path);
if inventory.items.is_empty() {
Expand All @@ -39,10 +43,7 @@ fn surface_coverage_gate() {
}
v
};
let paths: Vec<&Path> = candidate_test_files
.iter()
.map(PathBuf::as_path)
.collect();
let paths: Vec<&Path> = candidate_test_files.iter().map(PathBuf::as_path).collect();
assert!(
!paths.is_empty(),
"no .rs test files found under {}",
Expand Down
11 changes: 6 additions & 5 deletions ferrolearn-bayes/tests/conformance_wave4.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,11 @@ fn conformance_categorical_nb() {
.iter()
.map(|v| v.as_u64().unwrap() as usize)
.collect();
let matches = preds.iter().zip(expected.iter()).filter(|(a, e)| a == e).count();
let matches = preds
.iter()
.zip(expected.iter())
.filter(|(a, e)| a == e)
.count();
let acc = matches as f64 / preds.len() as f64;
assert!(
acc >= 0.95,
"CategoricalNB accuracy {acc:.4} < 0.95 floor"
);
assert!(acc >= 0.95, "CategoricalNB accuracy {acc:.4} < 0.95 floor");
}
5 changes: 2 additions & 3 deletions ferrolearn-bench/benches/kernel_methods.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
//! Kernel methods benchmarks: KernelRidge, GaussianProcess, Nystroem, RBFSampler.

use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
use ferrolearn_bench::{regression_data};
use ferrolearn_bench::regression_data;
use ferrolearn_core::{Fit, Predict, Transform};
use ferrolearn_kernel::{
GaussianProcessRegressor, KernelRidge, Nystroem, RBFSampler,
gp_kernels::RBFKernel,
GaussianProcessRegressor, KernelRidge, Nystroem, RBFSampler, gp_kernels::RBFKernel,
};

const KERNEL_SIZES: &[(&str, usize, usize)] = &[
Expand Down
Loading
Loading