use alloc::{vec, vec::Vec};
use core::fmt;
use slatec_core::to_fortran_integer;
use slatec_sys::FortranInteger;
use crate::callback_runtime::{
self, CallbackRuntimeError, ExpertLeastSquaresCallbackFailure, ExpertLeastSquaresF32Callback,
ExpertLeastSquaresF64Callback,
};
use crate::nonlinear::JacobianMut;
#[cfg(feature = "least-squares-nonlinear-expert")]
use super::ExpertLeastSquaresResult;
use super::LeastSquaresStatus;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CovarianceScaling {
Native,
ResidualVariance,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct CovarianceOptions {
pub scaling: CovarianceScaling,
}
impl Default for CovarianceOptions {
fn default() -> Self {
Self {
scaling: CovarianceScaling::ResidualVariance,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CovarianceEligibility {
ConvergedOnly,
AllowNumericalTermination,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CovarianceStatus {
FullRank,
}
#[derive(Clone, Debug, PartialEq)]
pub struct CovarianceResult<T = f64> {
pub covariance: Vec<T>,
pub parameter_count: usize,
pub rank: usize,
pub permutation: Vec<usize>,
pub residual_sum_of_squares: T,
pub variance_scale: T,
pub status: CovarianceStatus,
}
impl<T> CovarianceResult<T> {
pub fn get(&self, row: usize, column: usize) -> Option<&T> {
if row >= self.parameter_count || column >= self.parameter_count {
return None;
}
self.covariance
.get(row + column.checked_mul(self.parameter_count)?)
}
pub fn as_column_major_slice(&self) -> &[T] {
&self.covariance
}
}
impl CovarianceResult<f64> {
pub fn standard_errors(&self) -> Result<Vec<f64>, CovarianceError> {
standard_errors_f64(self)
}
pub fn correlation_matrix(&self) -> Result<Vec<f64>, CovarianceError> {
correlation_f64(self)
}
}
impl CovarianceResult<f32> {
pub fn standard_errors(&self) -> Result<Vec<f32>, CovarianceError> {
standard_errors_f32(self)
}
pub fn correlation_matrix(&self) -> Result<Vec<f32>, CovarianceError> {
correlation_f32(self)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum CovarianceError {
EmptyParameters,
EmptyResiduals,
Underdetermined {
residuals: usize,
parameters: usize,
},
NonFiniteParameter {
index: usize,
},
NonPositiveDegreesOfFreedom {
residuals: usize,
rank: usize,
},
CallbackPanicked,
CallbackReturnedNonFinite {
index: usize,
},
JacobianPanicked,
JacobianReturnedNonFinite {
row: usize,
column: usize,
},
NestedNativeCallback,
IntegerOverflow {
argument: &'static str,
},
WorkspaceOverflow,
RankDeficient {
parameter_count: usize,
},
IneligibleFitStatus {
status: LeastSquaresStatus,
},
NegativeVarianceDiagonal {
index: usize,
},
ZeroVarianceDiagonal {
index: usize,
},
NativeStatus {
status: i32,
},
NativeContractViolation {
detail: &'static str,
},
}
impl fmt::Display for CovarianceError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyParameters => write!(formatter, "covariance estimation needs parameters"),
Self::EmptyResiduals => write!(formatter, "covariance estimation needs residuals"),
Self::Underdetermined {
residuals,
parameters,
} => write!(
formatter,
"covariance estimation requires residual count {residuals} to be at least parameter count {parameters}"
),
Self::NonFiniteParameter { index } => write!(
formatter,
"covariance parameter at index {index} must be finite"
),
Self::NonPositiveDegreesOfFreedom { residuals, rank } => write!(
formatter,
"residual-variance covariance scaling needs positive degrees of freedom; residuals {residuals}, rank {rank}"
),
Self::CallbackPanicked => write!(formatter, "covariance residual callback panicked"),
Self::CallbackReturnedNonFinite { index } => write!(
formatter,
"covariance residual callback returned a non-finite value at index {index}"
),
Self::JacobianPanicked => write!(formatter, "covariance Jacobian callback panicked"),
Self::JacobianReturnedNonFinite { row, column } => write!(
formatter,
"covariance Jacobian callback left entry ({row}, {column}) non-finite or unwritten"
),
Self::NestedNativeCallback => write!(
formatter,
"nested callback-based SLATEC calls are unsupported"
),
Self::IntegerOverflow { argument } => write!(
formatter,
"covariance {argument} does not fit Fortran INTEGER"
),
Self::WorkspaceOverflow => {
write!(formatter, "covariance workspace-size arithmetic overflowed")
}
Self::RankDeficient { parameter_count } => write!(
formatter,
"native covariance factorization was singular for {parameter_count} parameters"
),
Self::IneligibleFitStatus { status } => write!(
formatter,
"expert least-squares status {status:?} is not eligible for covariance"
),
Self::NegativeVarianceDiagonal { index } => write!(
formatter,
"covariance diagonal at index {index} is negative or non-finite"
),
Self::ZeroVarianceDiagonal { index } => write!(
formatter,
"covariance diagonal at index {index} has zero variance"
),
Self::NativeStatus { status } => {
write!(formatter, "unknown covariance native status {status}")
}
Self::NativeContractViolation { detail } => {
write!(
formatter,
"native covariance contract was violated: {detail}"
)
}
}
}
}
impl std::error::Error for CovarianceError {}
fn native_integer(value: usize, argument: &'static str) -> Result<FortranInteger, CovarianceError> {
to_fortran_integer(value).map_err(|_| CovarianceError::IntegerOverflow { argument })
}
fn validate<T: Copy>(parameters: &[T], residual_count: usize) -> Result<(), CovarianceError> {
if parameters.is_empty() {
return Err(CovarianceError::EmptyParameters);
}
if residual_count == 0 {
return Err(CovarianceError::EmptyResiduals);
}
if residual_count < parameters.len() {
return Err(CovarianceError::Underdetermined {
residuals: residual_count,
parameters: parameters.len(),
});
}
Ok(())
}
fn validate_f64(
parameters: &[f64],
residual_count: usize,
options: CovarianceOptions,
) -> Result<(), CovarianceError> {
validate(parameters, residual_count)?;
if let Some((index, _)) = parameters
.iter()
.enumerate()
.find(|(_, value)| !value.is_finite())
{
return Err(CovarianceError::NonFiniteParameter { index });
}
validate_scaling(parameters.len(), residual_count, options)
}
fn validate_f32(
parameters: &[f32],
residual_count: usize,
options: CovarianceOptions,
) -> Result<(), CovarianceError> {
validate(parameters, residual_count)?;
if let Some((index, _)) = parameters
.iter()
.enumerate()
.find(|(_, value)| !value.is_finite())
{
return Err(CovarianceError::NonFiniteParameter { index });
}
validate_scaling(parameters.len(), residual_count, options)
}
fn validate_scaling(
parameter_count: usize,
residual_count: usize,
options: CovarianceOptions,
) -> Result<(), CovarianceError> {
if options.scaling == CovarianceScaling::ResidualVariance && residual_count <= parameter_count {
return Err(CovarianceError::NonPositiveDegreesOfFreedom {
residuals: residual_count,
rank: parameter_count,
});
}
Ok(())
}
fn callback_error(error: CallbackRuntimeError) -> CovarianceError {
match error {
CallbackRuntimeError::NestedCallback => CovarianceError::NestedNativeCallback,
}
}
fn callback_failure(failure: ExpertLeastSquaresCallbackFailure) -> CovarianceError {
match failure {
ExpertLeastSquaresCallbackFailure::ResidualPanicked => CovarianceError::CallbackPanicked,
ExpertLeastSquaresCallbackFailure::JacobianPanicked => CovarianceError::JacobianPanicked,
ExpertLeastSquaresCallbackFailure::ResidualNonFinite { index } => {
CovarianceError::CallbackReturnedNonFinite { index }
}
ExpertLeastSquaresCallbackFailure::JacobianNonFinite { row, column } => {
CovarianceError::JacobianReturnedNonFinite { row, column }
}
ExpertLeastSquaresCallbackFailure::InvalidPointer => {
CovarianceError::NativeContractViolation {
detail: "covariance callback pointer was null or its input/output regions overlapped",
}
}
ExpertLeastSquaresCallbackFailure::DimensionMismatch => {
CovarianceError::NativeContractViolation {
detail: "covariance callback M or N differed from its registered context",
}
}
ExpertLeastSquaresCallbackFailure::InvalidLeadingDimension => {
CovarianceError::NativeContractViolation {
detail: "covariance callback LDR was invalid",
}
}
ExpertLeastSquaresCallbackFailure::UnexpectedFlag => {
CovarianceError::NativeContractViolation {
detail: "covariance callback received an unsupported IFLAG",
}
}
}
}
fn residual_sum_of_squares_f64(values: &[f64]) -> Result<f64, CovarianceError> {
let mut scale = 0.0_f64;
let mut sum = 1.0_f64;
for value in values {
let magnitude = value.abs();
if magnitude != 0.0 {
if scale < magnitude {
sum = 1.0 + sum * (scale / magnitude).powi(2);
scale = magnitude;
} else {
sum += (magnitude / scale).powi(2);
}
}
}
let square = if scale == 0.0 {
0.0
} else {
scale * scale * sum
};
if square.is_finite() {
Ok(square)
} else {
Err(CovarianceError::NativeContractViolation {
detail: "finite residuals produced a residual sum of squares outside f64 range",
})
}
}
fn residual_sum_of_squares_f32(values: &[f32]) -> Result<f32, CovarianceError> {
let mut scale = 0.0_f32;
let mut sum = 1.0_f32;
for value in values {
let magnitude = value.abs();
if magnitude != 0.0 {
if scale < magnitude {
sum = 1.0 + sum * (scale / magnitude).powi(2);
scale = magnitude;
} else {
sum += (magnitude / scale).powi(2);
}
}
}
let square = if scale == 0.0 {
0.0
} else {
scale * scale * sum
};
if square.is_finite() {
Ok(square)
} else {
Err(CovarianceError::NativeContractViolation {
detail: "finite residuals produced a residual sum of squares outside f32 range",
})
}
}
fn expand_upper_f64(
workspace: &[f64],
leading_dimension: usize,
parameter_count: usize,
) -> Result<Vec<f64>, CovarianceError> {
let length = parameter_count
.checked_mul(parameter_count)
.ok_or(CovarianceError::WorkspaceOverflow)?;
let mut covariance = vec![0.0; length];
for column in 0..parameter_count {
for row in 0..=column {
let value = workspace[row + column * leading_dimension];
if !value.is_finite() {
return Err(CovarianceError::NativeContractViolation {
detail: "native DCOV returned a non-finite covariance entry",
});
}
covariance[row + column * parameter_count] = value;
covariance[column + row * parameter_count] = value;
}
}
Ok(covariance)
}
fn expand_upper_f32(
workspace: &[f32],
leading_dimension: usize,
parameter_count: usize,
) -> Result<Vec<f32>, CovarianceError> {
let length = parameter_count
.checked_mul(parameter_count)
.ok_or(CovarianceError::WorkspaceOverflow)?;
let mut covariance = vec![0.0; length];
for column in 0..parameter_count {
for row in 0..=column {
let value = workspace[row + column * leading_dimension];
if !value.is_finite() {
return Err(CovarianceError::NativeContractViolation {
detail: "native SCOV returned a non-finite covariance entry",
});
}
covariance[row + column * parameter_count] = value;
covariance[column + row * parameter_count] = value;
}
}
Ok(covariance)
}
#[allow(clippy::too_many_arguments)]
fn run_f64<F, J>(
parameters: &[f64],
residual_count: usize,
residuals: F,
mut jacobian: J,
options: CovarianceOptions,
analytic: bool,
) -> Result<CovarianceResult<f64>, CovarianceError>
where
F: FnMut(&[f64], &mut [f64]),
J: FnMut(&[f64], &[f64], JacobianMut<'_, f64>),
{
validate_f64(parameters, residual_count, options)?;
let parameter_count = parameters.len();
let mut native_parameters = parameters.to_vec();
let workspace_len = residual_count
.checked_mul(parameter_count)
.ok_or(CovarianceError::WorkspaceOverflow)?;
let mut residual_vector = vec![0.0; residual_count];
let mut covariance_workspace = vec![0.0; workspace_len];
let mut wa1 = vec![0.0; parameter_count];
let mut wa2 = vec![0.0; parameter_count];
let mut wa3 = vec![0.0; parameter_count];
let mut wa4 = vec![0.0; residual_count];
let mut iopt = if analytic { 2 } else { 1 };
let mut m = native_integer(residual_count, "residual count")?;
let mut n = native_integer(parameter_count, "parameter count")?;
let mut ldr = m;
let mut info = 0;
let invocation = callback_runtime::with_expert_least_squares_f64(
parameter_count,
residual_count,
analytic,
residuals,
move |x, fvec, matrix, leading_dimension| {
if let Some(view) =
JacobianMut::new(matrix, residual_count, parameter_count, leading_dimension)
{
jacobian(x, fvec, view);
}
},
|callback: ExpertLeastSquaresF64Callback| {
let _error_scope = crate::runtime::permit_recoverable_native_statuses();
unsafe {
slatec_sys::least_squares::dcov(
callback.ffi(),
&mut iopt,
&mut m,
&mut n,
native_parameters.as_mut_ptr(),
residual_vector.as_mut_ptr(),
covariance_workspace.as_mut_ptr(),
&mut ldr,
&mut info,
wa1.as_mut_ptr(),
wa2.as_mut_ptr(),
wa3.as_mut_ptr(),
wa4.as_mut_ptr(),
);
}
},
)
.map_err(callback_error)?;
if let Some(failure) = invocation.failure {
return Err(callback_failure(failure));
}
match info {
1 => {}
2 => return Err(CovarianceError::RankDeficient { parameter_count }),
0 => {
return Err(CovarianceError::NativeContractViolation {
detail: "DCOV rejected Rust-validated dimensions",
});
}
value => return Err(CovarianceError::NativeStatus { status: value }),
}
let rss = residual_sum_of_squares_f64(&residual_vector)?;
let variance_scale = if residual_count == parameter_count {
1.0
} else {
rss / (residual_count - parameter_count) as f64
};
if !variance_scale.is_finite() {
return Err(CovarianceError::NativeContractViolation {
detail: "native DCOV variance scale was non-finite",
});
}
Ok(CovarianceResult {
covariance: expand_upper_f64(&covariance_workspace, residual_count, parameter_count)?,
parameter_count,
rank: parameter_count,
permutation: (0..parameter_count).collect(),
residual_sum_of_squares: rss,
variance_scale,
status: CovarianceStatus::FullRank,
})
}
#[allow(clippy::too_many_arguments)]
fn run_f32<F, J>(
parameters: &[f32],
residual_count: usize,
residuals: F,
mut jacobian: J,
options: CovarianceOptions,
analytic: bool,
) -> Result<CovarianceResult<f32>, CovarianceError>
where
F: FnMut(&[f32], &mut [f32]),
J: FnMut(&[f32], &[f32], JacobianMut<'_, f32>),
{
validate_f32(parameters, residual_count, options)?;
let parameter_count = parameters.len();
let mut native_parameters = parameters.to_vec();
let workspace_len = residual_count
.checked_mul(parameter_count)
.ok_or(CovarianceError::WorkspaceOverflow)?;
let mut residual_vector = vec![0.0; residual_count];
let mut covariance_workspace = vec![0.0; workspace_len];
let mut wa1 = vec![0.0; parameter_count];
let mut wa2 = vec![0.0; parameter_count];
let mut wa3 = vec![0.0; parameter_count];
let mut wa4 = vec![0.0; residual_count];
let mut iopt = if analytic { 2 } else { 1 };
let mut m = native_integer(residual_count, "residual count")?;
let mut n = native_integer(parameter_count, "parameter count")?;
let mut ldr = m;
let mut info = 0;
let invocation = callback_runtime::with_expert_least_squares_f32(
parameter_count,
residual_count,
analytic,
residuals,
move |x, fvec, matrix, leading_dimension| {
if let Some(view) =
JacobianMut::new(matrix, residual_count, parameter_count, leading_dimension)
{
jacobian(x, fvec, view);
}
},
|callback: ExpertLeastSquaresF32Callback| {
let _error_scope = crate::runtime::permit_recoverable_native_statuses();
unsafe {
slatec_sys::least_squares::scov(
callback.ffi(),
&mut iopt,
&mut m,
&mut n,
native_parameters.as_mut_ptr(),
residual_vector.as_mut_ptr(),
covariance_workspace.as_mut_ptr(),
&mut ldr,
&mut info,
wa1.as_mut_ptr(),
wa2.as_mut_ptr(),
wa3.as_mut_ptr(),
wa4.as_mut_ptr(),
);
}
},
)
.map_err(callback_error)?;
if let Some(failure) = invocation.failure {
return Err(callback_failure(failure));
}
match info {
1 => {}
2 => return Err(CovarianceError::RankDeficient { parameter_count }),
0 => {
return Err(CovarianceError::NativeContractViolation {
detail: "SCOV rejected Rust-validated dimensions",
});
}
value => return Err(CovarianceError::NativeStatus { status: value }),
}
let rss = residual_sum_of_squares_f32(&residual_vector)?;
let variance_scale = if residual_count == parameter_count {
1.0
} else {
rss / (residual_count - parameter_count) as f32
};
if !variance_scale.is_finite() {
return Err(CovarianceError::NativeContractViolation {
detail: "native SCOV variance scale was non-finite",
});
}
Ok(CovarianceResult {
covariance: expand_upper_f32(&covariance_workspace, residual_count, parameter_count)?,
parameter_count,
rank: parameter_count,
permutation: (0..parameter_count).collect(),
residual_sum_of_squares: rss,
variance_scale,
status: CovarianceStatus::FullRank,
})
}
pub fn estimate_covariance<F, J>(
parameters: &[f64],
residual_count: usize,
residuals: F,
jacobian: J,
options: CovarianceOptions,
) -> Result<CovarianceResult<f64>, CovarianceError>
where
F: FnMut(&[f64], &mut [f64]),
J: FnMut(&[f64], &[f64], JacobianMut<'_, f64>),
{
run_f64(
parameters,
residual_count,
residuals,
jacobian,
options,
true,
)
}
pub fn estimate_covariance_f32<F, J>(
parameters: &[f32],
residual_count: usize,
residuals: F,
jacobian: J,
options: CovarianceOptions,
) -> Result<CovarianceResult<f32>, CovarianceError>
where
F: FnMut(&[f32], &mut [f32]),
J: FnMut(&[f32], &[f32], JacobianMut<'_, f32>),
{
run_f32(
parameters,
residual_count,
residuals,
jacobian,
options,
true,
)
}
pub fn estimate_covariance_finite_difference<F>(
parameters: &[f64],
residual_count: usize,
residuals: F,
options: CovarianceOptions,
) -> Result<CovarianceResult<f64>, CovarianceError>
where
F: FnMut(&[f64], &mut [f64]),
{
run_f64(
parameters,
residual_count,
residuals,
|_, _, _| {},
options,
false,
)
}
pub fn estimate_covariance_finite_difference_f32<F>(
parameters: &[f32],
residual_count: usize,
residuals: F,
options: CovarianceOptions,
) -> Result<CovarianceResult<f32>, CovarianceError>
where
F: FnMut(&[f32], &mut [f32]),
{
run_f32(
parameters,
residual_count,
residuals,
|_, _, _| {},
options,
false,
)
}
#[cfg(feature = "least-squares-nonlinear-expert")]
pub fn covariance_from_expert_fit<F, J>(
fit: &ExpertLeastSquaresResult<f64>,
residuals: F,
jacobian: J,
options: CovarianceOptions,
eligibility: CovarianceEligibility,
) -> Result<CovarianceResult<f64>, CovarianceError>
where
F: FnMut(&[f64], &mut [f64]),
J: FnMut(&[f64], &[f64], JacobianMut<'_, f64>),
{
validate_eligibility(fit.status, eligibility)?;
estimate_covariance(
&fit.parameters,
fit.residuals.len(),
residuals,
jacobian,
options,
)
}
#[cfg(feature = "least-squares-nonlinear-expert")]
pub fn covariance_from_expert_fit_f32<F, J>(
fit: &ExpertLeastSquaresResult<f32>,
residuals: F,
jacobian: J,
options: CovarianceOptions,
eligibility: CovarianceEligibility,
) -> Result<CovarianceResult<f32>, CovarianceError>
where
F: FnMut(&[f32], &mut [f32]),
J: FnMut(&[f32], &[f32], JacobianMut<'_, f32>),
{
validate_eligibility(fit.status, eligibility)?;
estimate_covariance_f32(
&fit.parameters,
fit.residuals.len(),
residuals,
jacobian,
options,
)
}
#[cfg(feature = "least-squares-nonlinear-expert")]
fn validate_eligibility(
status: LeastSquaresStatus,
eligibility: CovarianceEligibility,
) -> Result<(), CovarianceError> {
let accepted = matches!(
status,
LeastSquaresStatus::ConvergedResidual
| LeastSquaresStatus::ConvergedParameters
| LeastSquaresStatus::ConvergedResidualAndParameters
| LeastSquaresStatus::ConvergedOrthogonality
) || eligibility == CovarianceEligibility::AllowNumericalTermination;
if accepted {
Ok(())
} else {
Err(CovarianceError::IneligibleFitStatus { status })
}
}
fn standard_errors_f64(result: &CovarianceResult<f64>) -> Result<Vec<f64>, CovarianceError> {
let mut output = Vec::with_capacity(result.parameter_count);
for index in 0..result.parameter_count {
let value = *result
.get(index, index)
.expect("checked covariance diagonal");
if !value.is_finite() || value < 0.0 {
return Err(CovarianceError::NegativeVarianceDiagonal { index });
}
output.push(value.sqrt());
}
Ok(output)
}
fn standard_errors_f32(result: &CovarianceResult<f32>) -> Result<Vec<f32>, CovarianceError> {
let mut output = Vec::with_capacity(result.parameter_count);
for index in 0..result.parameter_count {
let value = *result
.get(index, index)
.expect("checked covariance diagonal");
if !value.is_finite() || value < 0.0 {
return Err(CovarianceError::NegativeVarianceDiagonal { index });
}
output.push(value.sqrt());
}
Ok(output)
}
fn correlation_f64(result: &CovarianceResult<f64>) -> Result<Vec<f64>, CovarianceError> {
let errors = standard_errors_f64(result)?;
let length = result
.parameter_count
.checked_mul(result.parameter_count)
.ok_or(CovarianceError::WorkspaceOverflow)?;
let mut output = vec![0.0; length];
for column in 0..result.parameter_count {
if errors[column] == 0.0 {
return Err(CovarianceError::ZeroVarianceDiagonal { index: column });
}
for row in 0..result.parameter_count {
if errors[row] == 0.0 {
return Err(CovarianceError::ZeroVarianceDiagonal { index: row });
}
output[row + column * result.parameter_count] = result.covariance
[row + column * result.parameter_count]
/ (errors[row] * errors[column]);
}
}
Ok(output)
}
fn correlation_f32(result: &CovarianceResult<f32>) -> Result<Vec<f32>, CovarianceError> {
let errors = standard_errors_f32(result)?;
let length = result
.parameter_count
.checked_mul(result.parameter_count)
.ok_or(CovarianceError::WorkspaceOverflow)?;
let mut output = vec![0.0; length];
for column in 0..result.parameter_count {
if errors[column] == 0.0 {
return Err(CovarianceError::ZeroVarianceDiagonal { index: column });
}
for row in 0..result.parameter_count {
if errors[row] == 0.0 {
return Err(CovarianceError::ZeroVarianceDiagonal { index: row });
}
output[row + column * result.parameter_count] = result.covariance
[row + column * result.parameter_count]
/ (errors[row] * errors[column]);
}
}
Ok(output)
}
#[cfg(test)]
mod tests {
use super::{CovarianceError, CovarianceOptions, CovarianceScaling, validate_scaling};
#[test]
fn residual_variance_requires_positive_degrees_of_freedom() {
assert!(matches!(
validate_scaling(2, 2, CovarianceOptions::default()),
Err(CovarianceError::NonPositiveDegreesOfFreedom { .. })
));
assert!(
validate_scaling(
2,
2,
CovarianceOptions {
scaling: CovarianceScaling::Native
}
)
.is_ok()
);
}
}