use crate::lunar_gauge::{IDX_OFFSET, N_GAUGE, T_BASE_S};
use crate::lunar_llr_geometry::Vec3;
pub fn differential_range_row(
node_a_body: Vec3,
node_b_body: Vec3,
t_tt_jc: f64,
) -> [f64; N_GAUGE] {
let ra_rel = crate::lunar_orientation::de440_moon_pa_body_to_inertial(node_a_body, t_tt_jc);
let rb_rel = crate::lunar_orientation::de440_moon_pa_body_to_inertial(node_b_body, t_tt_jc);
let dv = [
ra_rel[0] - rb_rel[0],
ra_rel[1] - rb_rel[1],
ra_rel[2] - rb_rel[2],
];
let n = (dv[0] * dv[0] + dv[1] * dv[1] + dv[2] * dv[2]).sqrt();
let uhat = [dv[0] / n, dv[1] / n, dv[2] / n];
let ja = crate::lunar_datum::partials_datum7(uhat, node_a_body, t_tt_jc);
let jb = crate::lunar_datum::partials_datum7(uhat, node_b_body, t_tt_jc);
let mut row = [0.0_f64; N_GAUGE];
for (c, r) in row.iter_mut().take(7).enumerate() {
*r = ja[c] - jb[c];
}
row
}
pub fn differential_clock_tie_row() -> [f64; N_GAUGE] {
let node_a_offset = 1.0_f64;
let node_b_offset = 1.0_f64;
let mut row = [0.0_f64; N_GAUGE];
row[IDX_OFFSET] = node_a_offset - node_b_offset;
row
}
pub fn assemble_faultobs_info(blocks: &[(Vec<[f64; N_GAUGE]>, f64)]) -> Vec<Vec<f64>> {
crate::lunar_gauge::assemble_coupled_info(blocks)
}
pub const N_DATUM_GAUGE: usize = 8;
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct NetworkLayout {
pub n_nodes: usize,
pub state_dim: usize,
}
impl NetworkLayout {
pub fn new(n_nodes: usize) -> Self {
Self {
n_nodes,
state_dim: 5 * n_nodes,
}
}
pub fn pos_idx(&self, j: usize) -> usize {
3 * j
}
pub fn off_idx(&self, j: usize) -> usize {
3 * self.n_nodes + j
}
pub fn rate_idx(&self, j: usize) -> usize {
4 * self.n_nodes + j
}
}
pub fn pernode_range_row(
layout: &NetworkLayout,
node_a: usize,
node_b: usize,
u_ab: [f64; 3],
elapsed_s: f64,
) -> Vec<f64> {
let mut row = vec![0.0_f64; layout.state_dim];
let (pa, pb) = (layout.pos_idx(node_a), layout.pos_idx(node_b));
for k in 0..3 {
row[pa + k] = u_ab[k];
row[pb + k] = -u_ab[k];
}
row[layout.off_idx(node_a)] = 1.0;
row[layout.off_idx(node_b)] = -1.0;
let kappa = elapsed_s / T_BASE_S;
row[layout.rate_idx(node_a)] = kappa;
row[layout.rate_idx(node_b)] = -kappa;
row
}
pub fn assemble_pernode_info(rows: &[(Vec<f64>, f64)], state_dim: usize) -> Vec<Vec<f64>> {
let mut info = vec![vec![0.0_f64; state_dim]; state_dim];
for (row, sigma) in rows {
let w = 1.0 / (sigma * sigma);
for p in 0..state_dim {
let jw = row[p] * w;
if jw == 0.0 {
continue;
}
for q in 0..state_dim {
info[p][q] += jw * row[q];
}
}
}
info
}
pub fn datum_gauge_generators(
layout: &NetworkLayout,
node_positions: &[[f64; 3]],
) -> Vec<Vec<f64>> {
debug_assert_eq!(
node_positions.len(),
layout.n_nodes,
"node_positions must have one entry per node"
);
let mut gens = Vec::with_capacity(N_DATUM_GAUGE);
for k in 0..3 {
let mut g = vec![0.0_f64; layout.state_dim];
for j in 0..layout.n_nodes {
g[layout.pos_idx(j) + k] = 1.0;
}
gens.push(g);
}
for k in 0..3 {
let mut e = [0.0_f64; 3];
e[k] = 1.0;
let mut g = vec![0.0_f64; layout.state_dim];
for (j, &p) in node_positions.iter().enumerate() {
let c = cross(e, p);
let base = layout.pos_idx(j);
g[base] = c[0];
g[base + 1] = c[1];
g[base + 2] = c[2];
}
gens.push(g);
}
let mut g_off = vec![0.0_f64; layout.state_dim];
for j in 0..layout.n_nodes {
g_off[layout.off_idx(j)] = 1.0;
}
gens.push(g_off);
let mut g_rate = vec![0.0_f64; layout.state_dim];
for j in 0..layout.n_nodes {
g_rate[layout.rate_idx(j)] = 1.0;
}
gens.push(g_rate);
gens
}
pub fn parity_projector(g: &[Vec<f64>], w: &[f64]) -> Vec<Vec<f64>> {
let n = g.len();
if n == 0 {
return vec![];
}
let state_dim = g[0].len();
let mut ntm = vec![vec![0.0_f64; state_dim]; state_dim];
for (i, row) in g.iter().enumerate() {
let wi = w[i];
for p in 0..state_dim {
let jwi = row[p] * wi;
if jwi == 0.0 {
continue;
}
for q in 0..state_dim {
ntm[p][q] += jwi * row[q];
}
}
}
let nplus = crate::fim::crlb(&ntm, 1e-9).pseudo_covariance;
let c: Vec<Vec<f64>> = nplus
.iter()
.map(|nrow| {
g.iter()
.map(|grow| nrow.iter().zip(grow.iter()).map(|(&nl, &gl)| nl * gl).sum())
.collect()
})
.collect();
let d: Vec<Vec<f64>> = c
.iter()
.map(|crow| {
crow.iter()
.zip(w.iter())
.map(|(&cv, &wv)| cv * wv)
.collect()
})
.collect();
let h: Vec<Vec<f64>> = g
.iter()
.map(|grow| {
(0..n)
.map(|i| grow.iter().zip(d.iter()).map(|(&gk, dk)| gk * dk[i]).sum())
.collect()
})
.collect();
h.into_iter()
.enumerate()
.map(|(i, row)| {
row.into_iter()
.enumerate()
.map(|(j, v)| if i == j { 1.0 - v } else { -v })
.collect()
})
.collect()
}
pub fn is_detectable(pperp: &[Vec<f64>], b: &[f64], tol: f64) -> (bool, f64) {
let pb: Vec<f64> = pperp
.iter()
.map(|row| row.iter().zip(b.iter()).map(|(&p, &bi)| p * bi).sum())
.collect();
let norm = pb.iter().map(|&x| x * x).sum::<f64>().sqrt();
(norm > tol, norm)
}
pub fn mdb(pperp: &[Vec<f64>], w: &[f64], c: &[f64], ncp: f64) -> f64 {
debug_assert_eq!(pperp.len(), w.len(), "P⊥ rows and w length must match");
debug_assert_eq!(pperp.len(), c.len(), "P⊥ size and c length must match");
let q: f64 = c
.iter()
.enumerate()
.map(|(i, &ci)| {
let wi_ci = w[i] * ci;
pperp[i]
.iter()
.zip(c.iter())
.map(|(&pij, &cj)| wi_ci * pij * cj)
.sum::<f64>()
})
.sum();
if q <= 0.0 {
return f64::INFINITY;
}
(ncp / q).sqrt()
}
#[derive(Clone, Debug)]
pub struct PeerFaultModel {
pub incidence: Vec<usize>,
}
pub fn peer_signature(n_meas: usize, incidence: &[usize]) -> Vec<Vec<f64>> {
let n_cols = incidence.len();
let mut mat = vec![vec![0.0_f64; n_cols]; n_meas];
for (col, &row_idx) in incidence.iter().enumerate() {
debug_assert!(
row_idx < n_meas,
"incidence index {row_idx} out of range {n_meas}"
);
mat[row_idx][col] = 1.0;
}
mat
}
pub const MAX_COALITION: usize = 12;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ByzantineClass {
pub f_detect: usize,
pub f_identify: usize,
pub block_spark: usize,
}
fn project_block(pperp: &[Vec<f64>], block: &[Vec<f64>]) -> Vec<Vec<f64>> {
if block.is_empty() {
return vec![];
}
let ncols = block[0].len();
pperp
.iter()
.map(|prow| {
(0..ncols)
.map(|c| {
prow.iter()
.zip(block.iter())
.map(|(&p, brow)| p * brow[c])
.sum()
})
.collect()
})
.collect()
}
fn stack_columns(eff: &[Vec<Vec<f64>>], subset: &[usize]) -> Vec<Vec<f64>> {
let mut cols = Vec::new();
for &j in subset {
let block = &eff[j];
if block.is_empty() {
continue;
}
let ncols = block[0].len();
for c in 0..ncols {
cols.push(block.iter().map(|row| row[c]).collect());
}
}
cols
}
fn effective_column_rank(cols: &[Vec<f64>], rel_tol: f64) -> usize {
let t = cols.len();
if t == 0 {
return 0;
}
let mut gram = vec![vec![0.0_f64; t]; t];
for (i, ci) in cols.iter().enumerate() {
for (j, cj) in cols.iter().enumerate().skip(i) {
let dot: f64 = ci.iter().zip(cj.iter()).map(|(&a, &b)| a * b).sum();
gram[i][j] = dot;
gram[j][i] = dot;
}
}
let eig = crate::fim::sym_eig(&gram);
let lam_max = eig.values.last().copied().unwrap_or(0.0);
if lam_max <= 0.0 {
return 0;
}
let thr = rel_tol * lam_max;
eig.values.iter().filter(|&&v| v > thr).count()
}
fn combinations(n: usize, k: usize) -> Vec<Vec<usize>> {
let mut result = Vec::new();
if k == 0 || k > n {
return result;
}
let mut c: Vec<usize> = (0..k).collect();
loop {
result.push(c.clone());
let mut i = k;
loop {
if i == 0 {
return result;
}
i -= 1;
if c[i] < n - k + i {
c[i] += 1;
for j in (i + 1)..k {
c[j] = c[j - 1] + 1;
}
break;
}
}
}
}
pub fn byzantine_bound(
peer_blocks: &[Vec<Vec<f64>>],
pperp: &[Vec<f64>],
rel_tol: f64,
) -> ByzantineClass {
let m = peer_blocks.len();
let eff: Vec<Vec<Vec<f64>>> = peer_blocks
.iter()
.map(|b| project_block(pperp, b))
.collect();
let cap = m.min(MAX_COALITION);
let mut block_spark = None;
'search: for k in 1..=cap {
for subset in combinations(m, k) {
let cols = stack_columns(&eff, &subset);
let total = cols.len();
if total == 0 {
continue;
}
if effective_column_rank(&cols, rel_tol) < total {
block_spark = Some(k);
break 'search;
}
}
}
let bs = block_spark.unwrap_or(cap + 1);
ByzantineClass {
f_detect: bs - 1,
f_identify: (bs - 1) / 2,
block_spark: bs,
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct HoldoverFloor {
pub rate_in_gauge: bool,
pub temporal_gauge_dim: usize,
}
pub fn holdover_floor(
info: &[Vec<f64>],
gauge_generators: &[Vec<f64>],
rel_tol: f64,
) -> HoldoverFloor {
let in_null = |g: &[f64]| -> bool {
let info_g: Vec<f64> = info
.iter()
.map(|row| row.iter().zip(g.iter()).map(|(&a, &b)| a * b).sum())
.collect();
let norm_ig: f64 = info_g.iter().map(|&x| x * x).sum::<f64>().sqrt();
let norm_info: f64 = info
.iter()
.flat_map(|r| r.iter())
.map(|&x| x * x)
.sum::<f64>()
.sqrt();
let norm_g: f64 = g.iter().map(|&x| x * x).sum::<f64>().sqrt();
norm_info > 0.0 && norm_g > 0.0 && norm_ig / (norm_info * norm_g) < rel_tol
};
let n = gauge_generators.len();
let rate_in_gauge = n > 0 && in_null(&gauge_generators[n - 1]);
let temporal_gauge_dim = if n >= 2 {
let offset_in_gauge = in_null(&gauge_generators[n - 2]);
usize::from(offset_in_gauge) + usize::from(rate_in_gauge)
} else {
usize::from(rate_in_gauge)
};
HoldoverFloor {
rate_in_gauge,
temporal_gauge_dim,
}
}
pub fn slope(
pperp: &[Vec<f64>],
w: &[f64],
g: &[Vec<f64>],
b: &[f64],
obs_proj: &[Vec<f64>],
) -> f64 {
let n_meas = g.len();
if n_meas == 0 {
return 0.0;
}
let state_dim = g[0].len();
let mut ntm = vec![vec![0.0_f64; state_dim]; state_dim];
for (i, row) in g.iter().enumerate() {
let wi = w[i];
for p in 0..state_dim {
let jwi = row[p] * wi;
if jwi == 0.0 {
continue;
}
for q in 0..state_dim {
ntm[p][q] += jwi * row[q];
}
}
}
let nplus = crate::fim::crlb(&ntm, 1e-9).pseudo_covariance;
let mut gtw_b = vec![0.0_f64; state_dim];
for (i, row) in g.iter().enumerate() {
let wb = w[i] * b[i];
if wb == 0.0 {
continue;
}
for p in 0..state_dim {
gtw_b[p] += row[p] * wb;
}
}
let dx: Vec<f64> = nplus
.iter()
.map(|row| row.iter().zip(gtw_b.iter()).map(|(&nv, &gv)| nv * gv).sum())
.collect();
let obs_dx: Vec<f64> = obs_proj
.iter()
.map(|row| row.iter().zip(dx.iter()).map(|(&ov, &dv)| ov * dv).sum())
.collect();
let numerator: f64 = obs_dx.iter().map(|&x| x * x).sum::<f64>().sqrt();
let pb: Vec<f64> = pperp
.iter()
.map(|row| row.iter().zip(b.iter()).map(|(&pv, &bv)| pv * bv).sum())
.collect();
let denom_sq: f64 = pb.iter().zip(w.iter()).map(|(&v, &wi)| wi * v * v).sum();
let denom = denom_sq.sqrt();
if denom < 1e-12 {
f64::INFINITY
} else {
numerator / denom
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ProviderSplit {
pub common_detectable: bool,
pub differential_detectable: bool,
}
pub fn provider_mismatch_split(
common_block: &[f64],
differential_block: &[f64],
pperp: &[Vec<f64>],
tol: f64,
) -> ProviderSplit {
let (common_detectable, _) = is_detectable(pperp, common_block, tol);
let (differential_detectable, _) = is_detectable(pperp, differential_block, tol);
ProviderSplit {
common_detectable,
differential_detectable,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lunar_gauge::{classify_null_space, IDX_RATE, IDX_SCALE};
const T0: f64 = (2_460_310.5 - 2_451_545.0) / 36_525.0;
fn epochs() -> [f64; 4] {
std::array::from_fn(|k| T0 + (k as f64) * 2.0 / 36_525.0)
}
fn network_nodes() -> Vec<Vec3> {
let mut nodes: Vec<Vec3> = crate::lunar_llr_geometry::reflectors()
.iter()
.map(|r| r.pa_body_m)
.collect();
nodes.push([1_600_000.0, 700_000.0, 900_000.0]);
nodes
}
fn free_network_rows() -> Vec<[f64; N_GAUGE]> {
let nodes = network_nodes();
let eps = epochs();
let mut rows = Vec::new();
for &t in &eps {
for i in 0..nodes.len() {
for j in (i + 1)..nodes.len() {
rows.push(differential_range_row(nodes[i], nodes[j], t));
}
}
}
rows
}
#[test]
fn differential_row_rigid_transform_invariance() {
let refl = crate::lunar_llr_geometry::reflectors();
let node_a = refl[0].pa_body_m; let node_b = refl[3].pa_body_m; let row = differential_range_row(node_a, node_b, T0);
for c in [0_usize, 1, 2] {
assert!(
row[c].abs() < 1e-9,
"translation col {c} must vanish (rigid gauge), got {}",
row[c]
);
}
for c in [4_usize, 5, 6] {
assert!(
row[c].abs() < 1e-9,
"rotation col {c} must vanish (rigid gauge), got {}",
row[c]
);
}
assert!(
row[IDX_SCALE].abs() > 1.0,
"scale col must be observable (nonzero), got {}",
row[IDX_SCALE]
);
assert_eq!(
row[IDX_OFFSET], 0.0,
"IDX_OFFSET must be 0 (common cancels)"
);
assert_eq!(row[IDX_RATE], 0.0, "IDX_RATE must be 0 (common cancels)");
}
#[test]
fn differential_scale_column_equals_baseline_length() {
let refl = crate::lunar_llr_geometry::reflectors();
let a = refl[0].pa_body_m;
let b = refl[2].pa_body_m;
let baseline =
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt();
for &t in &epochs() {
let row = differential_range_row(a, b, t);
let rel = (row[IDX_SCALE].abs() - baseline).abs() / baseline;
assert!(
rel < 1e-9,
"scale col {} must equal baseline {} (rel {}), epoch {}",
row[IDX_SCALE],
baseline,
rel,
t
);
}
}
#[test]
fn differential_clock_tie_common_cancels() {
let row = differential_clock_tie_row();
assert_eq!(row[IDX_OFFSET], 0.0, "common offset must cancel");
assert_eq!(row[IDX_RATE], 0.0, "common rate must cancel");
assert!(
row.iter().all(|&v| v == 0.0),
"inter-node clock tie carries no common-datum information: {row:?}"
);
}
#[test]
fn free_network_gauge_is_eight() {
let rows = free_network_rows();
assert!(
rows.len() >= 40,
"network must be representative (many rows)"
);
let info = assemble_faultobs_info(&[(rows, 1.0)]);
let cls = classify_null_space(&info, 1e-9);
assert_eq!(
cls.defect, 8,
"free-network datum defect must be 8, got {cls:?}"
);
assert_eq!(
cls.dim_spatial, 6,
"dim_spatial must be 6 (3 transl + 3 rot)"
);
assert_eq!(
cls.dim_temporal, 2,
"dim_temporal must be 2 (offset + rate)"
);
assert_eq!(cls.coupled_dim, 0, "coupled_dim must be 0");
assert!(
cls.p_st_norm < 1e-9,
"p_st_norm must be ~0 (direct-sum gauge), got {}",
cls.p_st_norm
);
let scale_info = info[IDX_SCALE][IDX_SCALE];
assert!(
scale_info > 1.0,
"scale diagonal must be well-conditioned (>1), got {scale_info}"
);
for (i, rowm) in info.iter().enumerate() {
if i != IDX_SCALE {
assert!(
rowm[i] <= 1e-6 * scale_info,
"non-scale diagonal [{i}] must be negligible vs scale, got {}",
rowm[i]
);
}
}
}
fn pernode_body_nodes() -> Vec<Vec3> {
let mut nodes: Vec<Vec3> = crate::lunar_llr_geometry::reflectors()
.iter()
.map(|r| r.pa_body_m)
.collect();
nodes.push([1_600_000.0, 700_000.0, 900_000.0]);
nodes.push([-1_200_000.0, 1_000_000.0, -800_000.0]);
nodes.push([300_000.0, -1_500_000.0, 1_100_000.0]);
nodes
}
fn pernode_inertial(nodes: &[Vec3]) -> Vec<[f64; 3]> {
nodes
.iter()
.map(|&b| crate::lunar_orientation::de440_moon_pa_body_to_inertial(b, T0))
.collect()
}
const PERNODE_ELAPSED_S: [f64; 3] = [21_600.0, 43_200.0, 86_400.0];
fn pernode_rows(layout: &NetworkLayout, inertial: &[[f64; 3]]) -> Vec<(Vec<f64>, f64)> {
let m = layout.n_nodes;
let mut rows = Vec::new();
for a in 0..m {
for b in (a + 1)..m {
let d = [
inertial[a][0] - inertial[b][0],
inertial[a][1] - inertial[b][1],
inertial[a][2] - inertial[b][2],
];
let n = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
let u = [d[0] / n, d[1] / n, d[2] / n];
for &elapsed in &PERNODE_ELAPSED_S {
rows.push((pernode_range_row(layout, a, b, u, elapsed), 1.0));
}
}
}
rows
}
fn mat_vec(m: &[Vec<f64>], v: &[f64]) -> Vec<f64> {
m.iter()
.map(|row| row.iter().zip(v).map(|(&a, &b)| a * b).sum())
.collect()
}
fn vnorm(v: &[f64]) -> f64 {
v.iter().map(|&x| x * x).sum::<f64>().sqrt()
}
fn fro(m: &[Vec<f64>]) -> f64 {
m.iter()
.flat_map(|r| r.iter())
.map(|&x| x * x)
.sum::<f64>()
.sqrt()
}
#[test]
fn pernode_gauge_generators_are_null() {
let nodes = pernode_body_nodes();
let layout = NetworkLayout::new(nodes.len());
let inertial = pernode_inertial(&nodes);
let rows = pernode_rows(&layout, &inertial);
let info = assemble_pernode_info(&rows, layout.state_dim);
let gens = datum_gauge_generators(&layout, &inertial);
assert_eq!(gens.len(), 8, "eight datum⊕timescale gauge generators");
let info_fro = fro(&info);
for (idx, g) in gens.iter().enumerate() {
let v = mat_vec(&info, g);
let rel = vnorm(&v) / (info_fro * vnorm(g));
assert!(
rel < 1e-8,
"gauge generator {idx} must lie in N(GᵀWG): relative residual {rel:.3e}"
);
}
}
#[test]
fn pernode_rank_is_state_dim_minus_eight() {
let nodes = pernode_body_nodes();
let layout = NetworkLayout::new(nodes.len());
let inertial = pernode_inertial(&nodes);
let rows = pernode_rows(&layout, &inertial);
let info = assemble_pernode_info(&rows, layout.state_dim);
assert!(
rows.len() >= layout.state_dim - 8,
"network must have ≥ state_dim − 8 rows for the true rank"
);
let eig = crate::fim::sym_eig(&info);
let lam_max = *eig.values.last().unwrap();
let expected_rank = layout.state_dim - 8;
let thr = 1e-9 * lam_max;
let rank = eig.values.iter().filter(|&&v| v > thr).count();
assert_eq!(
rank, expected_rank,
"rank(GᵀWG) must be state_dim − 8 = {expected_rank}, got {rank}; eigenvalues {:?}",
eig.values
);
let null_max = eig.values[7];
let obs_min = eig.values[8];
assert!(
null_max < 1e-9 * lam_max,
"eighth eigenvalue must be numerically null: {null_max:.3e} vs λ_max {lam_max:.3e}"
);
assert!(
obs_min > 1e-6 * lam_max,
"smallest observable eigenvalue must be well separated: {obs_min:.3e} vs λ_max {lam_max:.3e}"
);
let gap = obs_min / null_max.abs().max(f64::MIN_POSITIVE);
assert!(
gap > 1e6,
"spectral gap between null and observable subspaces must be clear: {gap:.3e}"
);
}
#[test]
fn pernode_scale_is_observable() {
let nodes = pernode_body_nodes();
let layout = NetworkLayout::new(nodes.len());
let inertial = pernode_inertial(&nodes);
let rows = pernode_rows(&layout, &inertial);
let info = assemble_pernode_info(&rows, layout.state_dim);
let mut scale_gen = vec![0.0_f64; layout.state_dim];
for (j, &p) in inertial.iter().enumerate() {
let base = layout.pos_idx(j);
scale_gen[base] = p[0];
scale_gen[base + 1] = p[1];
scale_gen[base + 2] = p[2];
}
let v = mat_vec(&info, &scale_gen);
let rel = vnorm(&v) / (fro(&info) * vnorm(&scale_gen));
assert!(
rel > 1e-3,
"scale must be observable (not gauged): relative residual {rel:.3e} must be bounded away from 0"
);
}
fn pernode_g_and_w(layout: &NetworkLayout, inertial: &[[f64; 3]]) -> (Vec<Vec<f64>>, Vec<f64>) {
let rows = pernode_rows(layout, inertial);
let g = rows.iter().map(|(r, _)| r.clone()).collect();
let w = rows
.iter()
.map(|(_, sigma)| 1.0 / (sigma * sigma))
.collect();
(g, w)
}
fn mat_mul(a: &[Vec<f64>], b: &[Vec<f64>]) -> Vec<Vec<f64>> {
let ncols = b.first().map_or(0, |r| r.len());
a.iter()
.map(|a_row| {
(0..ncols)
.map(|j| a_row.iter().zip(b.iter()).map(|(&ai, bi)| ai * bi[j]).sum())
.collect()
})
.collect()
}
fn max_abs_diff(a: &[Vec<f64>], b: &[Vec<f64>]) -> f64 {
a.iter()
.zip(b.iter())
.flat_map(|(ra, rb)| ra.iter().zip(rb.iter()).map(|(&ai, &bi)| (ai - bi).abs()))
.fold(0.0_f64, f64::max)
}
#[test]
fn parity_projector_idempotent() {
let nodes = pernode_body_nodes();
let layout = NetworkLayout::new(nodes.len());
let inertial = pernode_inertial(&nodes);
let (g, w) = pernode_g_and_w(&layout, &inertial);
let pperp = parity_projector(&g, &w);
let pp2 = mat_mul(&pperp, &pperp);
let err = max_abs_diff(&pp2, &pperp);
assert!(
err < 1e-9,
"P⊥ must be idempotent: ‖P⊥² − P⊥‖_max = {err:.3e}"
);
}
#[test]
fn parity_projector_w_self_adjoint() {
let nodes = pernode_body_nodes();
let layout = NetworkLayout::new(nodes.len());
let inertial = pernode_inertial(&nodes);
let (g, w) = pernode_g_and_w(&layout, &inertial);
let pperp = parity_projector(&g, &w);
let n = pperp.len();
let mut max_err = 0.0_f64;
for i in 0..n {
for j in 0..n {
let wp_ij = w[i] * pperp[i][j];
let wp_ji = w[j] * pperp[j][i];
max_err = max_err.max((wp_ij - wp_ji).abs());
}
}
assert!(
max_err < 1e-9,
"WP⊥ must be symmetric: ‖WP⊥ − (WP⊥)ᵀ‖_max = {max_err:.3e}"
);
}
#[test]
fn parity_projector_annihilates_range_g() {
let nodes = pernode_body_nodes();
let layout = NetworkLayout::new(nodes.len());
let inertial = pernode_inertial(&nodes);
let (g, w) = pernode_g_and_w(&layout, &inertial);
let pperp = parity_projector(&g, &w);
let state_dim = g[0].len();
let max_err = (0..state_dim)
.map(|c| {
let g_col: Vec<f64> = g.iter().map(|row| row[c]).collect();
let ppg = mat_vec(&pperp, &g_col);
ppg.iter().map(|&v| v.abs()).fold(0.0_f64, f64::max)
})
.fold(0.0_f64, f64::max);
assert!(
max_err < 1e-8,
"P⊥ must annihilate range(G): ‖P⊥G‖_max = {max_err:.3e}"
);
}
#[test]
fn detectability_t1_in_range_vs_generic() {
let nodes = pernode_body_nodes();
let layout = NetworkLayout::new(nodes.len());
let inertial = pernode_inertial(&nodes);
let (g, w) = pernode_g_and_w(&layout, &inertial);
let pperp = parity_projector(&g, &w);
let state_dim = g[0].len();
let n_meas = g.len();
let mut x = vec![0.0_f64; state_dim];
x[layout.off_idx(1)] = 1.0;
let b_in: Vec<f64> = g
.iter()
.map(|row| row.iter().zip(x.iter()).map(|(&r, &xi)| r * xi).sum())
.collect();
let (det_in, norm_in) = is_detectable(&pperp, &b_in, 1e-8);
assert!(
!det_in,
"fault in range(G) must be undetectable; ‖P⊥b‖ = {norm_in:.3e}"
);
assert!(
norm_in < 1e-7,
"‖P⊥b‖ for in-range fault must be near zero, got {norm_in:.3e}"
);
let mut b_out = vec![0.0_f64; n_meas];
b_out[0] = 1.0;
let (det_out, norm_out) = is_detectable(&pperp, &b_out, 1e-8);
assert!(
det_out,
"generic fault must be detectable; ‖P⊥b‖ = {norm_out:.3e}"
);
assert!(
norm_out > 1e-3,
"‖P⊥b‖ for generic fault must be clearly nonzero, got {norm_out:.3e}"
);
}
fn pernode_g_and_w_unequal(
layout: &NetworkLayout,
inertial: &[[f64; 3]],
) -> (Vec<Vec<f64>>, Vec<f64>) {
let rows = pernode_rows(layout, inertial);
let sigmas = [0.5_f64, 1.0, 2.0];
let g: Vec<Vec<f64>> = rows.iter().map(|(r, _)| r.clone()).collect();
let w: Vec<f64> = rows
.iter()
.enumerate()
.map(|(k, _)| {
let s = sigmas[k % sigmas.len()];
1.0 / (s * s)
})
.collect();
(g, w)
}
#[test]
fn mdb_correct_vs_wrong_form_w_neq_i() {
let nodes = pernode_body_nodes();
let layout = NetworkLayout::new(nodes.len());
let inertial = pernode_inertial(&nodes);
let (g, w) = pernode_g_and_w_unequal(&layout, &inertial);
let pperp = parity_projector(&g, &w);
let n = pperp.len();
let mut c = vec![0.0_f64; n];
c[0] = 1.0;
let pperp_c: Vec<f64> = mat_vec(&pperp, &c); let q_correct: f64 = c
.iter()
.zip(w.iter())
.zip(pperp_c.iter())
.map(|((&ci, &wi), &ui)| wi * ci * ui)
.sum();
let pperp_t_c: Vec<f64> = (0..n)
.map(|j| {
c.iter()
.zip(pperp.iter())
.map(|(&ci, row)| ci * row[j])
.sum::<f64>()
})
.collect(); let w_pperp_c: Vec<f64> = w
.iter()
.zip(pperp_c.iter())
.map(|(&wi, &ui)| wi * ui)
.collect(); let q_wrong: f64 = pperp_t_c
.iter()
.zip(w_pperp_c.iter())
.map(|(&a, &b)| a * b)
.sum();
assert!(
q_correct > 1e-10,
"q_correct (cᵀWP⊥c) must be positive (e_0 is detectable): {q_correct:.6e}"
);
assert!(
q_wrong > 1e-10,
"q_wrong (cᵀP⊥WP⊥c) must be positive: {q_wrong:.6e}"
);
let rel_diff = (q_correct - q_wrong).abs() / q_correct.max(q_wrong);
assert!(
rel_diff > 1e-3,
"correct cᵀWP⊥c={q_correct:.6} and wrong cᵀP⊥WP⊥c={q_wrong:.6} \
must differ for W≠I: rel_diff={rel_diff:.4e} (test is toothless if this fails)"
);
let ncp = 17.075_f64; let mdb_val = mdb(&pperp, &w, &c, ncp);
let expected_correct = (ncp / q_correct).sqrt();
let mdb_wrong_val = (ncp / q_wrong).sqrt();
assert!(
(mdb_val - expected_correct).abs() < 1e-12,
"mdb() must use correct form: got {mdb_val:.8}, expected {expected_correct:.8}"
);
assert!(
(mdb_val - mdb_wrong_val).abs() > 1e-4,
"mdb() (correct={mdb_val:.6}) and wrong-form value ({mdb_wrong_val:.6}) must differ"
);
}
#[test]
fn mdb_detectable_and_undetectable() {
let nodes = pernode_body_nodes();
let layout = NetworkLayout::new(nodes.len());
let inertial = pernode_inertial(&nodes);
let (g, w) = pernode_g_and_w_unequal(&layout, &inertial);
let pperp = parity_projector(&g, &w);
let n = pperp.len();
let state_dim = g[0].len();
let ncp = 17.075_f64;
let mut c_det = vec![0.0_f64; n];
c_det[0] = 1.0;
let mdb_det = mdb(&pperp, &w, &c_det, ncp);
assert!(
mdb_det.is_finite() && mdb_det > 0.0,
"MDB for detectable direction must be positive and finite, got {mdb_det}"
);
assert!(
mdb_det < 1e6,
"MDB for generic detectable direction must be modest, got {mdb_det}"
);
let mut x = vec![0.0_f64; state_dim];
x[layout.off_idx(1)] = 1.0;
let b_in: Vec<f64> = g
.iter()
.map(|row| row.iter().zip(x.iter()).map(|(&r, &xi)| r * xi).sum())
.collect();
let b_norm = vnorm(&b_in);
let c_indet: Vec<f64> = b_in.iter().map(|&v| v / b_norm).collect();
let mdb_indet = mdb(&pperp, &w, &c_indet, ncp);
assert!(
mdb_indet > 1e3 || mdb_indet.is_infinite(),
"MDB for undetectable direction must be very large or infinite, got {mdb_indet}"
);
}
#[test]
fn peer_signature_shape_and_unit_columns() {
let n_meas = 10_usize;
let inc_0: Vec<usize> = vec![0, 2, 5];
let sig_0 = peer_signature(n_meas, &inc_0);
assert_eq!(sig_0.len(), n_meas, "peer 0 signature: row count");
assert_eq!(sig_0[0].len(), inc_0.len(), "peer 0 signature: col count");
for (col, &row_idx) in inc_0.iter().enumerate() {
for (r, sig0_row) in sig_0.iter().enumerate() {
let expected = if r == row_idx { 1.0 } else { 0.0 };
assert_eq!(
sig0_row[col], expected,
"peer_signature(peer 0)[row={r}][col={col}] should be {expected} \
(incidence[{col}]={row_idx})"
);
}
}
let inc_1: Vec<usize> = vec![1, 3, 5, 7];
let sig_1 = peer_signature(n_meas, &inc_1);
assert_eq!(sig_1.len(), n_meas, "peer 1 signature: row count");
assert_eq!(sig_1[0].len(), inc_1.len(), "peer 1 signature: col count");
for (col, &row_idx) in inc_1.iter().enumerate() {
for (r, sig1_row) in sig_1.iter().enumerate() {
let expected = if r == row_idx { 1.0 } else { 0.0 };
assert_eq!(
sig1_row[col], expected,
"peer_signature(peer 1)[row={r}][col={col}] should be {expected}"
);
}
}
let union_rows = [0_usize, 1, 2, 3, 5, 7];
for &r in &union_rows {
let covered = sig_0[r].iter().any(|&v| v != 0.0) || sig_1[r].iter().any(|&v| v != 0.0);
assert!(
covered,
"union incident row {r} must appear in at least one peer block"
);
}
for r in [4_usize, 6, 8, 9] {
assert!(
sig_0[r].iter().all(|&v| v == 0.0),
"non-incident row {r} must be zero in peer 0 block"
);
assert!(
sig_1[r].iter().all(|&v| v == 0.0),
"non-incident row {r} must be zero in peer 1 block"
);
}
}
fn col_norm(mat: &[Vec<f64>], c: usize) -> f64 {
mat.iter().map(|row| row[c] * row[c]).sum::<f64>().sqrt()
}
#[test]
fn byzantine_c3_rank_deficient_but_nonzero_is_undetectable() {
let pperp = vec![
vec![0.5, -0.5, 0.0],
vec![-0.5, 0.5, 0.0],
vec![0.0, 0.0, 1.0],
];
let block_dep = peer_signature(3, &[0, 1]);
let eff_dep = project_block(&pperp, &block_dep);
assert!(
col_norm(&eff_dep, 0) > 0.1 && col_norm(&eff_dep, 1) > 0.1,
"C3: both effective columns must be nonzero (naive test would say detectable): \
‖col0‖={:.3}, ‖col1‖={:.3}",
col_norm(&eff_dep, 0),
col_norm(&eff_dep, 1)
);
let cols_dep = stack_columns(&[eff_dep], &[0]);
assert_eq!(
effective_column_rank(&cols_dep, 1e-9),
1,
"C3: rank of the two effective columns must be 1 (they are anti-parallel)"
);
let cls_dep = byzantine_bound(&[block_dep], &pperp, 1e-9);
assert_eq!(
cls_dep,
ByzantineClass {
f_detect: 0,
f_identify: 0,
block_spark: 1
},
"C3: nonzero-but-rank-deficient single peer must be undetectable (block_spark=1)"
);
let block_full = peer_signature(3, &[0, 2]);
let eff_full = project_block(&pperp, &block_full);
let cols_full = stack_columns(&[eff_full], &[0]);
assert_eq!(
effective_column_rank(&cols_full, 1e-9),
2,
"contrast: independent effective columns must have full rank 2"
);
let cls_full = byzantine_bound(&[block_full], &pperp, 1e-9);
assert_eq!(
cls_full,
ByzantineClass {
f_detect: 1,
f_identify: 0,
block_spark: 2
},
"contrast: full-column-rank single peer is detectable (block_spark = M+1 = 2)"
);
}
fn node_incidence(layout: &NetworkLayout, k: usize) -> Vec<usize> {
let m = layout.n_nodes;
let mut inc = Vec::new();
let mut idx = 0_usize;
for a in 0..m {
for b in (a + 1)..m {
for _w in 0..PERNODE_ELAPSED_S.len() {
if a == k || b == k {
inc.push(idx);
}
idx += 1;
}
}
}
inc
}
#[test]
fn byzantine_real_network_uniform_bias_is_clock_offset() {
let nodes = pernode_body_nodes();
let layout = NetworkLayout::new(nodes.len());
let inertial = pernode_inertial(&nodes);
let (g, w) = pernode_g_and_w(&layout, &inertial);
let pperp = parity_projector(&g, &w);
let n_meas = g.len();
let inc = node_incidence(&layout, 2);
assert_eq!(
inc.len(),
(layout.n_nodes - 1) * PERNODE_ELAPSED_S.len(),
"node incidence must be (M−1)·windows measurements"
);
let block = peer_signature(n_meas, &inc);
let eff = project_block(&pperp, &block);
for c in 0..inc.len() {
assert!(
col_norm(&eff, c) > 1e-3,
"each individual measurement bias must be detectable: ‖col {c}‖ = {:.3e}",
col_norm(&eff, c)
);
}
let cols = stack_columns(&[eff], &[0]);
let total = cols.len();
let mut gram = vec![vec![0.0_f64; total]; total];
for (i, ci) in cols.iter().enumerate() {
for (j, cj) in cols.iter().enumerate() {
gram[i][j] = ci.iter().zip(cj.iter()).map(|(&a, &b)| a * b).sum();
}
}
let eig = crate::fim::sym_eig(&gram);
let lam_max = *eig.values.last().unwrap();
let rank = effective_column_rank(&cols, 1e-9);
assert!(
rank < total,
"block must be column-rank-deficient: rank {rank} vs {total} columns"
);
let null_ev = eig.values[total - rank - 1]; let obs_ev = eig.values[total - rank]; assert!(
null_ev < 1e-12 * lam_max,
"genuine null eigenvalue must be ≪ tol·λ_max: {null_ev:.3e} vs λ_max {lam_max:.3e}"
);
assert!(
obs_ev > 1e-6 * lam_max,
"smallest retained eigenvalue must be ≫ tol·λ_max: {obs_ev:.3e} vs λ_max {lam_max:.3e}"
);
let cls = byzantine_bound(&[block], &pperp, 1e-9);
assert_eq!(
cls.block_spark, 1,
"real-network uniform-bias peer: block_spark = 1"
);
assert_eq!(
cls.f_detect, 0,
"real-network uniform-bias peer: undetectable"
);
let single = peer_signature(n_meas, &[0]);
let cls_single = byzantine_bound(&[single], &pperp, 1e-9);
assert_eq!(
cls_single.block_spark, 2,
"single-measurement peer is full column rank ⇒ block_spark = 2"
);
assert_eq!(
cls_single.f_detect, 1,
"single-measurement peer is detectable"
);
}
#[test]
fn byzantine_redundant_net_identifies_at_least_one() {
let nodes = pernode_body_nodes();
let layout = NetworkLayout::new(nodes.len());
let inertial = pernode_inertial(&nodes);
let (g, w) = pernode_g_and_w(&layout, &inertial);
let pperp = parity_projector(&g, &w);
let n_meas = g.len();
let meas = [0_usize, 12, 24, 37, 49, 61];
let peers: Vec<Vec<Vec<f64>>> =
meas.iter().map(|&i| peer_signature(n_meas, &[i])).collect();
let cls = byzantine_bound(&peers, &pperp, 1e-9);
assert_eq!(
cls.block_spark,
peers.len() + 1,
"independent single-measurement peers admit no dependent coalition: block_spark = M+1"
);
assert_eq!(cls.f_detect, peers.len(), "all six peers detectable");
assert!(
cls.f_identify >= 1,
"redundant net must identify ≥ 1 faulty peer, got {}",
cls.f_identify
);
assert_eq!(
cls.f_identify, 3,
"identify bound = ⌊(block_spark−1)/2⌋ = ⌊6/2⌋ = 3, got {}",
cls.f_identify
);
}
#[test]
fn byzantine_analytic_spark_identity_projector() {
let ident = vec![
vec![1.0, 0.0, 0.0, 0.0],
vec![0.0, 1.0, 0.0, 0.0],
vec![0.0, 0.0, 1.0, 0.0],
vec![0.0, 0.0, 0.0, 1.0],
];
let shared = vec![
peer_signature(4, &[0]),
peer_signature(4, &[1]),
peer_signature(4, &[0]),
];
let cls_shared = byzantine_bound(&shared, &ident, 1e-9);
assert_eq!(
cls_shared,
ByzantineClass {
f_detect: 1,
f_identify: 0,
block_spark: 2
},
"two peers sharing a measurement collude at size 2 ⇒ block_spark = 2"
);
let indep = vec![
peer_signature(4, &[0]),
peer_signature(4, &[1]),
peer_signature(4, &[2]),
peer_signature(4, &[3]),
];
let cls_indep = byzantine_bound(&indep, &ident, 1e-9);
assert_eq!(
cls_indep,
ByzantineClass {
f_detect: 4,
f_identify: 2,
block_spark: 5
},
"four orthonormal peers admit no dependent coalition ⇒ block_spark = M+1 = 5"
);
}
#[test]
fn byzantine_detect_identify_relations() {
assert_eq!(combinations(4, 2).len(), 6);
assert_eq!(combinations(5, 3).len(), 10);
assert_eq!(combinations(3, 0).len(), 0);
assert_eq!(combinations(2, 3).len(), 0);
for subset in combinations(5, 3) {
assert!(
subset.windows(2).all(|w| w[0] < w[1]),
"subsets strictly increasing"
);
}
let ident: Vec<Vec<f64>> = (0..6)
.map(|i| (0..6).map(|j| if i == j { 1.0 } else { 0.0 }).collect())
.collect();
for m in 1..=6_usize {
let peers: Vec<Vec<Vec<f64>>> = (0..m).map(|i| peer_signature(6, &[i])).collect();
let cls = byzantine_bound(&peers, &ident, 1e-9);
assert_eq!(
cls.block_spark,
m + 1,
"distinct peers ⇒ block_spark = M+1 (M={m})"
);
assert_eq!(
cls.f_detect,
cls.block_spark - 1,
"f_detect = block_spark − 1"
);
assert_eq!(
cls.f_identify,
(cls.block_spark - 1) / 2,
"f_identify = ⌊(block_spark − 1)/2⌋"
);
}
}
#[allow(clippy::needless_range_loop)]
fn obs_projector(null_space: &[Vec<f64>]) -> Vec<Vec<f64>> {
let n = null_space.len();
if n == 0 {
return vec![];
}
let n_null = null_space[0].len();
let mut proj = vec![vec![0.0_f64; n]; n];
for i in 0..n {
proj[i][i] = 1.0;
}
for k in 0..n_null {
for i in 0..n {
for j in 0..n {
proj[i][j] -= null_space[i][k] * null_space[j][k];
}
}
}
proj
}
#[test]
fn holdover_floor_self_ref_and_anchored() {
let nodes = pernode_body_nodes();
let layout = NetworkLayout::new(nodes.len());
let inertial = pernode_inertial(&nodes);
let rows = pernode_rows(&layout, &inertial);
let info = assemble_pernode_info(&rows, layout.state_dim);
let gens = datum_gauge_generators(&layout, &inertial);
assert_eq!(gens.len(), N_DATUM_GAUGE, "eight gauge generators expected");
let floor_self = holdover_floor(&info, &gens, 1e-7);
assert!(
floor_self.rate_in_gauge,
"C2/T4: common rate must be in N(GᵀWG) for self-ref net — this IS the holdover floor"
);
assert_eq!(
floor_self.temporal_gauge_dim, 2,
"C2/T4: self-ref net has temporal_gauge_dim=2 (common offset + common rate both gauged)"
);
let mut anchored_rows = rows.clone();
let mut rate_tie = vec![0.0_f64; layout.state_dim];
rate_tie[layout.rate_idx(0)] = 1.0;
anchored_rows.push((rate_tie, 1.0));
let info_anchored = assemble_pernode_info(&anchored_rows, layout.state_dim);
let floor_anchored = holdover_floor(&info_anchored, &gens, 1e-7);
assert!(
!floor_anchored.rate_in_gauge,
"C2/T4: external rate tie must break holdover floor (rate NOT in gauge after tie)"
);
assert_eq!(
floor_anchored.temporal_gauge_dim, 1,
"C2/T4: after tying one node's absolute rate, the common rate leaves the gauge \
but the common offset survives, so temporal_gauge_dim = 1 (offset only)"
);
}
#[test]
fn slope_infinity_in_range_finite_detectable() {
let nodes = pernode_body_nodes();
let layout = NetworkLayout::new(nodes.len());
let inertial = pernode_inertial(&nodes);
let (g, w) = pernode_g_and_w(&layout, &inertial);
let pperp = parity_projector(&g, &w);
let state_dim = g[0].len();
let n_meas = g.len();
let mut ntm = vec![vec![0.0_f64; state_dim]; state_dim];
for (i, row) in g.iter().enumerate() {
let wi = w[i];
for p in 0..state_dim {
let jwi = row[p] * wi;
if jwi == 0.0 {
continue;
}
for q in 0..state_dim {
ntm[p][q] += jwi * row[q];
}
}
}
let crlb_r = crate::fim::crlb(&ntm, 1e-9);
let obs_proj = obs_projector(&crlb_r.null_space);
let mut x_in = vec![0.0_f64; state_dim];
x_in[layout.off_idx(1)] = 1.0;
let b_in: Vec<f64> = g
.iter()
.map(|row| row.iter().zip(x_in.iter()).map(|(&r, &xi)| r * xi).sum())
.collect();
let s_in = slope(&pperp, &w, &g, &b_in, &obs_proj);
assert!(
s_in > 1e6 || s_in.is_infinite(),
"g7: slope must be ∞ (or >1e6) for b ∈ range(G) — protection gap: got {s_in}"
);
let mut b_out = vec![0.0_f64; n_meas];
b_out[0] = 1.0;
let s_out = slope(&pperp, &w, &g, &b_out, &obs_proj);
assert!(
s_out.is_finite() && s_out > 0.0,
"g7: slope must be finite and positive for detectable fault b ∉ range(G): got {s_out}"
);
}
#[test]
fn provider_mismatch_split_common_undetectable_differential_detectable() {
let nodes = pernode_body_nodes();
let layout = NetworkLayout::new(nodes.len());
let inertial = pernode_inertial(&nodes);
let (g, w) = pernode_g_and_w(&layout, &inertial);
let pperp = parity_projector(&g, &w);
let state_dim = g[0].len();
let n_meas = g.len();
let mut x = vec![0.0_f64; state_dim];
x[layout.off_idx(2)] = 1.0;
let common_bias: Vec<f64> = g
.iter()
.map(|row| row.iter().zip(x.iter()).map(|(&r, &xi)| r * xi).sum())
.collect();
let mut diff_bias = vec![0.0_f64; n_meas];
diff_bias[0] = 1.0;
let split = provider_mismatch_split(&common_bias, &diff_bias, &pperp, 1e-8);
assert!(
!split.common_detectable,
"T5: common provider bias (∈ range(G)) must be UNDETECTABLE — needs external tie"
);
assert!(
split.differential_detectable,
"T5: differential provider bias (∉ range(G)) must be DETECTABLE — self-monitored"
);
}
}