use numr::autograd::DualTensor;
use numr::dtype::DType;
use numr::error::Result;
use numr::ops::{ScalarOps, TensorOps};
use numr::runtime::{Runtime, RuntimeClient};
use numr::sparse::{CsrData, SparseStorage};
use numr::tensor::Tensor;
use crate::integrate::impl_generic::ode::jacobian::compute_jacobian_autograd;
const STRUCTURAL_ZERO_THRESHOLD: f64 = 1e-14;
const PROBE_EPS: f64 = 1e-4;
pub fn detect_jacobian_sparsity<R, C, F>(
client: &C,
f: &F,
t0: &Tensor<R>,
y0: &Tensor<R>,
) -> Result<CsrData<R>>
where
R: Runtime<DType = DType>,
C: TensorOps<R> + ScalarOps<R> + RuntimeClient<R>,
F: Fn(&DualTensor<R>, &DualTensor<R>, &C) -> Result<DualTensor<R>>,
{
let n = y0.shape()[0];
if n == 0 {
return Err(numr::error::Error::InvalidArgument {
arg: "y0",
reason: "State vector must be non-empty for sparsity detection".to_string(),
});
}
let j0 = compute_jacobian_autograd(client, f, t0, y0)?;
let abs_y0 = client.abs(y0)?;
let abs_plus_eps = client.add_scalar(&abs_y0, PROBE_EPS)?;
let perturbation = client.mul_scalar(&abs_plus_eps, PROBE_EPS)?;
let y_perturbed = client.add(y0, &perturbation)?;
let j1 = compute_jacobian_autograd(client, f, t0, &y_perturbed)?;
let abs_j0 = client.abs(&j0)?;
let abs_j1 = client.abs(&j1)?;
let merged = client.maximum(&abs_j0, &abs_j1)?;
let merged_data: Vec<f64> = merged.to_vec();
let mut row_ptrs: Vec<i64> = Vec::with_capacity(n + 1);
let mut col_indices: Vec<i64> = Vec::new();
let mut values: Vec<f64> = Vec::new();
row_ptrs.push(0);
for row in 0..n {
for col in 0..n {
if merged_data[row * n + col] > STRUCTURAL_ZERO_THRESHOLD {
col_indices.push(col as i64);
values.push(1.0_f64);
}
}
row_ptrs.push(col_indices.len() as i64);
}
let nnz = values.len();
if nnz == 0 {
return Err(numr::error::Error::Internal(
"Sparsity detection found no nonzero entries in the Jacobian; \
the ODE right-hand side may be identically zero or the system \
is decoupled in unexpected ways."
.to_string(),
));
}
CsrData::<R>::from_slices::<f64>(&row_ptrs, &col_indices, &values, [n, n], client.device())
}
pub fn sparsity_ratio(pattern: &CsrData<impl Runtime<DType = DType>>) -> f64 {
let shape = pattern.shape();
let n_sq = shape[0] * shape[1];
if n_sq == 0 {
return 0.0;
}
let nnz = pattern.nnz();
nnz as f64 / n_sq as f64
}
#[cfg(all(test, feature = "sparse"))]
mod tests {
use super::*;
use numr::autograd::dual_ops::{dual_add, dual_mul_scalar};
use numr::runtime::cpu::{CpuClient, CpuDevice, CpuRuntime};
fn setup() -> (CpuDevice, CpuClient) {
let device = CpuDevice::new();
let client = CpuClient::new(device.clone());
(device, client)
}
#[test]
fn test_detect_diagonal_jacobian() {
let (device, client) = setup();
let t0 = Tensor::<CpuRuntime>::from_slice(&[0.0_f64], &[1], &device);
let y0 = Tensor::<CpuRuntime>::from_slice(&[1.0_f64, 2.0, 3.0], &[3], &device);
let f = |_t: &DualTensor<CpuRuntime>,
y: &DualTensor<CpuRuntime>,
c: &CpuClient|
-> Result<DualTensor<CpuRuntime>> {
dual_mul_scalar(y, -1.0, c)
};
let pattern = detect_jacobian_sparsity(&client, &f, &t0, &y0).unwrap();
let shape = pattern.shape();
assert_eq!(shape[0], 3, "pattern should be 3×3");
assert_eq!(shape[1], 3, "pattern should be 3×3");
let nnz = pattern.nnz();
assert_eq!(nnz, 3, "diagonal Jacobian should have exactly 3 nonzeros");
let ratio = sparsity_ratio(&pattern);
assert!((ratio - 1.0 / 3.0).abs() < 1e-10, "ratio = {}", ratio);
}
#[test]
fn test_detect_full_coupling_jacobian() {
let (device, client) = setup();
let n = 4_usize;
let t0 = Tensor::<CpuRuntime>::from_slice(&[0.0_f64], &[1], &device);
let y0 = Tensor::<CpuRuntime>::from_slice(&[1.0_f64, 2.0, 3.0, 4.0], &[n], &device);
let f = |_t: &DualTensor<CpuRuntime>,
y: &DualTensor<CpuRuntime>,
c: &CpuClient|
-> Result<DualTensor<CpuRuntime>> {
let neg_y = dual_mul_scalar(y, -1.0, c)?;
Ok(neg_y)
};
let pattern = detect_jacobian_sparsity(&client, &f, &t0, &y0).unwrap();
assert_eq!(
pattern.nnz(),
n,
"diagonal system should produce {} nonzeros",
n
);
}
#[test]
fn test_no_spurious_nonzeros() {
let (device, client) = setup();
let t0 = Tensor::<CpuRuntime>::from_slice(&[0.0_f64], &[1], &device);
let y0 = Tensor::<CpuRuntime>::from_slice(&[1.0_f64, 0.5, -0.3], &[3], &device);
let f = |_t: &DualTensor<CpuRuntime>,
y: &DualTensor<CpuRuntime>,
c: &CpuClient|
-> Result<DualTensor<CpuRuntime>> {
let y2 = dual_add(y, y, c)?;
Ok(y2)
};
let pattern = detect_jacobian_sparsity(&client, &f, &t0, &y0).unwrap();
assert_eq!(
pattern.nnz(),
3,
"2*y should give diagonal Jacobian with nnz=3"
);
}
#[test]
fn test_pattern_validity() {
let (device, client) = setup();
let t0 = Tensor::<CpuRuntime>::from_slice(&[0.0_f64], &[1], &device);
let y0 = Tensor::<CpuRuntime>::from_slice(&[1.0_f64, -1.0, 2.0, 0.5], &[4], &device);
let f = |_t: &DualTensor<CpuRuntime>,
y: &DualTensor<CpuRuntime>,
c: &CpuClient|
-> Result<DualTensor<CpuRuntime>> { dual_mul_scalar(y, -3.0, c) };
let pattern = detect_jacobian_sparsity(&client, &f, &t0, &y0).unwrap();
let shape = pattern.shape();
assert_eq!(shape[0], 4);
assert_eq!(shape[1], 4);
let row_ptrs: Vec<i64> = pattern.row_ptrs().to_vec();
assert_eq!(row_ptrs.len(), 5, "row_ptrs length = n+1");
assert_eq!(row_ptrs[0], 0);
assert_eq!(row_ptrs[4], pattern.nnz() as i64);
for w in row_ptrs.windows(2) {
assert!(w[1] >= w[0], "row_ptrs must be non-decreasing");
}
let col_idx: Vec<i64> = pattern.col_indices().to_vec();
for &c in &col_idx {
assert!(c >= 0 && (c as usize) < 4, "col index out of range: {}", c);
}
}
#[test]
fn test_bdf_with_auto_detected_sparsity() {
use crate::integrate::impl_generic::ode::bdf::bdf_impl;
use crate::integrate::ode::{BDFOptions, ODEOptions, SparseJacobianConfig};
let (device, client) = setup();
let y0 = Tensor::<CpuRuntime>::from_slice(&[1.0_f64, 0.5], &[2], &device);
let f = |_t: &DualTensor<CpuRuntime>,
y: &DualTensor<CpuRuntime>,
c: &CpuClient|
-> Result<DualTensor<CpuRuntime>> { dual_mul_scalar(y, -100.0, c) };
let sparse_config = SparseJacobianConfig::<CpuRuntime>::enabled();
assert!(
sparse_config.pattern.is_none(),
"pattern should start as None"
);
let bdf_opts = BDFOptions::default().with_sparse_jacobian(sparse_config);
let result = bdf_impl(
&client,
f,
[0.0, 0.1],
&y0,
&ODEOptions::default(),
&bdf_opts,
)
.expect("BDF with auto-detected sparsity should succeed");
assert!(result.success, "integration should succeed");
let y_final = result.y_final_vec();
let expected0 = 1.0_f64 * (-100.0_f64 * 0.1).exp();
let expected1 = 0.5_f64 * (-100.0_f64 * 0.1).exp();
assert!(
(y_final[0] - expected0).abs() < 1e-4,
"y[0] expected {:.8}, got {:.8}",
expected0,
y_final[0]
);
assert!(
(y_final[1] - expected1).abs() < 1e-4,
"y[1] expected {:.8}, got {:.8}",
expected1,
y_final[1]
);
}
}