use std::sync::Arc;
use ndarray::{Array1, Array2, Array3};
use crate::families::custom_family::{
BlockEffectiveJacobian, CustomFamilyError, FamilyLinearizationState, ParameterBlockSpec,
PenaltyMatrix,
};
use crate::families::identifiability_compiler::{IdentityRowHessian, RowJacobianOperator};
use crate::linalg::faer_ndarray::{default_rrqr_rank_alpha, rrqr_with_permutation};
use crate::linalg::matrix::{CoefficientTransformOperator, DenseDesignMatrix, DesignMatrix};
use crate::solver::identifiability_audit::{
IdentifiabilityAudit, audit_identifiability, audit_identifiability_channel_aware,
};
struct BlockJacobianAsRowOp {
jac: Array3<f64>,
}
impl BlockJacobianAsRowOp {
fn from_callback(
cb: &dyn BlockEffectiveJacobian,
n_rows: usize,
block_name: &str,
) -> Result<Self, String> {
let k = cb.n_outputs();
let zeros: Vec<f64> = Vec::new();
let state = FamilyLinearizationState {
beta: &zeros,
family_scalars: None,
channel_hessian: None,
probit_frailty_scale: 1.0,
};
let stacked = cb
.effective_jacobian_at(&state)
.map_err(|e| format!("BlockJacobianAsRowOp block '{block_name}': {e}"))?;
let nk = stacked.nrows();
let p_block = stacked.ncols();
if k == 0 {
return Err(format!(
"BlockJacobianAsRowOp block '{block_name}': n_outputs=0 is invalid"
));
}
if nk != n_rows * k {
return Err(format!(
"BlockJacobianAsRowOp block '{block_name}': effective_jacobian_at returned \
{} rows but expected n_rows({n_rows}) * k({k}) = {}",
nk,
n_rows * k,
));
}
let mut jac = Array3::<f64>::zeros((n_rows, p_block, k));
for i in 0..n_rows {
for j in 0..p_block {
for r in 0..k {
jac[[i, j, r]] = stacked[[i * k + r, j]];
}
}
}
Ok(Self { jac })
}
}
impl RowJacobianOperator for BlockJacobianAsRowOp {
fn k(&self) -> usize {
self.jac.shape()[2]
}
fn ncols(&self) -> usize {
self.jac.shape()[1]
}
fn nrows(&self) -> usize {
self.jac.shape()[0]
}
fn apply_row(&self, row: usize, delta_beta: &[f64], out: &mut [f64]) {
let k = self.k();
assert_eq!(out.len(), k);
assert_eq!(delta_beta.len(), self.ncols());
for r in 0..k {
out[r] = 0.0;
}
for (j, &b) in delta_beta.iter().enumerate() {
for r in 0..k {
out[r] += self.jac[[row, j, r]] * b;
}
}
}
fn evaluate_full(&self) -> Array3<f64> {
self.jac.clone()
}
}
#[derive(Debug)]
pub struct CanonicalSpecs {
pub reduced_specs: Vec<ParameterBlockSpec>,
pub per_block_transform: Vec<Array2<f64>>,
pub audit: IdentifiabilityAudit,
pub used_channel_aware_audit: bool,
}
impl CanonicalSpecs {
pub fn lift_block_betas_to_raw(&self, theta_blocks: &[Array1<f64>]) -> Vec<Array1<f64>> {
assert_eq!(
theta_blocks.len(),
self.per_block_transform.len(),
"lift_block_betas_to_raw: theta blocks ({}) != transforms ({})",
theta_blocks.len(),
self.per_block_transform.len(),
);
let mut out = Vec::with_capacity(theta_blocks.len());
for (theta, transform) in theta_blocks.iter().zip(self.per_block_transform.iter()) {
assert_eq!(
theta.len(),
transform.ncols(),
"lift_block_betas_to_raw: theta length {} != transform ncols {}",
theta.len(),
transform.ncols(),
);
out.push(transform.dot(theta));
}
out
}
pub fn raw_block_dims(&self) -> Vec<usize> {
self.per_block_transform.iter().map(|t| t.nrows()).collect()
}
pub fn reduced_block_dims(&self) -> Vec<usize> {
self.per_block_transform.iter().map(|t| t.ncols()).collect()
}
pub fn lift_joint_matrix_to_raw(&self, m_red: &Array2<f64>) -> Array2<f64> {
let raw_dims = self.raw_block_dims();
let red_dims = self.reduced_block_dims();
let total_p: usize = raw_dims.iter().sum();
let total_r: usize = red_dims.iter().sum();
assert_eq!(
m_red.nrows(),
total_r,
"lift_joint_matrix_to_raw: m_red rows {} != total reduced dim {}",
m_red.nrows(),
total_r,
);
assert_eq!(
m_red.ncols(),
total_r,
"lift_joint_matrix_to_raw: m_red cols {} != total reduced dim {}",
m_red.ncols(),
total_r,
);
let mut out = Array2::<f64>::zeros((total_p, total_p));
let mut raw_off_i = 0usize;
let mut red_off_i = 0usize;
for (i, t_i) in self.per_block_transform.iter().enumerate() {
let p_i = raw_dims[i];
let r_i = red_dims[i];
let mut raw_off_j = 0usize;
let mut red_off_j = 0usize;
for (j, t_j) in self.per_block_transform.iter().enumerate() {
let p_j = raw_dims[j];
let r_j = red_dims[j];
if r_i > 0 && r_j > 0 {
let m_ij = m_red.slice(ndarray::s![
red_off_i..red_off_i + r_i,
red_off_j..red_off_j + r_j
]);
let lifted = t_i.dot(&m_ij).dot(&t_j.t());
out.slice_mut(ndarray::s![
raw_off_i..raw_off_i + p_i,
raw_off_j..raw_off_j + p_j
])
.assign(&lifted);
}
raw_off_j += p_j;
red_off_j += r_j;
}
raw_off_i += p_i;
red_off_i += r_i;
}
out
}
}
pub fn canonicalize_for_identifiability(
specs: &[ParameterBlockSpec],
) -> Result<CanonicalSpecs, CustomFamilyError> {
if specs.is_empty() {
return Ok(CanonicalSpecs {
reduced_specs: Vec::new(),
per_block_transform: Vec::new(),
audit: audit_identifiability(specs).map_err(|r| {
CustomFamilyError::DimensionMismatch {
reason: format!("pre-fit identifiability audit failed: {r}"),
}
})?,
used_channel_aware_audit: false,
});
}
let n_rows = specs[0].design.nrows();
let max_n_outputs = specs
.iter()
.map(|s| {
s.jacobian_callback
.as_ref()
.map(|cb| cb.n_outputs())
.unwrap_or(1)
})
.max()
.unwrap_or(1);
let use_channel_aware = max_n_outputs > 1;
log::debug!(
"[CANON] canonicalize_for_identifiability: blocks={} n_rows={} \
max_n_outputs={} route={}",
specs.len(),
n_rows,
max_n_outputs,
if use_channel_aware {
"channel-aware"
} else {
"flat"
},
);
for spec in specs.iter() {
let jac_nrows = if use_channel_aware {
let k = spec
.jacobian_callback
.as_ref()
.map(|cb| cb.n_outputs())
.unwrap_or(1);
n_rows * k
} else {
n_rows
};
let frob_sq: f64 = {
let p = spec.design.ncols();
let zeros = vec![0.0f64; p];
let state = FamilyLinearizationState {
beta: &zeros,
family_scalars: None,
channel_hessian: None,
probit_frailty_scale: 1.0,
};
match spec.effective_jacobian_at("canonicalize_for_identifiability", &state) {
Ok(j) => j.iter().map(|v| v * v).sum(),
Err(e) => {
log::debug!(
"[CANON] block '{}': effective_jacobian_at probe failed: {e}",
spec.name,
);
f64::NAN
}
}
};
log::debug!(
"[CANON] block '{}': p={} jac_nrows={} frob_norm={:.4e}",
spec.name,
spec.design.ncols(),
jac_nrows,
frob_sq.sqrt(),
);
}
let audit = if use_channel_aware {
let k = max_n_outputs;
let mut operators: Vec<Arc<dyn RowJacobianOperator>> = Vec::with_capacity(specs.len());
for spec in specs.iter() {
let op: Arc<dyn RowJacobianOperator> = match spec.jacobian_callback.as_ref() {
Some(cb) if cb.n_outputs() == k => {
let row_op =
BlockJacobianAsRowOp::from_callback(cb.as_ref(), n_rows, &spec.name)
.map_err(|e| CustomFamilyError::DimensionMismatch {
reason: format!(
"canonicalize_for_identifiability: build \
BlockJacobianAsRowOp for block '{}': {e}",
spec.name,
),
})?;
Arc::new(row_op)
}
Some(cb) => {
let k_block = cb.n_outputs();
let row_op =
BlockJacobianAsRowOp::from_callback(cb.as_ref(), n_rows, &spec.name)
.map_err(|e| CustomFamilyError::DimensionMismatch {
reason: format!(
"canonicalize_for_identifiability: build \
BlockJacobianAsRowOp (k_mismatch) for block '{}': {e}",
spec.name,
),
})?;
let mut jac_ext = Array3::<f64>::zeros((row_op.nrows(), row_op.ncols(), k));
let jac_inner = row_op.evaluate_full();
for i in 0..row_op.nrows() {
for j in 0..row_op.ncols() {
for r in 0..k_block {
jac_ext[[i, j, r]] = jac_inner[[i, j, r]];
}
}
}
Arc::new(BlockJacobianAsRowOp { jac: jac_ext })
}
None => {
let p = spec.design.ncols();
let zeros = vec![0.0f64; p];
let state = FamilyLinearizationState {
beta: &zeros,
family_scalars: None,
channel_hessian: None,
probit_frailty_scale: 1.0,
};
let flat = spec
.effective_jacobian_at("canonicalize_for_identifiability", &state)
.map_err(|e| CustomFamilyError::DimensionMismatch {
reason: format!(
"canonicalize_for_identifiability: effective_jacobian_at \
for block '{}': {e}",
spec.name,
),
})?;
let mut jac_ext = Array3::<f64>::zeros((n_rows, flat.ncols(), k));
for i in 0..n_rows {
for j in 0..flat.ncols() {
jac_ext[[i, j, 0]] = flat[[i, j]];
}
}
Arc::new(BlockJacobianAsRowOp { jac: jac_ext })
}
};
operators.push(op);
}
let row_hess = IdentityRowHessian::new(n_rows, k);
let audit_result = audit_identifiability_channel_aware(specs, &operators, &row_hess)
.map_err(|reason| CustomFamilyError::DimensionMismatch {
reason: format!("pre-fit channel-aware identifiability audit failed: {reason}"),
})?;
log::info!(
"[CANON] channel-aware audit: {} blocks, joint_rank={}/{} (flat audit NOT used)",
specs.len(),
audit_result
.blocks
.iter()
.map(|b| b.effective_dim)
.sum::<usize>(),
specs.iter().map(|s| s.design.ncols()).sum::<usize>(),
);
audit_result
} else {
let audit_result = audit_identifiability(specs).map_err(|reason| {
CustomFamilyError::DimensionMismatch {
reason: format!("pre-fit identifiability audit failed: {reason}"),
}
})?;
log::debug!(
"[CANON] flat audit: {} blocks, joint_rank={}",
specs.len(),
audit_result
.blocks
.iter()
.map(|b| b.effective_dim)
.sum::<usize>(),
);
audit_result
};
if audit.fatal {
return Err(CustomFamilyError::IdentifiabilityFailure { audit });
}
let mut per_block_transform: Vec<Array2<f64>> = Vec::with_capacity(specs.len());
let mut reduced_specs: Vec<ParameterBlockSpec> = Vec::with_capacity(specs.len());
for spec in specs.iter() {
let p_raw = spec.design.ncols();
let dropped_locals: Vec<usize> = audit
.dropped_columns
.iter()
.filter(|drop| drop.block == spec.name)
.map(|drop| drop.column)
.collect();
let mut dropped_sorted = dropped_locals.clone();
dropped_sorted.sort_unstable();
dropped_sorted.dedup();
for &col in &dropped_sorted {
if col >= p_raw {
crate::bail_dim_custom!(
"canonicalize_for_identifiability: audit reported dropped column \
{col} for block '{}' which has only {} columns",
spec.name,
p_raw,
);
}
}
let kept: Vec<usize> = (0..p_raw)
.filter(|c| dropped_sorted.binary_search(c).is_err())
.collect();
let r_block = kept.len();
let mut t_i = Array2::<f64>::zeros((p_raw, r_block));
for (col_out, &raw_col) in kept.iter().enumerate() {
t_i[[raw_col, col_out]] = 1.0;
}
let reduced_design = if dropped_sorted.is_empty() {
spec.design.clone()
} else {
build_reduced_design(&spec.design, &kept, &spec.name, &t_i)?
};
let reduced_stacked_design: Option<DesignMatrix> = match spec.stacked_design.as_ref() {
Some(stacked) if !dropped_sorted.is_empty() => {
Some(build_reduced_design(stacked, &kept, &spec.name, &t_i)?)
}
Some(stacked) => Some(stacked.clone()),
None => None,
};
let reduced_stacked_offset = spec.stacked_offset.clone();
let reduced_penalties: Vec<PenaltyMatrix> = spec
.penalties
.iter()
.map(|p| pull_back_penalty(p, &kept))
.collect();
let reduced_initial_beta = match &spec.initial_beta {
Some(beta_raw) => {
if beta_raw.len() != p_raw {
crate::bail_dim_custom!(
"canonicalize_for_identifiability: block '{}' initial_beta \
length {} != design ncols {}",
spec.name,
beta_raw.len(),
p_raw,
);
}
let mut theta = Array1::<f64>::zeros(r_block);
for (out_idx, &raw_col) in kept.iter().enumerate() {
theta[out_idx] = beta_raw[raw_col];
}
Some(theta)
}
None => None,
};
reduced_specs.push(ParameterBlockSpec {
name: spec.name.clone(),
design: reduced_design,
offset: spec.offset.clone(),
penalties: reduced_penalties,
nullspace_dims: Vec::new(),
initial_log_lambdas: spec.initial_log_lambdas.clone(),
initial_beta: reduced_initial_beta,
gauge_priority: spec.gauge_priority,
jacobian_callback: spec.jacobian_callback.clone(),
stacked_design: reduced_stacked_design,
stacked_offset: reduced_stacked_offset,
});
per_block_transform.push(t_i);
}
{
let p_total_raw: usize = specs.iter().map(|s| s.design.ncols()).sum();
let p_total_red: usize = per_block_transform.iter().map(|t| t.ncols()).sum();
let k = if use_channel_aware { max_n_outputs } else { 1 };
let nk = n_rows * k;
let mut j_pre = Array2::<f64>::zeros((nk, p_total_raw));
let mut col_off = 0usize;
for spec in specs.iter() {
let p_b = spec.design.ncols();
let zeros = vec![0.0f64; p_b];
let state = FamilyLinearizationState {
beta: &zeros,
family_scalars: None,
channel_hessian: None,
probit_frailty_scale: 1.0,
};
match spec.effective_jacobian_at("canonicalize_rank_check", &state) {
Ok(j_b) => {
let k_b = j_b.nrows() / n_rows;
for i in 0..n_rows {
for j in 0..p_b {
for r in 0..k_b.min(k) {
j_pre[[i * k + r, col_off + j]] = j_b[[i * k_b + r, j]];
}
}
}
}
Err(_) => {
if let Ok(flat) = spec
.design
.try_to_dense_arc("canonicalize_rank_check")
.map(|a| a.as_ref().clone())
{
for i in 0..n_rows.min(flat.nrows()) {
for j in 0..p_b.min(flat.ncols()) {
j_pre[[i * k, col_off + j]] = flat[[i, j]];
}
}
}
}
}
col_off += p_b;
}
let mut j_can = Array2::<f64>::zeros((nk, p_total_red));
let mut raw_col_off = 0usize;
let mut red_col_off = 0usize;
for t_i in per_block_transform.iter() {
let p_i = t_i.nrows();
let r_i = t_i.ncols();
if p_i > 0 && r_i > 0 {
for row in 0..nk {
for out_col in 0..r_i {
let mut acc = 0.0_f64;
for in_col in 0..p_i {
acc += j_pre[[row, raw_col_off + in_col]] * t_i[[in_col, out_col]];
}
j_can[[row, red_col_off + out_col]] = acc;
}
}
}
raw_col_off += p_i;
red_col_off += r_i;
}
let rank_j_pre = rrqr_with_permutation(&j_pre, default_rrqr_rank_alpha())
.map(|r| r.rank)
.unwrap_or(0);
let rank_j_can = rrqr_with_permutation(&j_can, default_rrqr_rank_alpha())
.map(|r| r.rank)
.unwrap_or(0);
log::info!(
"[CANON] post-T invariant: rank(J)={rank_j_pre} rank(J_can)={rank_j_can} \
(p_raw={p_total_raw} p_red={p_total_red} k={k})",
);
if rank_j_pre != rank_j_can {
let block_shapes: Vec<String> = per_block_transform
.iter()
.zip(specs.iter())
.map(|(t, s)| format!("{}:({},{})", s.name, t.nrows(), t.ncols()))
.collect();
return Err(CustomFamilyError::DimensionMismatch {
reason: format!(
"canonicalize_for_identifiability: post-T rank invariant violated — \
rank(J)={rank_j_pre} but rank(J_can)={rank_j_can} \
(p_raw={p_total_raw} p_red={p_total_red} k={k}); \
this is a bug in T construction; per-block T shapes: [{}]",
block_shapes.join(", "),
),
});
}
if p_total_red > 0 {
let mut s_joint = Array2::<f64>::zeros((p_total_red, p_total_red));
let mut red_off = 0usize;
for spec in reduced_specs.iter() {
let r_i = spec.design.ncols();
for pen in spec.penalties.iter() {
let s_dense = pen.as_dense_cow();
if s_dense.nrows() == r_i && s_dense.ncols() == r_i {
for ii in 0..r_i {
for jj in 0..r_i {
s_joint[[red_off + ii, red_off + jj]] += s_dense[[ii, jj]];
}
}
}
}
red_off += r_i;
}
let mut red_col_offsets: Vec<usize> = Vec::with_capacity(reduced_specs.len() + 1);
red_col_offsets.push(0);
for spec in reduced_specs.iter() {
let prev = *red_col_offsets.last().unwrap();
red_col_offsets.push(prev + spec.design.ncols());
}
crate::solver::identifiability_audit::check_map_uniqueness(
&j_can,
&[],
&s_joint,
&reduced_specs,
&red_col_offsets,
)
.map_err(|error| {
log::warn!("[CANON] MAP uniqueness check failed: {}", error.message,);
CustomFamilyError::MapUniquenessFailure { error }
})?;
log::debug!(
"[CANON] MAP uniqueness check passed \
(p_red={p_total_red} penalty_blocks={})",
reduced_specs
.iter()
.map(|s| s.penalties.len())
.sum::<usize>(),
);
}
}
Ok(CanonicalSpecs {
reduced_specs,
per_block_transform,
audit,
used_channel_aware_audit: use_channel_aware,
})
}
fn build_reduced_design(
raw: &DesignMatrix,
kept: &[usize],
block_name: &str,
t_i: &Array2<f64>,
) -> Result<DesignMatrix, CustomFamilyError> {
let inner_dense = match raw {
DesignMatrix::Dense(d) => d.clone(),
DesignMatrix::Sparse(_) => {
let dense = raw
.try_to_dense_by_chunks(&format!(
"canonicalize_for_identifiability sparse->dense block '{block_name}'"
))
.map_err(|reason| CustomFamilyError::DimensionMismatch {
reason: format!(
"canonicalize_for_identifiability: densify sparse block '{block_name}' \
failed: {reason}"
),
})?;
DenseDesignMatrix::from(dense)
}
};
if let Some(arr) = inner_dense.as_dense_ref() {
let reduced =
Array2::<f64>::from_shape_fn((arr.nrows(), kept.len()), |(i, j)| arr[[i, kept[j]]]);
return Ok(DesignMatrix::Dense(DenseDesignMatrix::from(reduced)));
}
let op = CoefficientTransformOperator::new(inner_dense, t_i.clone()).map_err(|reason| {
CustomFamilyError::DimensionMismatch {
reason: format!(
"canonicalize_for_identifiability: build CoefficientTransformOperator \
for block '{block_name}': {reason}"
),
}
})?;
Ok(DesignMatrix::Dense(DenseDesignMatrix::from(Arc::new(op))))
}
fn pull_back_penalty(penalty: &PenaltyMatrix, kept: &[usize]) -> PenaltyMatrix {
let label = penalty.precision_label().map(|s| s.to_string());
let dense = penalty.as_dense_cow();
let reduced =
Array2::<f64>::from_shape_fn((kept.len(), kept.len()), |(i, j)| dense[[kept[i], kept[j]]]);
let base = PenaltyMatrix::Dense(reduced);
match label {
Some(lbl) => base.with_precision_label(lbl),
None => base,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::linalg::matrix::DenseDesignMatrix;
use ndarray::Array2;
fn spec_from_dense(name: &str, design: Array2<f64>) -> ParameterBlockSpec {
let n = design.nrows();
ParameterBlockSpec {
name: name.to_string(),
design: DesignMatrix::Dense(DenseDesignMatrix::from(design)),
offset: Array1::<f64>::zeros(n),
penalties: Vec::new(),
nullspace_dims: Vec::new(),
initial_log_lambdas: Array1::<f64>::zeros(0),
initial_beta: None,
gauge_priority: 100,
jacobian_callback: None,
stacked_design: None,
stacked_offset: None,
}
}
fn linspace(n: usize) -> Array1<f64> {
if n <= 1 {
return Array1::<f64>::zeros(n.max(1));
}
let step = 2.0 / (n as f64 - 1.0);
Array1::from_iter((0..n).map(|i| -1.0 + step * i as f64))
}
#[test]
fn canonical_clean_specs_identity_transform() {
let n = 32;
let x = linspace(n);
let mut p = Array2::<f64>::zeros((n, 2));
let mut s = Array2::<f64>::zeros((n, 2));
for i in 0..n {
p[[i, 0]] = 1.0;
p[[i, 1]] = x[i];
s[[i, 0]] = x[i] * x[i];
s[[i, 1]] = x[i] * x[i] * x[i];
}
let specs = [spec_from_dense("p", p), spec_from_dense("s", s)];
let canon = canonicalize_for_identifiability(&specs).expect("clean canonical must succeed");
assert_eq!(canon.reduced_specs.len(), 2);
assert_eq!(canon.per_block_transform[0].dim(), (2, 2));
assert_eq!(canon.per_block_transform[1].dim(), (2, 2));
let theta = vec![Array1::from(vec![0.5, -0.25]), Array1::from(vec![1.0, 2.0])];
let raw = canon.lift_block_betas_to_raw(&theta);
assert_eq!(raw[0].as_slice().unwrap(), &[0.5, -0.25]);
assert_eq!(raw[1].as_slice().unwrap(), &[1.0, 2.0]);
}
#[test]
fn canonical_refuses_aliased_smooth_constant_with_intercept() {
let n = 64;
let x = linspace(n);
let parametric = Array2::<f64>::from_shape_fn((n, 1), |(_, _)| 1.0);
let mut smooth = Array2::<f64>::zeros((n, 3));
for i in 0..n {
smooth[[i, 0]] = 1.0;
smooth[[i, 1]] = x[i] * x[i];
smooth[[i, 2]] = x[i] * x[i] * x[i];
}
let specs = [
spec_from_dense("intercept", parametric),
spec_from_dense("smooth_with_const", smooth),
];
let err = canonicalize_for_identifiability(&specs)
.expect_err("aliased smooth-constant + intercept must refuse, not reduce");
match err {
CustomFamilyError::IdentifiabilityFailure { audit } => {
assert!(
audit.fatal,
"audit attached to IdentifiabilityFailure must be fatal; got {}",
audit.summary,
);
assert!(
audit.summary.contains("intercept")
&& audit.summary.contains("smooth_with_const"),
"refusal summary must name both offending blocks; got {:?}",
audit.summary,
);
}
other => panic!("expected IdentifiabilityFailure, got {other:?}"),
}
}
#[test]
fn canonical_five_block_gauge_ownership_succeeds_with_attribution() {
let n = 96;
let x = linspace(n);
let mut time = Array2::<f64>::zeros((n, 3));
let mut marginal = Array2::<f64>::zeros((n, 3));
let mut logslope = Array2::<f64>::zeros((n, 3));
let mut score_warp = Array2::<f64>::zeros((n, 2));
let mut link_dev = Array2::<f64>::zeros((n, 2));
for i in 0..n {
time[[i, 0]] = 1.0;
time[[i, 1]] = x[i];
time[[i, 2]] = x[i] * x[i] * x[i];
marginal[[i, 0]] = 1.0;
marginal[[i, 1]] = x[i] * x[i];
marginal[[i, 2]] = x[i].sin();
logslope[[i, 0]] = 1.0;
logslope[[i, 1]] = (3.0 * x[i]).sin();
logslope[[i, 2]] = (6.0 * x[i]).sin();
score_warp[[i, 0]] = 1.0;
score_warp[[i, 1]] = (5.0 * x[i]).cos();
link_dev[[i, 0]] = 1.0;
link_dev[[i, 1]] = (7.0 * x[i]).tanh();
}
let mut t_spec = spec_from_dense("time_surface", time);
t_spec.gauge_priority = 200;
let mut m_spec = spec_from_dense("marginal_surface", marginal);
m_spec.gauge_priority = 150;
let mut g_spec = spec_from_dense("logslope_surface", logslope);
g_spec.gauge_priority = 120;
let mut w_spec = spec_from_dense("score_warp_dev", score_warp);
w_spec.gauge_priority = 80;
let mut l_spec = spec_from_dense("link_dev", link_dev);
l_spec.gauge_priority = 60;
let specs = [t_spec, m_spec, g_spec, w_spec, l_spec];
let canon = canonicalize_for_identifiability(&specs).expect(
"five-block aliased joint with distinct gauge_priority must succeed (gauge-resolved)",
);
assert!(
!canon.audit.fatal,
"audit must be non-fatal when gauge_priority is non-trivial and \
all drops are attributed to lower-priority blocks; got {}",
canon.audit.summary,
);
let total_kept: usize = canon.audit.blocks.iter().map(|b| b.effective_dim).sum();
assert_eq!(
total_kept,
9,
"expected joint rank = 13 − 4 = 9 reported by audit; got {total_kept} \
(per-block effective_dim {:?})",
canon
.audit
.blocks
.iter()
.map(|b| (b.block_name.clone(), b.effective_dim))
.collect::<Vec<_>>(),
);
for drop in &canon.audit.dropped_columns {
assert_ne!(
drop.block, "time_surface",
"highest-priority block must never be the attributed drop \
origin under priority-aware RRQR; got drop on time_surface \
({drop:?})",
);
}
let reduced_total: usize = canon.reduced_specs.iter().map(|s| s.design.ncols()).sum();
assert_eq!(
reduced_total, 9,
"reduced specs must have joint rank = 9 total columns; got {reduced_total}",
);
assert_eq!(
canon.reduced_specs[0].design.ncols(),
3,
"time_surface must retain all 3 columns after gauge canonicalisation",
);
}
#[test]
fn canonical_clean_specs_with_penalty_round_trip() {
let n = 32;
let x = linspace(n);
let parametric = Array2::<f64>::from_shape_fn((n, 1), |(_, _)| 1.0);
let mut smooth = Array2::<f64>::zeros((n, 2));
for i in 0..n {
smooth[[i, 0]] = x[i] * x[i];
smooth[[i, 1]] = x[i] * x[i] * x[i];
}
let mut smooth_spec = spec_from_dense("smooth_only", smooth);
let mut s = Array2::<f64>::zeros((2, 2));
s[[0, 0]] = 4.0;
s[[1, 1]] = 9.0;
s[[0, 1]] = 1.5;
s[[1, 0]] = 1.5;
smooth_spec.penalties = vec![PenaltyMatrix::Dense(s.clone())];
smooth_spec.initial_log_lambdas = Array1::from(vec![0.0]);
let specs = [spec_from_dense("intercept", parametric), smooth_spec];
let canon = canonicalize_for_identifiability(&specs)
.expect("clean canonical must succeed with identity transforms");
let smooth_reduced = &canon.reduced_specs[1];
assert_eq!(smooth_reduced.penalties.len(), 1);
let dense_red = smooth_reduced.penalties[0].as_dense_cow().into_owned();
assert_eq!(dense_red.dim(), (2, 2));
let t_smooth = &canon.per_block_transform[1];
assert_eq!(t_smooth.dim(), (2, 2));
for i in 0..2 {
for j in 0..2 {
let expected = if i == j { 1.0 } else { 0.0 };
assert_eq!(t_smooth[[i, j]], expected, "T_smooth must be identity");
}
}
}
}