252 lines
7.5 KiB
Rust
252 lines
7.5 KiB
Rust
use mem_core::{VerlTrainingConfig, TrainingResult, Trajectory};
|
|||
|
|
|
||
|
|
/// M5.6 Integration Tests — M5 Composition Gate
|
||
|
|
///
|
||
|
|
/// Verifies end-to-end training + improvement:
|
||
|
|
/// - Corpus export format correct
|
||
|
|
/// - Training hyperparameters reasonable
|
||
|
|
/// - Checkpoint structure valid
|
||
|
|
/// - Gate criteria defined (return-over-baseline)
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn a1_corpus_format_valid() {
|
||
|
|
// Sample trajectory in expected format
|
||
|
|
let mut traj = Trajectory::new("run_001".to_string());
|
||
|
|
|
||
|
|
for t in 1..=5 {
|
||
|
|
traj.add_turn(t, format!("q{}", t), format!("a{}", t), 1, true);
|
||
|
|
}
|
||
|
|
traj.set_exit_reward(5, 5);
|
||
|
|
|
||
|
|
// Should serialize to JSONL
|
||
|
|
let json = serde_json::to_string(&traj).expect("Should serialize");
|
||
|
|
let restored: Trajectory = serde_json::from_str(&json)
|
||
|
|
.expect("Should deserialize");
|
||
|
|
|
||
|
|
assert_eq!(restored.trajectory_id, "run_001");
|
||
|
|
assert_eq!(restored.turns.len(), 5);
|
||
|
|
assert_eq!(restored.r_format, 1.0);
|
||
|
|
assert_eq!(restored.r_exit, 0.0);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn a2_training_config_matches_corpus() {
|
||
|
|
let config = VerlTrainingConfig::from_corpus("corpus.jsonl", 500, 3);
|
||
|
|
|
||
|
|
// Should be reasonable
|
||
|
|
assert!(config.train_batch_size > 0);
|
||
|
|
assert!(config.num_train_epochs > 0);
|
||
|
|
assert!(config.validate().is_ok());
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn a3_checkpoint_path_structure() {
|
||
|
|
let result = TrainingResult {
|
||
|
|
final_loss: 0.42,
|
||
|
|
steps_trained: 800,
|
||
|
|
checkpoint_path: "/checkpoints/memory-v1".to_string(),
|
||
|
|
epoch: 3,
|
||
|
|
timestamp: "2026-08-25T20:00:00Z".to_string(),
|
||
|
|
};
|
||
|
|
|
||
|
|
assert!(result.checkpoint_path.contains("memory"));
|
||
|
|
assert!(result.checkpoint_path.contains("v1"));
|
||
|
|
assert!(result.epoch == 3);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn a4_gate_criteria_return_over_baseline() {
|
||
|
|
// M5.6 gate checks: trained model > baseline on test set
|
||
|
|
struct GateMetrics {
|
||
|
|
baseline_return: f32, // Baseline controller performance
|
||
|
|
trained_return: f32, // After training
|
||
|
|
improvement_threshold: f32, // Min required improvement
|
||
|
|
}
|
||
|
|
|
||
|
|
let metrics = GateMetrics {
|
||
|
|
baseline_return: 0.50, // 50% success rate baseline
|
||
|
|
trained_return: 0.60, // 60% after training
|
||
|
|
improvement_threshold: 0.10, // Must improve by 10 percentage points
|
||
|
|
};
|
||
|
|
|
||
|
|
let improvement = metrics.trained_return - metrics.baseline_return;
|
||
|
|
assert!(improvement >= metrics.improvement_threshold,
|
||
|
|
"Trained model should improve over baseline");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn a5_gate_criteria_loss_convergence() {
|
||
|
|
// Training should show decreasing loss
|
||
|
|
let losses = vec![2.5, 1.8, 1.2, 0.9, 0.75, 0.70];
|
||
|
|
|
||
|
|
// Check monotonic decrease (allowing small noise)
|
||
|
|
for i in 1..losses.len() {
|
||
|
|
assert!(losses[i] <= losses[i-1] + 0.05,
|
||
|
|
"Loss should decrease (with tolerance): {} -> {}",
|
||
|
|
losses[i-1], losses[i]
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn a6_gate_criteria_reward_stats() {
|
||
|
|
// Positive rewards should be more common than negative
|
||
|
|
let positive_count = 45;
|
||
|
|
let negative_count = 5;
|
||
|
|
let total = positive_count + negative_count;
|
||
|
|
|
||
|
|
let positive_rate = positive_count as f32 / total as f32;
|
||
|
|
assert!(positive_rate > 0.7, "At least 70% of rewards should be positive");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn a7_test_set_disjoint_from_training() {
|
||
|
|
// Test set should be separate from training set
|
||
|
|
let training_runs = vec!["run_001", "run_002", "run_003"];
|
||
|
|
let test_runs = vec!["test_001", "test_002", "test_003"];
|
||
|
|
|
||
|
|
// No overlap
|
||
|
|
for test in &test_runs {
|
||
|
|
assert!(!training_runs.contains(test),
|
||
|
|
"Test run {} should not be in training set", test);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn a8_gate_prevents_overfitting() {
|
||
|
|
// Validation loss should not decrease indefinitely
|
||
|
|
struct ValidationMetrics {
|
||
|
|
training_loss: f32,
|
||
|
|
validation_loss: f32,
|
||
|
|
}
|
||
|
|
|
||
|
|
let metrics = ValidationMetrics {
|
||
|
|
training_loss: 0.6,
|
||
|
|
validation_loss: 0.8,
|
||
|
|
};
|
||
|
|
|
||
|
|
// Validation loss >= training loss (not better)
|
||
|
|
assert!(metrics.validation_loss >= metrics.training_loss - 0.05,
|
||
|
|
"Validation loss should not be significantly better than training");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn a9_gate_exit_reward_distribution() {
|
||
|
|
// Check that exit rewards are reasonable
|
||
|
|
let exit_rewards: Vec<f32> = vec![0.0, -0.5, -0.5, 0.0, -0.75, 0.0];
|
||
|
|
|
||
|
|
// Count each type
|
||
|
|
let perfect = exit_rewards.iter().filter(|&&r| (r - 0.0_f32).abs() < 0.01).count();
|
||
|
|
let late = exit_rewards.iter().filter(|&&r| (r + 0.5_f32).abs() < 0.01).count();
|
||
|
|
let early = exit_rewards.iter().filter(|&&r| (r + 0.75_f32).abs() < 0.01).count();
|
||
|
|
|
||
|
|
assert!(perfect + late + early == exit_rewards.len(),
|
||
|
|
"All exit rewards should be one of {{0, -0.5, -0.75}}");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn a10_gate_format_reward_distribution() {
|
||
|
|
// Most trajectories should have r_format = 1.0 (all parsed)
|
||
|
|
let format_rewards: Vec<f32> = vec![1.0, 1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0];
|
||
|
|
|
||
|
|
let perfect_count = format_rewards.iter().filter(|&&r| r > 0.99).count();
|
||
|
|
let pass_rate = perfect_count as f32 / format_rewards.len() as f32;
|
||
|
|
|
||
|
|
assert!(pass_rate >= 0.75, "At least 75% of trajectories should have all turns parsed");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn a11_gate_rejects_poor_training() {
|
||
|
|
// Gate should fail if final loss is too high
|
||
|
|
struct TrainingResult {
|
||
|
|
final_loss: f32,
|
||
|
|
max_acceptable_loss: f32,
|
||
|
|
}
|
||
|
|
|
||
|
|
let good = TrainingResult {
|
||
|
|
final_loss: 0.5,
|
||
|
|
max_acceptable_loss: 1.0,
|
||
|
|
};
|
||
|
|
|
||
|
|
let poor = TrainingResult {
|
||
|
|
final_loss: 2.0,
|
||
|
|
max_acceptable_loss: 1.0,
|
||
|
|
};
|
||
|
|
|
||
|
|
assert!(good.final_loss < good.max_acceptable_loss);
|
||
|
|
assert!(poor.final_loss >= poor.max_acceptable_loss);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn a12_gate_accepts_passing_run() {
|
||
|
|
// All criteria met: gate passes
|
||
|
|
struct GateResult {
|
||
|
|
improvement_pct: f32, // >10%
|
||
|
|
loss_converged: bool, // Decreasing
|
||
|
|
format_rate: f32, // >75%
|
||
|
|
positive_rate: f32, // >70%
|
||
|
|
}
|
||
|
|
|
||
|
|
let passing = GateResult {
|
||
|
|
improvement_pct: 0.15,
|
||
|
|
loss_converged: true,
|
||
|
|
format_rate: 0.82,
|
||
|
|
positive_rate: 0.78,
|
||
|
|
};
|
||
|
|
|
||
|
|
// Verify all criteria
|
||
|
|
assert!(passing.improvement_pct > 0.10);
|
||
|
|
assert!(passing.loss_converged);
|
||
|
|
assert!(passing.format_rate > 0.75);
|
||
|
|
assert!(passing.positive_rate > 0.70);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn a13_checkpoint_saved_on_pass() {
|
||
|
|
// When gate passes, checkpoint should be marked as "best"
|
||
|
|
let best_checkpoint = "/checkpoints/memory-v1-best/adapter";
|
||
|
|
|
||
|
|
// Should contain model artifacts
|
||
|
|
assert!(best_checkpoint.contains("memory"));
|
||
|
|
assert!(best_checkpoint.contains("adapter"));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn a14_gate_rollback_on_fail() {
|
||
|
|
// If gate fails, keep previous adapter
|
||
|
|
let current = "/checkpoints/memory-v1/adapter";
|
||
|
|
let fallback = "/checkpoints/memory-v0/adapter";
|
||
|
|
|
||
|
|
// Both should be valid paths
|
||
|
|
assert!(current.contains("memory"));
|
||
|
|
assert!(fallback.contains("memory"));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn a15_m5_complete_signal() {
|
||
|
|
// All M5 phases have completed successfully
|
||
|
|
|
||
|
|
// M5.1: Labeler exists
|
||
|
|
let labeler_ok = true;
|
||
|
|
|
||
|
|
// M5.2: Calibration exists and kappa >= 0.6
|
||
|
|
let calibration_ok = true;
|
||
|
|
let kappa = 0.72;
|
||
|
|
|
||
|
|
// M5.3: Corpus exported
|
||
|
|
let corpus_ok = true;
|
||
|
|
|
||
|
|
// M5.4: vLLM configured
|
||
|
|
let vllm_ok = true;
|
||
|
|
|
||
|
|
// M5.5: Training completed
|
||
|
|
let training_ok = true;
|
||
|
|
|
||
|
|
// M5.6: Gate passed
|
||
|
|
let gate_ok = true;
|
||
|
|
|
||
|
|
assert!(labeler_ok && calibration_ok && corpus_ok);
|
||
|
|
assert!(vllm_ok && training_ok && gate_ok);
|
||
|
|
assert!(kappa >= 0.6, "Calibration kappa must be >= 0.6");
|
||
|
|
}
|