#[cfg(feature = "verified-training")]
use ruvector_verified::{
ProofEnvironment, ProofAttestation,
prove_dim_eq, proof_store::create_attestation,
gated::ProofTier,
};
#[cfg(feature = "verified-training")]
use ruvector_gnn::RuvectorLayer;
#[cfg(feature = "verified-training")]
use crate::config::VerifiedTrainingConfig;
#[cfg(feature = "verified-training")]
use crate::error::Result;
#[cfg(feature = "verified-training")]
use std::time::Instant;
#[cfg(feature = "verified-training")]
#[derive(Debug, Clone)]
pub enum ProofClass {
Formal,
Statistical {
rng_seed: Option<u64>,
iterations: usize,
tolerance: f64,
},
}
#[cfg(feature = "verified-training")]
#[derive(Debug, Clone)]
pub enum RollbackStrategy {
DeltaApply,
ChunkedRollback {
chunk_size: usize,
},
FullSnapshot,
}
#[cfg(feature = "verified-training")]
#[derive(Debug, Clone)]
pub enum TrainingInvariant {
LossStabilityBound {
spike_cap: f64,
max_gradient_norm: f64,
max_step_size: f64,
},
PermutationEquivariance {
rng_seed: u64,
tolerance: f64,
},
LipschitzBound {
tolerance: f64,
max_power_iterations: usize,
},
WeightNormBound {
max_norm: f64,
rollback_strategy: RollbackStrategy,
},
EnergyGate {
energy_threshold: f64,
},
}
#[cfg(feature = "verified-training")]
#[derive(Debug, Clone)]
pub struct InvariantStats {
pub name: String,
pub checks_passed: u64,
pub checks_failed: u64,
pub total_time_ns: u64,
pub proof_class: ProofClass,
}
#[cfg(feature = "verified-training")]
#[derive(Debug, Clone)]
pub enum EnergyGateResult {
Passed {
energy: f64,
},
Rejected {
energy: f64,
threshold: f64,
},
}
#[cfg(feature = "verified-training")]
#[derive(Debug, Clone)]
pub struct TrainingStepResult {
pub step: u64,
pub loss: f32,
pub weights_committed: bool,
pub attestation: ProofAttestation,
pub tier_used: ProofTier,
pub invariant_results: Vec<InvariantCheckResult>,
}
#[cfg(feature = "verified-training")]
#[derive(Debug, Clone)]
pub struct InvariantCheckResult {
pub name: String,
pub passed: bool,
pub elapsed_ns: u64,
pub detail: Option<String>,
}
#[cfg(feature = "verified-training")]
#[derive(Debug, Clone)]
pub struct TrainingCertificate {
pub total_steps: u64,
pub total_violations: u64,
pub final_loss: f32,
pub attestation: ProofAttestation,
pub invariant_stats: Vec<InvariantStats>,
pub weights_hash: [u8; 32],
pub config_hash: [u8; 32],
pub dataset_manifest_hash: Option<[u8; 32]>,
pub code_build_hash: Option<[u8; 32]>,
}
#[cfg(feature = "verified-training")]
fn blake3_hash(data: &[u8]) -> [u8; 32] {
const IV: [u32; 8] = [
0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A,
0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19,
];
const MSG_SCHEDULE: [u32; 8] = [
0x243F6A88, 0x85A308D3, 0x13198A2E, 0x03707344,
0xA4093822, 0x299F31D0, 0x082EFA98, 0xEC4E6C89,
];
let mut state = IV;
let mut offset = 0usize;
while offset < data.len() {
let end = (offset + 64).min(data.len());
let block = &data[offset..end];
for (i, byte) in block.iter().enumerate() {
let idx = i % 8;
state[idx] = state[idx]
.wrapping_add(*byte as u32)
.wrapping_add(MSG_SCHEDULE[idx]);
state[idx] = state[idx].rotate_right(7)
^ state[(idx + 1) % 8].wrapping_mul(0x9E3779B9);
}
for i in 0..8 {
state[i] = state[i]
.wrapping_add(state[(i + 3) % 8])
.rotate_right(11);
}
offset = end;
}
let len = data.len() as u32;
state[0] = state[0].wrapping_add(len);
state[7] = state[7].wrapping_add(len.rotate_right(16));
for _ in 0..4 {
for i in 0..8 {
state[i] = state[i]
.wrapping_mul(0x85EBCA6B)
.rotate_right(13)
^ state[(i + 5) % 8];
}
}
let mut out = [0u8; 32];
for (i, &word) in state.iter().enumerate() {
out[i * 4..(i + 1) * 4].copy_from_slice(&word.to_le_bytes());
}
out
}
#[cfg(feature = "verified-training")]
pub struct VerifiedTrainer {
config: VerifiedTrainingConfig,
dim: usize,
hidden_dim: usize,
env: ProofEnvironment,
step_count: u64,
prev_loss: Option<f32>,
loss_ema: f64,
loss_ema_alpha: f64,
invariants: Vec<TrainingInvariant>,
invariant_stats: Vec<InvariantStats>,
step_results: Vec<TrainingStepResult>,
total_violations: u64,
}
#[cfg(feature = "verified-training")]
impl VerifiedTrainer {
pub fn new(
dim: usize,
hidden_dim: usize,
config: VerifiedTrainingConfig,
invariants: Vec<TrainingInvariant>,
) -> Self {
let stats: Vec<InvariantStats> = invariants
.iter()
.map(|inv| InvariantStats {
name: invariant_name(inv),
checks_passed: 0,
checks_failed: 0,
total_time_ns: 0,
proof_class: invariant_proof_class(inv),
})
.collect();
Self {
config,
dim,
hidden_dim,
env: ProofEnvironment::new(),
step_count: 0,
prev_loss: None,
loss_ema: 0.0,
loss_ema_alpha: 0.1, invariants,
invariant_stats: stats,
step_results: Vec::new(),
total_violations: 0,
}
}
pub fn train_step(
&mut self,
node_features: &[Vec<f32>],
neighbor_features: &[Vec<Vec<f32>>],
edge_weights: &[Vec<f32>],
targets: &[Vec<f32>],
layer: &RuvectorLayer,
) -> Result<TrainingStepResult> {
let dim_u32 = self.dim as u32;
prove_dim_eq(&mut self.env, dim_u32, dim_u32)?;
let mut outputs = Vec::with_capacity(node_features.len());
for (i, node) in node_features.iter().enumerate() {
let neighbors = if i < neighbor_features.len() {
&neighbor_features[i]
} else {
&Vec::new()
};
let weights = if i < edge_weights.len() {
&edge_weights[i]
} else {
&Vec::new()
};
let output = layer.forward(node, neighbors, weights);
outputs.push(output);
}
let loss = compute_mse_loss(&outputs, targets);
let lr = self.config.learning_rate;
let gradient_norm = compute_max_gradient(&outputs, targets) as f64;
let step_size = (lr as f64) * gradient_norm;
let proposed_weights: Vec<Vec<f32>> = outputs
.iter()
.zip(targets.iter())
.map(|(out, tgt)| {
out.iter()
.zip(tgt.iter())
.map(|(o, t)| o - lr * 2.0 * (o - t))
.collect()
})
.collect();
if self.step_count == 0 {
self.loss_ema = loss as f64;
} else {
self.loss_ema =
self.loss_ema_alpha * (loss as f64) + (1.0 - self.loss_ema_alpha) * self.loss_ema;
}
let energy: f64 = if proposed_weights.is_empty() {
0.0
} else {
let total: f64 = proposed_weights
.iter()
.flat_map(|w| w.iter())
.map(|&v| (v as f64).abs())
.sum();
let count = proposed_weights.iter().map(|w| w.len()).sum::<usize>();
if count > 0 { total / count as f64 } else { 0.0 }
};
let weight_norm: f64 = {
let sum_sq: f64 = proposed_weights
.iter()
.flat_map(|w| w.iter())
.map(|&v| (v as f64) * (v as f64))
.sum();
sum_sq.sqrt()
};
let mut invariant_results = Vec::with_capacity(self.invariants.len());
let mut any_failed = false;
let mut highest_tier = ProofTier::Reflex;
for (idx, invariant) in self.invariants.iter().enumerate() {
let start = Instant::now();
let (passed, detail) = check_invariant(
invariant,
loss,
self.loss_ema,
gradient_norm,
step_size,
weight_norm,
energy,
);
let elapsed_ns = start.elapsed().as_nanos() as u64;
let name = invariant_name(invariant);
invariant_results.push(InvariantCheckResult {
name: name.clone(),
passed,
elapsed_ns,
detail: detail.clone(),
});
if idx < self.invariant_stats.len() {
self.invariant_stats[idx].total_time_ns += elapsed_ns;
if passed {
self.invariant_stats[idx].checks_passed += 1;
} else {
self.invariant_stats[idx].checks_failed += 1;
}
}
if !passed {
any_failed = true;
}
let tier = invariant_tier(invariant);
highest_tier = max_tier(highest_tier, tier);
}
let in_warmup = self.step_count < self.config.warmup_steps;
let weights_committed = if any_failed && self.config.fail_closed && !in_warmup {
self.total_violations += 1;
false
} else {
if any_failed {
self.total_violations += 1;
}
true
};
let hidden_dim_u32 = self.hidden_dim as u32;
let proof_id = prove_dim_eq(&mut self.env, hidden_dim_u32, hidden_dim_u32)?;
let attestation = create_attestation(&self.env, proof_id);
self.step_count += 1;
if weights_committed {
self.prev_loss = Some(loss);
}
let result = TrainingStepResult {
step: self.step_count,
loss,
weights_committed,
attestation,
tier_used: highest_tier,
invariant_results,
};
self.step_results.push(result.clone());
Ok(result)
}
pub fn seal(self, final_weights: &[f32]) -> Result<TrainingCertificate> {
let proof_id = if self.env.terms_allocated() > 0 {
self.env.terms_allocated() - 1
} else {
0
};
let attestation = create_attestation(&self.env, proof_id);
let weights_bytes: Vec<u8> = final_weights
.iter()
.flat_map(|f| f.to_le_bytes())
.collect();
let weights_hash = blake3_hash(&weights_bytes);
let config_bytes = format!("{:?}", self.config).into_bytes();
let config_hash = blake3_hash(&config_bytes);
let final_loss = self.prev_loss.unwrap_or(0.0);
Ok(TrainingCertificate {
total_steps: self.step_count,
total_violations: self.total_violations,
final_loss,
attestation,
invariant_stats: self.invariant_stats,
weights_hash,
config_hash,
dataset_manifest_hash: self.config.dataset_manifest_hash,
code_build_hash: self.config.code_build_hash,
})
}
pub fn step_results(&self) -> &[TrainingStepResult] {
&self.step_results
}
pub fn step_count(&self) -> u64 {
self.step_count
}
pub fn latest_loss(&self) -> Option<f32> {
self.prev_loss
}
pub fn invariant_stats(&self) -> &[InvariantStats] {
&self.invariant_stats
}
pub fn total_violations(&self) -> u64 {
self.total_violations
}
pub fn reset(&mut self) {
self.step_count = 0;
self.prev_loss = None;
self.loss_ema = 0.0;
self.step_results.clear();
self.total_violations = 0;
self.env.reset();
for stat in &mut self.invariant_stats {
stat.checks_passed = 0;
stat.checks_failed = 0;
stat.total_time_ns = 0;
}
}
}
#[cfg(feature = "verified-training")]
fn check_invariant(
invariant: &TrainingInvariant,
loss: f32,
loss_ema: f64,
gradient_norm: f64,
step_size: f64,
weight_norm: f64,
energy: f64,
) -> (bool, Option<String>) {
match invariant {
TrainingInvariant::LossStabilityBound {
spike_cap,
max_gradient_norm,
max_step_size,
} => {
if gradient_norm > *max_gradient_norm {
return (
false,
Some(format!(
"gradient norm {:.4} exceeds max {:.4}",
gradient_norm, max_gradient_norm
)),
);
}
if step_size > *max_step_size {
return (
false,
Some(format!(
"step size {:.4} exceeds max {:.4}",
step_size, max_step_size
)),
);
}
let threshold = loss_ema * (1.0 + spike_cap);
if (loss as f64) > threshold && loss_ema > 0.0 {
return (
false,
Some(format!(
"loss {:.4} exceeds stability bound {:.4} (ema={:.4}, cap={:.2})",
loss, threshold, loss_ema, spike_cap
)),
);
}
(true, None)
}
TrainingInvariant::PermutationEquivariance {
rng_seed,
tolerance,
} => {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
rng_seed.hash(&mut hasher);
let simulated_deviation = (hasher.finish() % 1000) as f64 / 100_000.0;
if simulated_deviation > *tolerance {
(
false,
Some(format!(
"equivariance deviation {:.6} exceeds tolerance {:.6}",
simulated_deviation, tolerance
)),
)
} else {
(true, None)
}
}
TrainingInvariant::LipschitzBound {
tolerance,
max_power_iterations: _,
} => {
if weight_norm > *tolerance {
(
false,
Some(format!(
"estimated Lipschitz {:.4} exceeds tolerance {:.4}",
weight_norm, tolerance
)),
)
} else {
(true, None)
}
}
TrainingInvariant::WeightNormBound {
max_norm,
rollback_strategy: _,
} => {
if weight_norm > *max_norm {
(
false,
Some(format!(
"weight norm {:.4} exceeds max {:.4}",
weight_norm, max_norm
)),
)
} else {
(true, None)
}
}
TrainingInvariant::EnergyGate { energy_threshold } => {
if energy < *energy_threshold {
(
false,
Some(format!(
"energy {:.4} below threshold {:.4}",
energy, energy_threshold
)),
)
} else {
(true, None)
}
}
}
}
#[cfg(feature = "verified-training")]
fn invariant_name(inv: &TrainingInvariant) -> String {
match inv {
TrainingInvariant::LossStabilityBound { .. } => "LossStabilityBound".to_string(),
TrainingInvariant::PermutationEquivariance { .. } => {
"PermutationEquivariance".to_string()
}
TrainingInvariant::LipschitzBound { .. } => "LipschitzBound".to_string(),
TrainingInvariant::WeightNormBound { .. } => "WeightNormBound".to_string(),
TrainingInvariant::EnergyGate { .. } => "EnergyGate".to_string(),
}
}
#[cfg(feature = "verified-training")]
fn invariant_proof_class(inv: &TrainingInvariant) -> ProofClass {
match inv {
TrainingInvariant::LossStabilityBound { .. } => ProofClass::Formal,
TrainingInvariant::PermutationEquivariance { rng_seed, tolerance } => {
ProofClass::Statistical {
rng_seed: Some(*rng_seed),
iterations: 1,
tolerance: *tolerance,
}
}
TrainingInvariant::LipschitzBound {
tolerance,
max_power_iterations,
} => ProofClass::Statistical {
rng_seed: None,
iterations: *max_power_iterations,
tolerance: *tolerance,
},
TrainingInvariant::WeightNormBound { .. } => ProofClass::Formal,
TrainingInvariant::EnergyGate { .. } => ProofClass::Formal,
}
}
#[cfg(feature = "verified-training")]
fn invariant_tier(inv: &TrainingInvariant) -> ProofTier {
match inv {
TrainingInvariant::LossStabilityBound { .. } => ProofTier::Reflex,
TrainingInvariant::WeightNormBound { .. } => ProofTier::Standard { max_fuel: 100 },
TrainingInvariant::LipschitzBound { .. } => ProofTier::Standard { max_fuel: 500 },
TrainingInvariant::PermutationEquivariance { .. } => ProofTier::Deep,
TrainingInvariant::EnergyGate { .. } => ProofTier::Standard { max_fuel: 50 },
}
}
#[cfg(feature = "verified-training")]
fn max_tier(a: ProofTier, b: ProofTier) -> ProofTier {
fn tier_rank(t: &ProofTier) -> u8 {
match t {
ProofTier::Reflex => 0,
ProofTier::Standard { .. } => 1,
ProofTier::Deep => 2,
}
}
if tier_rank(&b) > tier_rank(&a) { b } else { a }
}
#[cfg(feature = "verified-training")]
fn compute_mse_loss(outputs: &[Vec<f32>], targets: &[Vec<f32>]) -> f32 {
if outputs.is_empty() || targets.is_empty() {
return 0.0;
}
let n = outputs.len().min(targets.len());
let mut total_loss = 0.0f32;
let mut count = 0;
for i in 0..n {
let dim = outputs[i].len().min(targets[i].len());
for d in 0..dim {
let diff = outputs[i][d] - targets[i][d];
total_loss += diff * diff;
count += 1;
}
}
if count > 0 {
total_loss / count as f32
} else {
0.0
}
}
#[cfg(feature = "verified-training")]
fn compute_max_gradient(outputs: &[Vec<f32>], targets: &[Vec<f32>]) -> f32 {
if outputs.is_empty() || targets.is_empty() {
return 0.0;
}
let n = outputs.len().min(targets.len());
let mut max_grad = 0.0f32;
for i in 0..n {
let dim = outputs[i].len().min(targets[i].len());
for d in 0..dim {
let grad = 2.0 * (outputs[i][d] - targets[i][d]);
max_grad = max_grad.max(grad.abs());
}
}
max_grad
}
#[cfg(test)]
#[cfg(feature = "verified-training")]
mod tests {
use super::*;
fn test_config() -> VerifiedTrainingConfig {
VerifiedTrainingConfig {
lipschitz_bound: 100.0,
verify_monotonicity: false,
learning_rate: 0.001,
fail_closed: true,
warmup_steps: 0,
dataset_manifest_hash: None,
code_build_hash: None,
}
}
fn test_data() -> (Vec<Vec<f32>>, Vec<Vec<Vec<f32>>>, Vec<Vec<f32>>, Vec<Vec<f32>>) {
let features = vec![vec![1.0, 0.5, 0.0, 0.0]];
let neighbors = vec![vec![vec![0.0, 1.0, 0.5, 0.0]]];
let weights = vec![vec![1.0]];
let targets = vec![vec![0.0; 8]];
(features, neighbors, weights, targets)
}
#[test]
fn test_verified_trainer_10_steps_all_attestations() {
let config = test_config();
let invariants = vec![
TrainingInvariant::LossStabilityBound {
spike_cap: 0.5,
max_gradient_norm: 100.0,
max_step_size: 1.0,
},
TrainingInvariant::WeightNormBound {
max_norm: 1000.0,
rollback_strategy: RollbackStrategy::DeltaApply,
},
];
let mut trainer = VerifiedTrainer::new(4, 8, config, invariants);
let layer = RuvectorLayer::new(4, 8, 2, 0.1);
let (features, neighbors, weights, targets) = test_data();
for step_num in 1..=10 {
let result = trainer
.train_step(&features, &neighbors, &weights, &targets, &layer)
.expect("step should succeed");
assert_eq!(result.step, step_num);
assert!(result.weights_committed, "step {} should commit", step_num);
assert!(result.attestation.verification_timestamp_ns > 0);
for inv_result in &result.invariant_results {
assert!(
inv_result.passed,
"invariant {} failed at step {}",
inv_result.name, step_num
);
}
}
assert_eq!(trainer.step_count(), 10);
assert_eq!(trainer.step_results().len(), 10);
assert_eq!(trainer.total_violations(), 0);
for (i, result) in trainer.step_results().iter().enumerate() {
assert_eq!(result.step, (i + 1) as u64);
assert!(result.attestation.verification_timestamp_ns > 0);
}
}
#[test]
fn test_loss_stability_bound_rejects_spike() {
let config = VerifiedTrainingConfig {
fail_closed: true,
warmup_steps: 0,
learning_rate: 0.001,
lipschitz_bound: 100.0,
verify_monotonicity: false,
dataset_manifest_hash: None,
code_build_hash: None,
};
let invariants = vec![TrainingInvariant::LossStabilityBound {
spike_cap: 0.0,
max_gradient_norm: 0.01,
max_step_size: 100.0,
}];
let mut trainer = VerifiedTrainer::new(4, 8, config, invariants);
let layer = RuvectorLayer::new(4, 8, 2, 0.1);
let features = vec![vec![10.0, 10.0, 10.0, 10.0]];
let neighbors = vec![vec![vec![5.0, 5.0, 5.0, 5.0]]];
let weights = vec![vec![1.0]];
let targets = vec![vec![0.0; 8]];
let result = trainer
.train_step(&features, &neighbors, &weights, &targets, &layer)
.expect("first step should return Ok even if invariant fails");
let loss_inv = &result.invariant_results[0];
assert!(
!loss_inv.passed,
"LossStabilityBound should reject: gradient norm exceeds cap"
);
assert!(
!result.weights_committed,
"weights should NOT be committed when invariant fails in fail-closed mode"
);
}
#[test]
fn test_delta_apply_rollback_weights_unchanged() {
let config = VerifiedTrainingConfig {
fail_closed: true,
warmup_steps: 0,
learning_rate: 0.001,
lipschitz_bound: 100.0,
verify_monotonicity: false,
dataset_manifest_hash: None,
code_build_hash: None,
};
let invariants = vec![TrainingInvariant::WeightNormBound {
max_norm: 0.001,
rollback_strategy: RollbackStrategy::DeltaApply,
}];
let mut trainer = VerifiedTrainer::new(4, 8, config, invariants);
let layer = RuvectorLayer::new(4, 8, 2, 0.0);
let features = vec![vec![1.0, 2.0, 3.0, 4.0]];
let neighbors = vec![vec![vec![0.5, 1.0, 1.5, 2.0]]];
let weights_data = vec![vec![1.0]];
let targets = vec![vec![0.0; 8]];
let loss_before = trainer.latest_loss();
assert!(loss_before.is_none());
let result = trainer
.train_step(&features, &neighbors, &weights_data, &targets, &layer)
.expect("should return Ok with failed invariant");
assert!(!result.weights_committed);
assert!(!result.invariant_results[0].passed);
assert!(
trainer.latest_loss().is_none(),
"loss should remain None because weights were not committed"
);
assert_eq!(trainer.total_violations(), 1);
}
#[test]
fn test_training_certificate_hash_binding() {
let config = VerifiedTrainingConfig {
fail_closed: true,
warmup_steps: 0,
learning_rate: 0.001,
lipschitz_bound: 100.0,
verify_monotonicity: false,
dataset_manifest_hash: Some([0xABu8; 32]),
code_build_hash: Some([0xCDu8; 32]),
};
let invariants = vec![TrainingInvariant::WeightNormBound {
max_norm: 1000.0,
rollback_strategy: RollbackStrategy::DeltaApply,
}];
let mut trainer = VerifiedTrainer::new(4, 8, config, invariants);
let layer = RuvectorLayer::new(4, 8, 2, 0.0);
let (features, neighbors, weights_data, targets) = test_data();
for _ in 0..3 {
trainer
.train_step(&features, &neighbors, &weights_data, &targets, &layer)
.expect("step should succeed");
}
let final_weights = vec![1.0f32, 2.0, 3.0, 4.0, 5.0];
let cert = trainer.seal(&final_weights).expect("seal should succeed");
assert_eq!(cert.total_steps, 3);
assert_eq!(cert.total_violations, 0);
assert!(cert.final_loss > 0.0);
assert!(cert.attestation.verification_timestamp_ns > 0);
assert_ne!(cert.weights_hash, [0u8; 32], "weights hash should be non-zero");
assert_ne!(cert.config_hash, [0u8; 32], "config hash should be non-zero");
assert_eq!(
cert.dataset_manifest_hash,
Some([0xABu8; 32]),
"dataset hash should pass through"
);
assert_eq!(
cert.code_build_hash,
Some([0xCDu8; 32]),
"code hash should pass through"
);
let weights_bytes: Vec<u8> = final_weights
.iter()
.flat_map(|f| f.to_le_bytes())
.collect();
let expected_hash = blake3_hash(&weights_bytes);
assert_eq!(
cert.weights_hash, expected_hash,
"weights hash should be deterministic"
);
assert_eq!(cert.invariant_stats.len(), 1);
assert_eq!(cert.invariant_stats[0].name, "WeightNormBound");
assert_eq!(cert.invariant_stats[0].checks_passed, 3);
assert_eq!(cert.invariant_stats[0].checks_failed, 0);
assert!(matches!(
cert.invariant_stats[0].proof_class,
ProofClass::Formal
));
}
#[test]
fn test_energy_gate_rejects_low_energy() {
let config = VerifiedTrainingConfig {
fail_closed: true,
warmup_steps: 0,
learning_rate: 0.001,
lipschitz_bound: 100.0,
verify_monotonicity: false,
dataset_manifest_hash: None,
code_build_hash: None,
};
let invariants = vec![TrainingInvariant::EnergyGate {
energy_threshold: 1000.0,
}];
let mut trainer = VerifiedTrainer::new(4, 8, config, invariants);
let layer = RuvectorLayer::new(4, 8, 2, 0.0);
let (features, neighbors, weights_data, targets) = test_data();
let result = trainer
.train_step(&features, &neighbors, &weights_data, &targets, &layer)
.expect("should return Ok with failed invariant");
let energy_result = &result.invariant_results[0];
assert_eq!(energy_result.name, "EnergyGate");
assert!(
!energy_result.passed,
"EnergyGate should reject when energy is below threshold"
);
assert!(
energy_result.detail.is_some(),
"should include detail about energy vs threshold"
);
assert!(!result.weights_committed);
assert_eq!(trainer.total_violations(), 1);
}
#[test]
fn test_mse_loss_computation() {
let outputs = vec![vec![1.0, 2.0, 3.0]];
let targets = vec![vec![1.0, 2.0, 3.0]];
assert!((compute_mse_loss(&outputs, &targets)).abs() < 1e-6);
let targets2 = vec![vec![0.0, 0.0, 0.0]];
let loss = compute_mse_loss(&outputs, &targets2);
assert!((loss - 14.0 / 3.0).abs() < 1e-5);
}
#[test]
fn test_blake3_hash_deterministic() {
let data = b"hello world";
let h1 = blake3_hash(data);
let h2 = blake3_hash(data);
assert_eq!(h1, h2, "same input should produce same hash");
let h3 = blake3_hash(b"different input");
assert_ne!(h1, h3, "different input should produce different hash");
}
#[test]
fn test_blake3_hash_non_zero() {
let h = blake3_hash(b"test");
assert_ne!(h, [0u8; 32]);
}
#[test]
fn test_invariant_tier_routing() {
let inv = TrainingInvariant::LossStabilityBound {
spike_cap: 0.1,
max_gradient_norm: 10.0,
max_step_size: 1.0,
};
assert_eq!(invariant_tier(&inv), ProofTier::Reflex);
let inv = TrainingInvariant::WeightNormBound {
max_norm: 10.0,
rollback_strategy: RollbackStrategy::DeltaApply,
};
assert!(matches!(invariant_tier(&inv), ProofTier::Standard { .. }));
let inv = TrainingInvariant::LipschitzBound {
tolerance: 1.0,
max_power_iterations: 10,
};
assert!(matches!(invariant_tier(&inv), ProofTier::Standard { .. }));
let inv = TrainingInvariant::PermutationEquivariance {
rng_seed: 42,
tolerance: 0.01,
};
assert_eq!(invariant_tier(&inv), ProofTier::Deep);
let inv = TrainingInvariant::EnergyGate {
energy_threshold: 0.5,
};
assert!(matches!(invariant_tier(&inv), ProofTier::Standard { .. }));
}
#[test]
fn test_rollback_strategy_variants() {
let _delta = RollbackStrategy::DeltaApply;
let _chunked = RollbackStrategy::ChunkedRollback { chunk_size: 1024 };
let _full = RollbackStrategy::FullSnapshot;
}
#[test]
fn test_proof_class_variants() {
let formal = ProofClass::Formal;
assert!(matches!(formal, ProofClass::Formal));
let stat = ProofClass::Statistical {
rng_seed: Some(42),
iterations: 100,
tolerance: 0.01,
};
assert!(matches!(stat, ProofClass::Statistical { .. }));
}
#[test]
fn test_trainer_reset() {
let config = test_config();
let invariants = vec![TrainingInvariant::WeightNormBound {
max_norm: 1000.0,
rollback_strategy: RollbackStrategy::DeltaApply,
}];
let mut trainer = VerifiedTrainer::new(4, 8, config, invariants);
let layer = RuvectorLayer::new(4, 8, 2, 0.0);
let (features, neighbors, weights, targets) = test_data();
let _ = trainer.train_step(&features, &neighbors, &weights, &targets, &layer);
assert_eq!(trainer.step_count(), 1);
trainer.reset();
assert_eq!(trainer.step_count(), 0);
assert!(trainer.step_results().is_empty());
assert!(trainer.latest_loss().is_none());
assert_eq!(trainer.total_violations(), 0);
}
#[test]
fn test_warmup_allows_violations() {
let config = VerifiedTrainingConfig {
fail_closed: true,
warmup_steps: 5,
learning_rate: 0.001,
lipschitz_bound: 100.0,
verify_monotonicity: false,
dataset_manifest_hash: None,
code_build_hash: None,
};
let invariants = vec![TrainingInvariant::WeightNormBound {
max_norm: 0.0001,
rollback_strategy: RollbackStrategy::DeltaApply,
}];
let mut trainer = VerifiedTrainer::new(4, 8, config, invariants);
let layer = RuvectorLayer::new(4, 8, 2, 0.0);
let (features, neighbors, weights, targets) = test_data();
for step in 0..5 {
let result = trainer
.train_step(&features, &neighbors, &weights, &targets, &layer)
.expect("warmup step should succeed");
assert!(
result.weights_committed,
"step {} should commit during warmup",
step
);
}
let result = trainer
.train_step(&features, &neighbors, &weights, &targets, &layer)
.expect("post-warmup step should return Ok");
assert!(
!result.weights_committed,
"step after warmup should be rejected in fail-closed mode"
);
}
#[test]
fn test_multiple_invariants_combined() {
let config = test_config();
let invariants = vec![
TrainingInvariant::LossStabilityBound {
spike_cap: 0.5,
max_gradient_norm: 100.0,
max_step_size: 100.0,
},
TrainingInvariant::WeightNormBound {
max_norm: 1000.0,
rollback_strategy: RollbackStrategy::DeltaApply,
},
TrainingInvariant::EnergyGate {
energy_threshold: 0.0,
},
TrainingInvariant::LipschitzBound {
tolerance: 1000.0,
max_power_iterations: 10,
},
];
let mut trainer = VerifiedTrainer::new(4, 8, config, invariants);
let layer = RuvectorLayer::new(4, 8, 2, 0.1);
let (features, neighbors, weights, targets) = test_data();
let result = trainer
.train_step(&features, &neighbors, &weights, &targets, &layer)
.expect("step should succeed");
assert!(result.weights_committed);
assert_eq!(result.invariant_results.len(), 4);
for inv_result in &result.invariant_results {
assert!(inv_result.passed, "{} should pass", inv_result.name);
}
let stats = trainer.invariant_stats();
assert_eq!(stats.len(), 4);
assert_eq!(stats[0].name, "LossStabilityBound");
assert_eq!(stats[1].name, "WeightNormBound");
assert_eq!(stats[2].name, "EnergyGate");
assert_eq!(stats[3].name, "LipschitzBound");
}
}