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, orthogonalize_design_blocks,
};
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 r in 0..k {
let row_base = r * n_rows;
for i in 0..n_rows {
let src_row = row_base + i;
for j in 0..p_block {
jac[[i, j, r]] = stacked[[src_row, 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> {
canonicalize_for_identifiability_inner(specs, true)
}
fn canonicalize_for_identifiability_inner(
specs: &[ParameterBlockSpec],
orthogonalize: bool,
) -> Result<CanonicalSpecs, CustomFamilyError> {
if orthogonalize {
if let Some(canon) = try_orthogonalize_blocks(specs)? {
return Ok(canon);
}
}
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 family_owned_geometry = specs.iter().any(|spec| spec.jacobian_callback.is_some());
if family_owned_geometry && !audit.dropped_columns.is_empty() {
let per_block_transform: Vec<Array2<f64>> = specs
.iter()
.map(|spec| Array2::<f64>::eye(spec.design.ncols()))
.collect();
let dropped_summary = audit
.dropped_columns
.iter()
.map(|drop| format!("{}[{}]", drop.block, drop.column))
.collect::<Vec<_>>()
.join(", ");
log::info!(
"[CANON] width-preserving callback-owned geometry path: audit attributed \
dropped columns [{dropped_summary}], but at least one block owns its \
effective geometry via jacobian_callback; keeping raw block widths and \
deferring curvature on the weak directions to the robust/Firth path"
);
return Ok(CanonicalSpecs {
reduced_specs: specs.to_vec(),
per_block_transform,
audit,
used_channel_aware_audit: use_channel_aware,
});
}
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;
let r_max = k_b.min(k);
for r in 0..r_max {
let src_row_base = r * n_rows;
for i in 0..n_rows {
let dst_row = i * k + r;
let src_row = src_row_base + i;
for j in 0..p_b {
j_pre[[dst_row, col_off + j]] = j_b[[src_row, 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 try_orthogonalize_blocks(
specs: &[ParameterBlockSpec],
) -> Result<Option<CanonicalSpecs>, CustomFamilyError> {
if specs.len() < 2 {
return Ok(None);
}
let family_owned_geometry = specs.iter().any(|s| s.jacobian_callback.is_some());
if family_owned_geometry {
return Ok(None);
}
let n_rows = specs[0].design.nrows();
let mut block_designs: Vec<Array2<f64>> = Vec::with_capacity(specs.len());
for spec in specs.iter() {
if spec.design.nrows() != n_rows {
return Ok(None);
}
let dense = match spec
.design
.try_to_dense_arc("orthogonalize_design_blocks densify")
{
Ok(arc) => arc.as_ref().clone(),
Err(_) => return Ok(None),
};
block_designs.push(dense);
}
let weight = vec![1.0_f64; n_rows];
let priority: Vec<u32> = specs.iter().map(|s| s.gauge_priority as u32).collect();
let ortho = orthogonalize_design_blocks(&block_designs, &priority, &weight).map_err(|e| {
CustomFamilyError::DimensionMismatch {
reason: format!("orthogonalize_design_blocks failed: {e}"),
}
})?;
if ortho.dropped.is_empty() {
return Ok(None);
}
for annotation in ortho
.direction_annotations
.iter()
.filter(|annotation| annotation.absorbed_width > 0)
{
log::info!(
"[IDENT] structural direction annotation: block={} raw_width={} kept_width={} absorbed_width={} kind={:?}",
annotation.block_idx,
annotation.raw_width,
annotation.kept_width,
annotation.absorbed_width,
annotation.kind,
);
}
let mut ortho_specs: Vec<ParameterBlockSpec> = Vec::with_capacity(specs.len());
for (spec, v_b) in specs.iter().zip(ortho.block_transforms.iter()) {
let p_b = spec.design.ncols();
if v_b.nrows() != p_b {
return Err(CustomFamilyError::DimensionMismatch {
reason: format!(
"orthogonalize: block '{}' transform has {} rows but design has {p_b} columns",
spec.name,
v_b.nrows(),
),
});
}
let inner_dense = match &spec.design {
DesignMatrix::Dense(d) => d.clone(),
DesignMatrix::Sparse(_) => {
let dense = spec
.design
.try_to_dense_arc("orthogonalize reduced-design densify")
.map_err(|reason| CustomFamilyError::DimensionMismatch {
reason: format!(
"orthogonalize: densify block '{}' failed: {reason}",
spec.name,
),
})?;
DenseDesignMatrix::from(dense)
}
};
let op = CoefficientTransformOperator::new(inner_dense, v_b.clone()).map_err(|reason| {
CustomFamilyError::DimensionMismatch {
reason: format!(
"orthogonalize: build CoefficientTransformOperator for block '{}': {reason}",
spec.name,
),
}
})?;
let reduced_design = DesignMatrix::Dense(DenseDesignMatrix::from(Arc::new(op)));
let reduced_penalties: Vec<PenaltyMatrix> = spec
.penalties
.iter()
.map(|p| pull_back_penalty_dense(p, v_b))
.collect();
let reduced_initial_beta = match &spec.initial_beta {
Some(beta_raw) => {
if beta_raw.len() != p_b {
return Err(CustomFamilyError::DimensionMismatch {
reason: format!(
"orthogonalize: block '{}' initial_beta length {} != design ncols {p_b}",
spec.name,
beta_raw.len(),
),
});
}
Some(v_b.t().dot(beta_raw))
}
None => None,
};
ortho_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: None,
stacked_design: None,
stacked_offset: None,
});
}
let inner = canonicalize_for_identifiability_inner(&ortho_specs, false)?;
let mut composed_transform: Vec<Array2<f64>> = Vec::with_capacity(specs.len());
for (v_b, t_inner) in ortho
.block_transforms
.iter()
.zip(inner.per_block_transform.iter())
{
if v_b.ncols() != t_inner.nrows() {
return Err(CustomFamilyError::DimensionMismatch {
reason: format!(
"orthogonalize: transform composition shape mismatch — V_b is {:?}, \
T_inner is {:?}",
v_b.dim(),
t_inner.dim(),
),
});
}
composed_transform.push(v_b.dot(t_inner));
}
log::info!(
"[CANON] orthogonalisation applied: {} block(s) shed overlap directions {:?}; \
p_raw={} → p_reduced={}",
ortho.dropped.len(),
ortho.dropped,
specs.iter().map(|s| s.design.ncols()).sum::<usize>(),
composed_transform.iter().map(|t| t.ncols()).sum::<usize>(),
);
Ok(Some(CanonicalSpecs {
reduced_specs: inner.reduced_specs,
per_block_transform: composed_transform,
audit: inner.audit,
used_channel_aware_audit: inner.used_channel_aware_audit,
}))
}
fn pull_back_penalty_dense(penalty: &PenaltyMatrix, v: &Array2<f64>) -> PenaltyMatrix {
let label = penalty.precision_label().map(|s| s.to_string());
let fixed_log_lambda = penalty.fixed_log_lambda();
let dense = penalty.as_dense_cow();
let s_v = dense.dot(v);
let reduced = v.t().dot(&s_v);
let mut base = PenaltyMatrix::Dense(reduced);
if let Some(lbl) = label {
base = base.with_precision_label(lbl);
}
if let Some(value) = fixed_log_lambda {
base = base.with_fixed_log_lambda(value);
}
base
}
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 fixed_log_lambda = penalty.fixed_log_lambda();
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 mut base = PenaltyMatrix::Dense(reduced);
if let Some(lbl) = label {
base = base.with_precision_label(lbl);
}
if let Some(value) = fixed_log_lambda {
base = base.with_fixed_log_lambda(value);
}
base
}
#[cfg(test)]
mod tests {
use super::*;
use crate::families::custom_family::AdditiveBlockJacobian;
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");
}
}
}
fn spec_from_dense_with_priority(
name: &str,
design: Array2<f64>,
gauge_priority: u8,
) -> ParameterBlockSpec {
let mut s = spec_from_dense(name, design);
s.gauge_priority = gauge_priority;
s
}
#[test]
fn callback_owned_geometry_keeps_raw_width_after_audit_drop() {
let n = 48;
let x = linspace(n);
let mut anchor = Array2::<f64>::zeros((n, 2));
let mut callback_owned = Array2::<f64>::zeros((n, 2));
for i in 0..n {
anchor[[i, 0]] = 1.0;
anchor[[i, 1]] = x[i];
callback_owned[[i, 0]] = x[i];
callback_owned[[i, 1]] = x[i] * x[i];
}
let anchor_spec = spec_from_dense_with_priority("marginal_surface", anchor, 150);
let mut callback_spec =
spec_from_dense_with_priority("logslope_surface", callback_owned.clone(), 120);
callback_spec.jacobian_callback = Some(Arc::new(AdditiveBlockJacobian {
design: callback_owned,
own_output: 0,
n_family_outputs: 1,
}));
let specs = [anchor_spec, callback_spec];
let canon = canonicalize_for_identifiability(&specs).expect(
"callback-owned overlap should be audit-attributed but width-preserving (#772)",
);
assert!(
!canon.audit.fatal,
"priority-owned overlap should be non-fatal; got {}",
canon.audit.summary,
);
assert!(
canon
.audit
.dropped_columns
.iter()
.any(|drop| drop.block == "logslope_surface"),
"test must exercise an attributed logslope drop; got {:?}",
canon.audit.dropped_columns,
);
assert_eq!(
canon.reduced_specs[0].design.ncols(),
2,
"anchor block keeps raw width"
);
assert_eq!(
canon.reduced_specs[1].design.ncols(),
2,
"callback-owned block keeps raw width instead of applying design-column surgery"
);
for (block, transform) in canon.per_block_transform.iter().enumerate() {
assert_eq!(
transform.dim(),
(2, 2),
"block {block} transform must be raw-width identity"
);
for row in 0..2 {
for col in 0..2 {
let expected = if row == col { 1.0 } else { 0.0 };
assert_eq!(
transform[[row, col]],
expected,
"block {block} transform must be identity"
);
}
}
}
}
#[test]
fn orthogonalize_removes_exact_cross_block_overlap_and_round_trips() {
let n = 48;
let x = linspace(n);
let mut a = Array2::<f64>::zeros((n, 2));
let mut b = Array2::<f64>::zeros((n, 2));
for i in 0..n {
a[[i, 0]] = 1.0;
a[[i, 1]] = x[i];
b[[i, 0]] = x[i];
b[[i, 1]] = x[i] * x[i];
}
let specs = [
spec_from_dense_with_priority("anchor", a.clone(), 150),
spec_from_dense_with_priority("overlap", b.clone(), 120),
];
let canon = canonicalize_for_identifiability(&specs)
.expect("orthogonalisation must resolve the overlap, not refuse");
let v_b = &canon.per_block_transform[1];
assert_eq!(
v_b.ncols(),
1,
"overlap block must keep exactly one direction"
);
assert_eq!(
v_b.nrows(),
2,
"overlap block transform maps from raw width 2"
);
assert_eq!(canon.per_block_transform[0].ncols(), 2);
let theta = vec![Array1::from(vec![0.7, -0.3]), Array1::from(vec![1.4])];
let raw = canon.lift_block_betas_to_raw(&theta);
assert_eq!(raw[0].len(), 2);
assert_eq!(raw[1].len(), 2);
let pred_a = a.dot(&raw[0]);
let pred_b = b.dot(&raw[1]);
let v_a = &canon.per_block_transform[0];
let red_a = a.dot(v_a).dot(&theta[0]);
let red_b = b.dot(v_b).dot(&theta[1]);
for i in 0..n {
let raw_pred = pred_a[i] + pred_b[i];
let red_pred = red_a[i] + red_b[i];
assert!(
(raw_pred - red_pred).abs() < 1e-9,
"row {i}: raw prediction {raw_pred} != reduced prediction {red_pred}",
);
}
}
#[test]
fn orthogonalize_clean_design_yields_identity_transforms() {
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_with_priority("p", p, 150),
spec_from_dense_with_priority("s", s, 120),
];
let canon = canonicalize_for_identifiability(&specs).expect("clean design canonicalises");
assert_eq!(canon.per_block_transform[0].dim(), (2, 2));
assert_eq!(canon.per_block_transform[1].dim(), (2, 2));
}
#[test]
fn orthogonalize_design_blocks_drops_only_overlap() {
use crate::families::identifiability_compiler::orthogonalize_design_blocks;
let n = 40;
let x = linspace(n);
let mut anchor = Array2::<f64>::zeros((n, 2));
let mut overlap = Array2::<f64>::zeros((n, 2));
for i in 0..n {
anchor[[i, 0]] = 1.0;
anchor[[i, 1]] = x[i];
overlap[[i, 0]] = x[i];
overlap[[i, 1]] = x[i] * x[i] * x[i];
}
let weight = vec![1.0_f64; n];
let res =
orthogonalize_design_blocks(&[anchor.clone(), overlap.clone()], &[150, 120], &weight)
.expect("orthogonalisation must succeed");
assert_eq!(
res.block_transforms[0].ncols(),
2,
"anchor keeps full width"
);
assert_eq!(
res.block_transforms[1].ncols(),
1,
"overlap block sheds exactly the aliased direction",
);
assert_eq!(
res.dropped,
vec![(1, 1)],
"one direction dropped from block 1"
);
}
}