use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_finetune_method_parse() {
assert!(matches!(
"auto".parse::<FinetuneMethod>(),
Ok(FinetuneMethod::Auto)
));
assert!(matches!(
"full".parse::<FinetuneMethod>(),
Ok(FinetuneMethod::Full)
));
assert!(matches!(
"lora".parse::<FinetuneMethod>(),
Ok(FinetuneMethod::LoRA)
));
assert!(matches!(
"qlora".parse::<FinetuneMethod>(),
Ok(FinetuneMethod::QLoRA)
));
assert!("unknown".parse::<FinetuneMethod>().is_err());
}
#[test]
fn test_finetune_method_to_entrenar() {
assert!(matches!(Method::from(FinetuneMethod::Auto), Method::Auto));
assert!(matches!(Method::from(FinetuneMethod::LoRA), Method::LoRA));
assert!(matches!(Method::from(FinetuneMethod::QLoRA), Method::QLoRA));
assert!(matches!(Method::from(FinetuneMethod::Full), Method::Full));
}
#[test]
fn test_parse_model_size() {
assert_eq!(parse_model_size("7B").expect("7B"), 7_000_000_000);
assert_eq!(parse_model_size("1.5B").expect("1.5B"), 1_500_000_000);
assert_eq!(parse_model_size("135M").expect("135M"), 135_000_000);
assert!(parse_model_size("invalid").is_err());
}
#[test]
fn test_format_params() {
assert_eq!(format_params(7_000_000_000), "7.0B");
assert_eq!(format_params(135_000_000), "135.0M");
assert_eq!(format_params(1000), "1000");
}
#[test]
fn test_run_no_model() {
let result = run(
None,
"auto",
None,
16.0,
false,
None,
None,
None,
false,
3,
Some(2e-4),
None,
None,
5,
"apr,safetensors",
false,
None,
false,
None,
"cuda",
None,
None,
None,
None,
0,
&[],
None,
false,
false,
0,
);
assert!(result.is_err());
}
#[test]
fn test_run_plan_with_model_size() {
let result = run(
None,
"lora",
None,
16.0,
true,
None,
None,
None,
false,
3,
Some(2e-4),
Some("7B"),
None,
5,
"apr,safetensors",
false,
None,
false,
None,
"cuda",
None,
None,
None,
None,
0,
&[],
None,
false,
false,
0,
);
assert!(result.is_ok());
}
#[test]
fn test_run_plan_json() {
let result = run(
None,
"qlora",
None,
24.0,
true,
None,
None,
None,
false,
3,
Some(2e-4),
Some("14B"),
None,
5,
"apr,safetensors",
false,
None,
false,
None,
"cuda",
None,
None,
None,
None,
0,
&[],
None,
true,
false,
0,
);
assert!(result.is_ok());
}
#[test]
fn test_run_with_model_file() {
let mut input = NamedTempFile::with_suffix(".apr").expect("create input");
input.write_all(&[0u8; 4096]).expect("write");
let result = run(
Some(input.path()),
"auto",
None,
16.0,
true,
None,
None,
None,
false,
3,
Some(2e-4),
None,
None,
5,
"apr,safetensors",
false,
None,
false,
None,
"cuda",
None,
None,
None,
None,
0,
&[],
None,
false,
false,
0,
);
assert!(result.is_ok());
}
#[test]
fn test_merge_no_model() {
let result = run_merge(None, None, None, false);
assert!(result.is_err());
}
#[test]
fn test_merge_no_adapter() {
let input = NamedTempFile::with_suffix(".apr").expect("create input");
let result = run_merge(Some(input.path()), None, None, false);
assert!(result.is_err());
}
#[test]
fn test_merge_model_not_found() {
let result = run_merge(
Some(Path::new("/nonexistent.apr")),
Some(Path::new("/nonexistent_adapter/")),
None,
false,
);
assert!(result.is_err());
}
#[test]
fn test_is_lora_eligible() {
assert!(is_lora_eligible("model.layers.0.self_attn.q_proj.weight"));
assert!(is_lora_eligible("model.layers.0.self_attn.v_proj.weight"));
assert!(is_lora_eligible("model.layers.0.mlp.gate_proj.weight"));
assert!(is_lora_eligible("model.layers.0.mlp.up_proj.weight"));
assert!(is_lora_eligible("model.layers.0.mlp.down_proj.weight"));
assert!(is_lora_eligible("blk.0.attn_q.weight"));
assert!(is_lora_eligible("blk.0.ffn_gate.weight"));
assert!(!is_lora_eligible("model.embed_tokens.weight"));
assert!(!is_lora_eligible("model.norm.weight"));
assert!(!is_lora_eligible("lm_head.weight"));
assert!(!is_lora_eligible("model.layers.0.self_attn.q_proj.bias"));
assert!(!is_lora_eligible("token_embd.weight"));
}
#[test]
fn test_hash_seed_deterministic() {
let s1 = hash_seed("test.weight", 0);
let s2 = hash_seed("test.weight", 0);
assert_eq!(s1, s2, "Same inputs must produce same output");
let s3 = hash_seed("test.weight", 1);
assert_ne!(s1, s3, "Different index must produce different output");
let s4 = hash_seed("other.weight", 0);
assert_ne!(s1, s4, "Different name must produce different output");
}
#[test]
fn test_run_training_creates_adapter() {
let mut writer = aprender::serialization::apr::AprWriter::new();
writer.set_metadata("model_type", serde_json::json!("qwen2"));
writer.set_metadata("hidden_size", serde_json::json!(8));
writer.set_metadata("num_hidden_layers", serde_json::json!(1));
writer.set_metadata("num_attention_heads", serde_json::json!(1));
writer.set_metadata("num_key_value_heads", serde_json::json!(1));
writer.set_metadata("vocab_size", serde_json::json!(10));
writer.set_metadata("intermediate_size", serde_json::json!(16));
let q_data: Vec<f32> = (0..64).map(|i| (i as f32) * 0.01).collect();
writer.add_tensor_f32(
"model.layers.0.self_attn.q_proj.weight",
vec![8, 8],
&q_data,
);
let v_data: Vec<f32> = (0..64).map(|i| (i as f32) * 0.02).collect();
writer.add_tensor_f32(
"model.layers.0.self_attn.v_proj.weight",
vec![8, 8],
&v_data,
);
writer.add_tensor_f32("model.embed_tokens.weight", vec![10, 8], &vec![0.1; 80]);
let input_file = NamedTempFile::with_suffix(".apr").expect("create input");
let bytes = writer.to_bytes().expect("serialize");
std::fs::write(input_file.path(), bytes).expect("write");
let data_file = NamedTempFile::with_suffix(".jsonl").expect("create data");
std::fs::write(
data_file.path(),
"{\"instruction\": \"Say hello\", \"response\": \"Hello world\"}\n",
)
.expect("write data");
let output_file = NamedTempFile::with_suffix(".apr").expect("create output");
let result = run(
Some(input_file.path()),
"lora",
None,
16.0,
false,
Some(data_file.path()),
Some(output_file.path()),
None,
false,
3,
Some(2e-4),
Some("0.5B"),
None,
5,
"apr,safetensors",
false,
None,
false,
None,
"cuda",
None,
None,
None,
None,
0,
&[],
None,
true,
false,
0,
);
match &result {
Ok(()) => {
let adapter = aprender::serialization::apr::AprReader::open(output_file.path())
.expect("adapter should be valid APR");
assert!(!adapter.tensors.is_empty(), "Adapter should have tensors");
}
Err(e) => {
let msg = format!("{e}");
assert!(
msg.contains("Missing model.norm.weight")
|| msg.contains("pipeline")
|| msg.contains("Configuration error"),
"Unexpected error (expected pipeline/config issue): {msg}"
);
}
}
}
#[test]
fn test_merge_creates_merged_model() {
let mut base_writer = aprender::serialization::apr::AprWriter::new();
base_writer.set_metadata("model_type", serde_json::json!("test"));
base_writer.set_metadata("architecture", serde_json::json!("qwen2"));
base_writer.set_metadata("hidden_size", serde_json::json!(8));
base_writer.set_metadata("num_layers", serde_json::json!(1));
base_writer.set_metadata("num_heads", serde_json::json!(2));
base_writer.set_metadata("num_kv_heads", serde_json::json!(1));
base_writer.set_metadata("intermediate_size", serde_json::json!(8));
base_writer.set_metadata("vocab_size", serde_json::json!(4));
base_writer.set_metadata(
"tokenizer.vocabulary",
serde_json::json!(["a", "b", "c", "d"]),
);
base_writer.set_metadata("tokenizer.merges", serde_json::json!(["a b"]));
let q_data: Vec<f32> = vec![1.0; 64];
base_writer.add_tensor_f32(
"model.layers.0.self_attn.q_proj.weight",
vec![8, 8],
&q_data,
);
base_writer.add_tensor_f32("model.norm.weight", vec![8], &vec![1.0; 8]);
let base_file = NamedTempFile::with_suffix(".apr").expect("create base");
std::fs::write(base_file.path(), base_writer.to_bytes().expect("serialize")).expect("write");
let mut adapter_writer = aprender::serialization::apr::AprWriter::new();
adapter_writer.set_metadata("lora_rank", serde_json::json!(4));
adapter_writer.set_metadata("lora_alpha", serde_json::json!(8.0));
let lora_a: Vec<f32> = vec![0.1; 4 * 8]; adapter_writer.add_tensor_f32(
"model.layers.0.self_attn.q_proj.weight.lora_a",
vec![4, 8],
&lora_a,
);
let lora_b: Vec<f32> = vec![0.05; 8 * 4]; adapter_writer.add_tensor_f32(
"model.layers.0.self_attn.q_proj.weight.lora_b",
vec![8, 4],
&lora_b,
);
let adapter_file = NamedTempFile::with_suffix(".apr").expect("create adapter");
std::fs::write(
adapter_file.path(),
adapter_writer.to_bytes().expect("serialize"),
)
.expect("write");
let output_file = NamedTempFile::with_suffix(".apr").expect("create output");
let result = run_merge(
Some(base_file.path()),
Some(adapter_file.path()),
Some(output_file.path()),
true,
);
assert!(result.is_ok(), "Merge should succeed: {result:?}");
let merged = aprender::serialization::apr::AprReader::open(output_file.path())
.expect("merged should be valid APR");
assert_eq!(merged.tensors.len(), 2); let q_merged = merged
.read_tensor_f32("model.layers.0.self_attn.q_proj.weight")
.expect("should have q_proj");
assert!(
q_merged.iter().any(|&v| (v - 1.0).abs() > 1e-6),
"Merged weights should differ from base"
);
}
#[cfg(test)]
fn build_tiny_qwen2_base_v2(hidden: usize) -> Vec<u8> {
use aprender::format::v2::{AprV2Metadata, AprV2Writer};
let mut md = AprV2Metadata::new("pmat712-base");
md.architecture = Some("qwen2".to_string());
md.hidden_size = Some(hidden);
md.vocab_size = Some(64);
md.num_layers = Some(1);
md.num_heads = Some(4);
md.num_kv_heads = Some(2);
md.intermediate_size = Some(hidden);
md.max_position_embeddings = Some(128);
md.rope_theta = Some(1_000_000.0);
md.rms_norm_eps = Some(1e-6);
let vocab: Vec<String> = (0..64).map(|i| format!("t{i}")).collect();
md.custom
.insert("tokenizer.vocabulary".to_string(), serde_json::json!(vocab));
md.custom
.insert("tokenizer.merges".to_string(), serde_json::json!(["t0 t1"]));
md.custom
.insert("tokenizer.bos_token_id".to_string(), serde_json::json!(0));
md.custom
.insert("tokenizer.eos_token_id".to_string(), serde_json::json!(1));
let mut w = AprV2Writer::new(md);
let sq = |n: usize| vec![0.02_f32; n];
w.add_f32_tensor(
"model.embed_tokens.weight",
vec![64, hidden],
&sq(64 * hidden),
);
w.add_f32_tensor("model.norm.weight", vec![hidden], &vec![1.0; hidden]);
w.add_f32_tensor(
"model.layers.0.self_attn.q_proj.weight",
vec![hidden, hidden],
&vec![1.0_f32; hidden * hidden],
);
w.add_f32_tensor(
"model.layers.0.self_attn.k_proj.weight",
vec![hidden, hidden],
&sq(hidden * hidden),
);
w.add_f32_tensor(
"model.layers.0.self_attn.v_proj.weight",
vec![hidden, hidden],
&sq(hidden * hidden),
);
w.add_f32_tensor(
"model.layers.0.self_attn.o_proj.weight",
vec![hidden, hidden],
&sq(hidden * hidden),
);
w.add_f32_tensor(
"model.layers.0.input_layernorm.weight",
vec![hidden],
&vec![1.0; hidden],
);
w.add_f32_tensor(
"model.layers.0.post_attention_layernorm.weight",
vec![hidden],
&vec![1.0; hidden],
);
w.add_f32_tensor(
"model.layers.0.mlp.gate_proj.weight",
vec![hidden, hidden],
&sq(hidden * hidden),
);
w.add_f32_tensor(
"model.layers.0.mlp.up_proj.weight",
vec![hidden, hidden],
&sq(hidden * hidden),
);
w.add_f32_tensor(
"model.layers.0.mlp.down_proj.weight",
vec![hidden, hidden],
&sq(hidden * hidden),
);
w.write().expect("write base v2")
}
#[cfg(test)]
fn build_tiny_lora_adapter_v2(hidden: usize, rank: usize, alpha: f64) -> Vec<u8> {
use aprender::format::v2::{AprV2Metadata, AprV2Writer};
let mut md = AprV2Metadata::new("pmat712-adapter");
md.custom
.insert("lora_rank".to_string(), serde_json::json!(rank));
md.custom
.insert("lora_alpha".to_string(), serde_json::json!(alpha));
let mut w = AprV2Writer::new(md);
w.add_f32_tensor(
"model.layers.0.self_attn.q_proj.weight.lora_a",
vec![rank, hidden],
&vec![0.3_f32; rank * hidden],
);
w.add_f32_tensor(
"model.layers.0.self_attn.q_proj.weight.lora_b",
vec![hidden, rank],
&vec![0.5_f32; hidden * rank],
);
w.write().expect("write adapter v2")
}
#[test]
fn test_lora_to_gguf_export_roundtrip_pmat712() {
use aprender::format::gguf::{load_gguf_tensors, GgufReader};
use aprender::format::{apr_export, ExportFormat, ExportOptions};
let hidden = 256usize;
let rank = 8usize;
let alpha = 16.0f64;
let base_file = NamedTempFile::with_suffix(".apr").expect("base tmp");
std::fs::write(base_file.path(), build_tiny_qwen2_base_v2(hidden)).expect("write base");
let adapter_file = NamedTempFile::with_suffix(".apr").expect("adapter tmp");
std::fs::write(
adapter_file.path(),
build_tiny_lora_adapter_v2(hidden, rank, alpha),
)
.expect("write adapter");
let merged_apr = NamedTempFile::with_suffix(".apr").expect("merged tmp");
let merge_res = run_merge(
Some(base_file.path()),
Some(adapter_file.path()),
Some(merged_apr.path()),
true,
);
assert!(merge_res.is_ok(), "LoRA merge must succeed: {merge_res:?}");
let gguf_file = NamedTempFile::with_suffix(".gguf").expect("gguf tmp");
let opts = ExportOptions {
format: ExportFormat::Gguf,
quantize: None,
include_tokenizer: false,
include_config: false,
skip_completeness_check: true,
};
let report =
apr_export(merged_apr.path(), gguf_file.path(), opts).expect("GGUF export must succeed");
assert_eq!(report.format, ExportFormat::Gguf);
assert!(gguf_file.path().exists(), "GGUF file must be written");
let gguf = GgufReader::from_file(gguf_file.path())
.expect("F-LORA-GGUF-001: GGUF must be parseable (valid magic/version)");
assert!(
gguf.version >= 2,
"GGUF version must be >= 2, got {}",
gguf.version
);
assert_eq!(
gguf.architecture().as_deref(),
Some("qwen2"),
"F-LORA-GGUF-002: architecture must round-trip as qwen2"
);
assert_eq!(
gguf.hidden_size(),
Some(hidden),
"F-LORA-GGUF-002: embedding_length must match base hidden_size"
);
assert_eq!(
gguf.num_layers(),
Some(1),
"F-LORA-GGUF-002: block_count must match base num_layers"
);
assert_eq!(
gguf.tensor_count as usize,
gguf.tensors.len(),
"tensor_count header must match parsed tensors"
);
let q_name = "blk.0.attn_q.weight";
let q_meta = gguf
.tensors
.iter()
.find(|t| t.name == q_name)
.unwrap_or_else(|| {
panic!(
"F-LORA-GGUF-003: GGUF must contain {q_name}; got {:?}",
gguf.tensors
.iter()
.map(|t| t.name.clone())
.collect::<Vec<_>>()
)
});
assert_eq!(
q_meta.dims,
vec![hidden as u64, hidden as u64],
"q_proj dims must be [hidden,hidden]"
);
assert_eq!(
q_meta.dtype, 0,
"F32 GgmlType is 0 (no quantization requested)"
);
let _ = q_meta; let tensors = load_gguf_tensors(gguf_file.path())
.expect("F-LORA-GGUF-004: GGUF tensors must load back as F32");
let (q_data, q_shape) = tensors
.get(q_name)
.expect("loaded GGUF must contain blk.0.attn_q.weight");
assert_eq!(
q_data.len(),
hidden * hidden,
"q_proj element count must be hidden^2"
);
assert_eq!(
q_shape.iter().product::<usize>(),
hidden * hidden,
"q_proj shape product"
);
let delta_present = q_data.iter().any(|&v| (v - 1.0).abs() > 1e-4);
assert!(
delta_present,
"F-LORA-GGUF-004: merged q_proj must differ from base (LoRA delta survived merge→GGUF)"
);
}
#[cfg(test)]
fn build_tiny_qwen2_base_beat(hidden: usize) -> Vec<u8> {
use aprender::format::v2::{AprV2Metadata, AprV2Writer};
let mut md = AprV2Metadata::new("pmat712-beat-base");
md.architecture = Some("qwen2".to_string());
md.hidden_size = Some(hidden);
md.vocab_size = Some(64);
md.num_layers = Some(1);
md.num_heads = Some(4);
md.num_kv_heads = Some(2);
md.intermediate_size = Some(hidden);
md.max_position_embeddings = Some(128);
md.rope_theta = Some(1_000_000.0);
md.rms_norm_eps = Some(1e-6);
let vocab: Vec<String> = (0..64).map(|i| format!("t{i}")).collect();
md.custom
.insert("tokenizer.vocabulary".to_string(), serde_json::json!(vocab));
md.custom
.insert("tokenizer.merges".to_string(), serde_json::json!(["t0 t1"]));
let mut qw = vec![0.0_f32; hidden * hidden];
for r in 0..hidden {
for c in 0..hidden {
qw[r * hidden + c] = (((2 * r) as f32) - (c as f32) + 0.5).sin() * 0.5;
}
}
let sq = |n: usize| vec![0.02_f32; n];
let mut w = AprV2Writer::new(md);
w.add_f32_tensor(
"model.embed_tokens.weight",
vec![64, hidden],
&sq(64 * hidden),
);
w.add_f32_tensor("model.norm.weight", vec![hidden], &vec![1.0; hidden]);
w.add_f32_tensor(
"model.layers.0.self_attn.q_proj.weight",
vec![hidden, hidden],
&qw,
);
w.add_f32_tensor(
"model.layers.0.self_attn.k_proj.weight",
vec![hidden, hidden],
&sq(hidden * hidden),
);
w.add_f32_tensor(
"model.layers.0.self_attn.v_proj.weight",
vec![hidden, hidden],
&sq(hidden * hidden),
);
w.add_f32_tensor(
"model.layers.0.self_attn.o_proj.weight",
vec![hidden, hidden],
&sq(hidden * hidden),
);
w.add_f32_tensor(
"model.layers.0.input_layernorm.weight",
vec![hidden],
&vec![1.0; hidden],
);
w.add_f32_tensor(
"model.layers.0.post_attention_layernorm.weight",
vec![hidden],
&vec![1.0; hidden],
);
w.add_f32_tensor(
"model.layers.0.mlp.gate_proj.weight",
vec![hidden, hidden],
&sq(hidden * hidden),
);
w.add_f32_tensor(
"model.layers.0.mlp.up_proj.weight",
vec![hidden, hidden],
&sq(hidden * hidden),
);
w.add_f32_tensor(
"model.layers.0.mlp.down_proj.weight",
vec![hidden, hidden],
&sq(hidden * hidden),
);
w.write().expect("write beat base v2")
}
#[cfg(test)]
fn build_tiny_lora_adapter_beat(hidden: usize, rank: usize, alpha: f64) -> Vec<u8> {
use aprender::format::v2::{AprV2Metadata, AprV2Writer};
let mut md = AprV2Metadata::new("pmat712-beat-adapter");
md.custom
.insert("lora_rank".to_string(), serde_json::json!(rank));
md.custom
.insert("lora_alpha".to_string(), serde_json::json!(alpha));
let mut a = vec![0.0_f32; rank * hidden];
for k in 0..rank {
for c in 0..hidden {
a[k * hidden + c] = (((k + 1) as f32) * 0.013 + (c as f32) * 0.0007).cos() * 0.1;
}
}
let mut b = vec![0.0_f32; hidden * rank];
for r in 0..hidden {
for k in 0..rank {
b[r * rank + k] = (((r + 3) as f32) * 0.011 - ((k + 1) as f32) * 0.019).sin() * 0.1;
}
}
let mut w = AprV2Writer::new(md);
w.add_f32_tensor(
"model.layers.0.self_attn.q_proj.weight.lora_a",
vec![rank, hidden],
&a,
);
w.add_f32_tensor(
"model.layers.0.self_attn.q_proj.weight.lora_b",
vec![hidden, rank],
&b,
);
w.write().expect("write beat adapter v2")
}
#[cfg(test)]
fn rowmajor_matvec(w: &[f32], x: &[f32], rows: usize, cols: usize) -> Vec<f32> {
assert_eq!(w.len(), rows * cols, "weight element count");
assert_eq!(x.len(), cols, "input length must equal cols");
(0..rows)
.map(|r| {
let row = &w[r * cols..r * cols + cols];
row.iter().zip(x).map(|(wv, xv)| wv * xv).sum::<f32>()
})
.collect()
}
#[test]
fn beat_lora_gguf_lossless_deploy_pmat712() {
use aprender::format::gguf::load_gguf_tensors;
use aprender::format::rosetta::RosettaStone;
use aprender::format::{apr_export, ExportFormat, ExportOptions};
let hidden = 256usize;
let rank = 8usize;
let alpha = 16.0f64;
let base_file = NamedTempFile::with_suffix(".apr").expect("base tmp");
std::fs::write(base_file.path(), build_tiny_qwen2_base_beat(hidden)).expect("write base");
let adapter_file = NamedTempFile::with_suffix(".apr").expect("adapter tmp");
std::fs::write(
adapter_file.path(),
build_tiny_lora_adapter_beat(hidden, rank, alpha),
)
.expect("write adapter");
let merged_apr = NamedTempFile::with_suffix(".apr").expect("merged tmp");
let merge_res = run_merge(
Some(base_file.path()),
Some(adapter_file.path()),
Some(merged_apr.path()),
true,
);
assert!(merge_res.is_ok(), "LoRA merge must succeed: {merge_res:?}");
let gguf_file = NamedTempFile::with_suffix(".gguf").expect("gguf tmp");
let opts = ExportOptions {
format: ExportFormat::Gguf,
quantize: None, include_tokenizer: false,
include_config: false,
skip_completeness_check: true,
};
apr_export(merged_apr.path(), gguf_file.path(), opts).expect("F32 GGUF export must succeed");
let rosetta = RosettaStone::new();
let w_apr = rosetta
.load_tensor_f32(merged_apr.path(), "model.layers.0.self_attn.q_proj.weight")
.expect("load merged q_proj from apr");
let q_name = "blk.0.attn_q.weight";
let gguf_tensors =
load_gguf_tensors(gguf_file.path()).expect("load merged q_proj from exported GGUF");
let (w_gguf, gguf_shape) = gguf_tensors
.get(q_name)
.expect("exported GGUF must contain blk.0.attn_q.weight");
assert_eq!(
w_apr.len(),
hidden * hidden,
"F-LOSSLESS-001: apr q_proj must be hidden^2"
);
assert_eq!(
w_gguf.len(),
hidden * hidden,
"F-LOSSLESS-001: gguf q_proj must be hidden^2"
);
assert_eq!(
gguf_shape.iter().product::<usize>(),
hidden * hidden,
"F-LOSSLESS-001: gguf q_proj shape product"
);
let x: Vec<f32> = (0..hidden)
.map(|i| ((i as f32) * 0.017 + 0.31).sin())
.collect();
let y_apr = rowmajor_matvec(&w_apr, &x, hidden, hidden);
let y_gguf = rowmajor_matvec(w_gguf, &x, hidden, hidden);
let y_min = y_apr.iter().cloned().fold(f32::INFINITY, f32::min);
let y_max = y_apr.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
assert!(
(y_max - y_min) > 1e-2,
"F-LOSSLESS-002: forward output must be non-degenerate (spread {} too small)",
y_max - y_min
);
let max_abs_dlogit = y_apr
.iter()
.zip(&y_gguf)
.map(|(a, b)| (a - b).abs())
.fold(0.0_f32, f32::max);
let y_scale = y_max.abs().max(y_min.abs()).max(1.0);
eprintln!(
"[BEAT pmat712] forward equivalence: max|Δy| = {max_abs_dlogit:.3e} \
(output scale ≈ {y_scale:.3}, tolerance 1e-4)"
);
assert!(
max_abs_dlogit <= 1e-4,
"F-LOSSLESS-003 (BEAT): F32 GGUF export must be forward-lossless — \
max|y_apr − y_gguf| = {max_abs_dlogit:.3e} exceeds 1e-4. A lost/garbled \
weight, layout transpose, or shape/metadata mismatch in export breaks this."
);
let mut w_apr_t = vec![0.0_f32; hidden * hidden];
for r in 0..hidden {
for c in 0..hidden {
w_apr_t[c * hidden + r] = w_apr[r * hidden + c];
}
}
let y_apr_t = rowmajor_matvec(&w_apr_t, &x, hidden, hidden);
let max_abs_transpose_gap = y_apr
.iter()
.zip(&y_apr_t)
.map(|(a, b)| (a - b).abs())
.fold(0.0_f32, f32::max);
assert!(
max_abs_transpose_gap > 100.0 * max_abs_dlogit.max(1e-6),
"F-LOSSLESS-004: test must be transpose-sensitive — y(W) vs y(Wᵀ) gap \
({max_abs_transpose_gap:.3e}) should dwarf the apr↔gguf gap \
({max_abs_dlogit:.3e}); otherwise the BEAT could not catch a transpose bug"
);
}
#[test]
fn finetune_method_parse_is_case_insensitive() {
assert!(matches!(
"LoRA".parse::<FinetuneMethod>(),
Ok(FinetuneMethod::LoRA)
));
assert!(matches!(
"QLORA".parse::<FinetuneMethod>(),
Ok(FinetuneMethod::QLoRA)
));
assert!(matches!(
"Full".parse::<FinetuneMethod>(),
Ok(FinetuneMethod::Full)
));
}
#[test]
fn finetune_method_parse_unknown_reports_options() {
let err = "bogus".parse::<FinetuneMethod>().unwrap_err();
assert!(err.contains("bogus"));
assert!(err.contains("auto"));
assert!(err.contains("qlora"));
}
#[test]
fn finetune_method_default_is_auto() {
assert!(matches!(FinetuneMethod::default(), FinetuneMethod::Auto));
}
#[test]
fn finetune_method_into_entrenar_method_all_variants() {
assert!(matches!(Method::from(FinetuneMethod::Auto), Method::Auto));
assert!(matches!(Method::from(FinetuneMethod::Full), Method::Full));
assert!(matches!(Method::from(FinetuneMethod::LoRA), Method::LoRA));
assert!(matches!(Method::from(FinetuneMethod::QLoRA), Method::QLoRA));
}
#[test]
fn parse_model_size_billions_and_millions() {
assert_eq!(parse_model_size("7B").unwrap(), 7_000_000_000);
assert_eq!(parse_model_size("1.5B").unwrap(), 1_500_000_000);
assert_eq!(parse_model_size("370M").unwrap(), 370_000_000);
assert_eq!(parse_model_size("70b").unwrap(), 70_000_000_000);
}
#[test]
fn parse_model_size_missing_suffix_errors() {
let err = parse_model_size("7000").unwrap_err();
match err {
CliError::ValidationFailed(m) => assert!(m.contains("Invalid model size format")),
_ => panic!("expected ValidationFailed"),
}
}
#[test]
fn parse_model_size_non_numeric_errors() {
let err = parse_model_size("abcB").unwrap_err();
match err {
CliError::ValidationFailed(m) => assert!(m.contains("Invalid number")),
_ => panic!("expected ValidationFailed"),
}
}
#[test]
fn format_params_buckets() {
assert_eq!(format_params(7_000_000_000), "7.0B");
assert_eq!(format_params(1_500_000_000), "1.5B");
assert_eq!(format_params(370_000_000), "370.0M");
assert_eq!(format_params(500), "500");
}
#[test]
fn resolve_model_params_prefers_explicit_size() {
let n = resolve_model_params(Some("3B"), None).unwrap();
assert_eq!(n, 3_000_000_000);
}
#[test]
fn resolve_model_params_requires_size_or_path() {
let err = resolve_model_params(None, None).unwrap_err();
match err {
CliError::ValidationFailed(m) => assert!(m.contains("model path or --model-size")),
_ => panic!("expected ValidationFailed"),
}
}
#[test]
fn build_distributed_config_coordinator_requires_bind() {
let err = build_distributed_config(Some("coordinator"), None, None, None).unwrap_err();
match err {
CliError::ValidationFailed(m) => assert!(m.contains("--bind is required")),
_ => panic!("expected ValidationFailed when coordinator role lacks --bind"),
}
}
#[test]
fn build_distributed_config_without_role_warns_but_ok() {
let r = build_distributed_config(None, Some("0.0.0.0:9000"), Some("1.2.3.4"), Some(4));
assert!(r.is_ok());
}
#[test]
fn parse_adapter_specs_bad_format_errors() {
let specs = vec!["no-colon-here".to_string()];
let err = parse_adapter_specs(&specs).unwrap_err();
match err {
CliError::ValidationFailed(m) => assert!(m.contains("Invalid --adapters format")),
_ => panic!("expected ValidationFailed for missing colon"),
}
}
#[test]
fn parse_adapter_specs_missing_data_file_is_file_not_found() {
let specs = vec!["/nonexistent/apr-pmat125-data.jsonl:checkpoints/a".to_string()];
let err = parse_adapter_specs(&specs).unwrap_err();
assert!(matches!(err, CliError::FileNotFound(_)));
}
#[test]
fn parse_adapter_specs_valid_pair_resolves_paths() {
let mut data = NamedTempFile::new().unwrap();
writeln!(data, "{{}}").unwrap();
let spec = format!("{}:checkpoints/adapter-a", data.path().display());
let specs = parse_adapter_specs(&[spec]).unwrap();
assert_eq!(specs.len(), 1);
assert_eq!(specs[0].0, data.path());
assert_eq!(
specs[0].1,
std::path::PathBuf::from("checkpoints/adapter-a")
);
}
#[test]
fn merge_adapters_config_no_file_returns_cli_only() {
let cli = vec!["a:1".to_string(), "b:2".to_string()];
let merged = merge_adapters_config(&cli, None, true).unwrap();
assert_eq!(merged, cli);
}
#[test]
fn build_classify_config_maps_cli_args() {
let model_cfg = entrenar::transformer::TransformerConfig::llama2_7b();
let cfg = build_classify_config(&model_cfg, 5, 16, 3, 1e-4, Some(256), false);
assert_eq!(cfg.num_classes, 5);
assert_eq!(cfg.lora_rank, 16);
assert!((cfg.lora_alpha - 16.0).abs() < 1e-6);
assert_eq!(cfg.epochs, 3);
assert_eq!(cfg.max_seq_len, 256);
assert!((cfg.learning_rate - 1e-4).abs() < 1e-9);
}
#[test]
fn build_classify_config_defaults_max_seq_len_when_none() {
use entrenar::finetune::classify_pipeline::ClassifyConfig;
let model_cfg = entrenar::transformer::TransformerConfig::llama2_7b();
let cfg = build_classify_config(&model_cfg, 2, 8, 1, 5e-5, None, false);
assert_eq!(cfg.max_seq_len, ClassifyConfig::default().max_seq_len);
}
#[test]
fn build_instruct_config_threads_max_seq_len() {
let mut config = plan(1_000_000_000, 24.0, Method::LoRA).expect("plan lora");
config.method = Method::LoRA;
let cfg = build_instruct_config(&config, 1e-4, 3, Some(384));
assert_eq!(
cfg.max_seq_len, 384,
"--max-seq-len 384 must be honored, not dropped to 512"
);
assert_eq!(cfg.epochs, 3);
assert!(!cfg.quantize_nf4, "plain LoRA → NF4 off");
}
#[test]
fn build_instruct_config_defaults_max_seq_len_to_512_when_absent() {
use entrenar::finetune::instruct_pipeline::InstructConfig;
let mut config = plan(1_000_000_000, 24.0, Method::QLoRA).expect("plan qlora");
config.method = Method::QLoRA;
let cfg = build_instruct_config(&config, 5e-5, 1, None);
assert_eq!(cfg.max_seq_len, InstructConfig::default().max_seq_len);
assert_eq!(cfg.max_seq_len, 512);
assert!(cfg.quantize_nf4, "QLoRA method → NF4 on");
}
#[test]
fn gpu_backend_notice_cuda_plain_lora_warns_cpu() {
let p = gpu_backend_notice("cuda", false, false);
assert!(!p.use_wgpu);
assert!(p.notice.contains("WARNING"), "must warn: {}", p.notice);
assert!(p.notice.to_lowercase().contains("cpu"));
assert!(
!p.notice.contains("using cuBLAS backward path (QLoRA/NF4)"),
"must not make a false cuBLAS claim for plain LoRA: {}",
p.notice
);
}
#[test]
fn gpu_backend_notice_cuda_qlora_claims_cublas() {
let p = gpu_backend_notice("cuda", true, false);
assert!(!p.use_wgpu);
assert!(
p.notice.contains("cuBLAS"),
"QLoRA on CUDA truthfully engages cuBLAS: {}",
p.notice
);
assert!(!p.notice.contains("WARNING"));
}
#[test]
fn gpu_backend_notice_wgpu_selects_wgpu() {
let p = gpu_backend_notice("wgpu", false, true);
assert!(p.use_wgpu);
assert!(p.notice.contains("WGPU"));
}
#[test]
fn gpu_backend_notice_auto_plain_lora_is_cpu() {
let p = gpu_backend_notice("auto", false, true);
assert!(!p.use_wgpu, "plain LoRA under auto stays on the CPU path");
assert!(p.notice.to_lowercase().contains("cpu"));
}
#[test]
fn gpu_backend_notice_auto_qlora_prefers_wgpu_when_available() {
let with_wgpu = gpu_backend_notice("auto", true, true);
assert!(with_wgpu.use_wgpu, "NF4 + wgpu available → WGPU fast path");
let without_wgpu = gpu_backend_notice("auto", true, false);
assert!(!without_wgpu.use_wgpu, "no wgpu feature → CUDA path");
assert!(without_wgpu.notice.contains("cuBLAS"));
}
#[test]
fn run_with_missing_model_file_errors() {
let missing = std::path::Path::new("/nonexistent/apr-pmat125-model.apr");
let result = run(
Some(missing), "lora", Some(16), 32.0, true, None, None, None, false, 1, Some(1e-4), None, None, 2, "apr", false, None, false, None, "auto", None, None, None, None, 0, &[], None, true, false, 0, );
assert!(result.is_err());
}
#[cfg(test)]
fn build_import_shaped_qwen2_base_v2(hidden: usize, with_tokenizer: bool) -> Vec<u8> {
use aprender::format::v2::{AprV2Metadata, AprV2Writer};
let mut md = AprV2Metadata::new("transformer_lm_q4k");
md.architecture = Some("qwen2".to_string());
md.hidden_size = Some(hidden);
md.vocab_size = Some(64);
md.intermediate_size = Some(hidden);
md.max_position_embeddings = Some(128);
md.rope_theta = Some(1_000_000.0);
md.rms_norm_eps = Some(1e-6);
md.custom
.insert("num_hidden_layers".to_string(), serde_json::json!(1));
md.custom
.insert("num_attention_heads".to_string(), serde_json::json!(4));
md.custom
.insert("num_key_value_heads".to_string(), serde_json::json!(2));
if with_tokenizer {
let vocab: Vec<String> = (0..64).map(|i| format!("t{i}")).collect();
md.custom
.insert("tokenizer.vocabulary".to_string(), serde_json::json!(vocab));
md.custom
.insert("tokenizer.merges".to_string(), serde_json::json!(["t0 t1"]));
md.custom
.insert("tokenizer.bos_token_id".to_string(), serde_json::json!(0));
md.custom
.insert("tokenizer.eos_token_id".to_string(), serde_json::json!(1));
}
let mut w = AprV2Writer::new(md);
let sq = |n: usize| vec![0.02_f32; n];
w.add_f32_tensor(
"model.embed_tokens.weight",
vec![64, hidden],
&sq(64 * hidden),
);
w.add_f32_tensor("model.norm.weight", vec![hidden], &vec![1.0; hidden]);
w.add_f32_tensor(
"model.layers.0.self_attn.q_proj.weight",
vec![hidden, hidden],
&vec![1.0_f32; hidden * hidden],
);
w.add_f32_tensor(
"model.layers.0.mlp.gate_proj.weight",
vec![hidden, hidden],
&sq(hidden * hidden),
);
w.write().expect("write import-shaped base v2")
}
#[cfg(feature = "inference")]
#[test]
fn falsify_apr_merge_runnable_001_merged_output_loads_in_realizar() {
let hidden = 32usize;
let base_file = NamedTempFile::with_suffix(".apr").expect("base tmp");
std::fs::write(
base_file.path(),
build_import_shaped_qwen2_base_v2(hidden, true),
)
.expect("write base");
let adapter_file = NamedTempFile::with_suffix(".apr").expect("adapter tmp");
std::fs::write(
adapter_file.path(),
build_tiny_lora_adapter_v2(hidden, 4, 8.0),
)
.expect("write adapter");
let merged = NamedTempFile::with_suffix(".apr").expect("merged tmp");
let res = run_merge(
Some(base_file.path()),
Some(adapter_file.path()),
Some(merged.path()),
true,
);
assert!(
res.is_ok(),
"merge must succeed on a runnable base: {res:?}"
);
let mapped = realizar::apr::MappedAprModel::from_path(merged.path())
.expect("realizar must mmap the merged output");
assert_eq!(
mapped.metadata.architecture.as_deref(),
Some("qwen2"),
"C-01: architecture must survive merge AND parse in realizar \
(empty ⇒ duplicate-field metadata poison regressed)"
);
assert_eq!(mapped.metadata.num_layers, Some(1), "C-03: num_layers");
assert_eq!(mapped.metadata.num_heads, Some(4), "C-03: num_heads");
assert_eq!(mapped.metadata.num_kv_heads, Some(2), "C-03: num_kv_heads");
assert_eq!(
mapped.metadata.intermediate_size,
Some(hidden),
"C-03: intermediate_size"
);
realizar::gguf::GGUFConfig::from_apr(&mapped, 64)
.expect("GGUFConfig::from_apr must succeed on the merged output");
let model = realizar::apr::AprV2Model::load(merged.path()).expect("realizar AprV2Model load");
assert!(
model.load_embedded_bpe_tokenizer().is_some(),
"embedded BPE tokenizer (vocabulary+merges) must load from the merged output"
);
}
#[test]
fn falsify_apr_merge_runnable_002_safetensors_extension_rejected() {
let hidden = 32usize;
let base_file = NamedTempFile::with_suffix(".apr").expect("base tmp");
std::fs::write(
base_file.path(),
build_import_shaped_qwen2_base_v2(hidden, true),
)
.expect("write base");
let adapter_file = NamedTempFile::with_suffix(".apr").expect("adapter tmp");
std::fs::write(
adapter_file.path(),
build_tiny_lora_adapter_v2(hidden, 4, 8.0),
)
.expect("write adapter");
let out_dir = tempfile::tempdir().expect("tmpdir");
let out = out_dir.path().join("merged.safetensors");
let res = run_merge(
Some(base_file.path()),
Some(adapter_file.path()),
Some(&out),
true,
);
assert!(
res.is_err(),
"merge must reject a .safetensors output extension (APR-in-disguise)"
);
assert!(
!out.exists(),
"no format-in-disguise file may be left on disk"
);
}
#[test]
fn falsify_apr_merge_runnable_003_gate_deletes_tokenizerless_output() {
let hidden = 32usize;
let base_file = NamedTempFile::with_suffix(".apr").expect("base tmp");
std::fs::write(
base_file.path(),
build_import_shaped_qwen2_base_v2(hidden, false),
)
.expect("write base");
let adapter_file = NamedTempFile::with_suffix(".apr").expect("adapter tmp");
std::fs::write(
adapter_file.path(),
build_tiny_lora_adapter_v2(hidden, 4, 8.0),
)
.expect("write adapter");
let out_dir = tempfile::tempdir().expect("tmpdir");
let out = out_dir.path().join("merged.apr");
let res = run_merge(
Some(base_file.path()),
Some(adapter_file.path()),
Some(&out),
true,
);
assert!(
res.is_err(),
"merge of a tokenizer-less base must fail the runnability gate"
);
assert!(!out.exists(), "gate must DELETE the unrunnable output");
}
#[test]
fn falsify_apr_merge_runnable_004_entrenar_adapter_naming_merges() {
use aprender::format::v2::{AprV2Metadata, AprV2Writer};
let hidden = 32usize;
let rank = 4usize;
let base_file = NamedTempFile::with_suffix(".apr").expect("base tmp");
std::fs::write(
base_file.path(),
build_import_shaped_qwen2_base_v2(hidden, true),
)
.expect("write base");
let mut md = AprV2Metadata::new("entrenar-adapter");
md.custom
.insert("lora_rank".to_string(), serde_json::json!(64));
md.custom
.insert("lora_alpha".to_string(), serde_json::json!(8.0));
let mut w = AprV2Writer::new(md);
w.add_f32_tensor(
"lora.0.q_proj.lora_a",
vec![rank, hidden],
&vec![0.3_f32; rank * hidden],
);
w.add_f32_tensor(
"lora.0.q_proj.lora_b",
vec![hidden, rank],
&vec![0.5_f32; hidden * rank],
);
let adapter_file = NamedTempFile::with_suffix(".apr").expect("adapter tmp");
std::fs::write(adapter_file.path(), w.write().expect("write adapter"))
.expect("write adapter file");
let merged = NamedTempFile::with_suffix(".apr").expect("merged tmp");
let res = run_merge(
Some(base_file.path()),
Some(adapter_file.path()),
Some(merged.path()),
true,
);
assert!(
res.is_ok(),
"merge must recognize entrenar lora.{{layer}}.{{proj}} naming: {res:?}"
);
let q: Vec<f32> = aprender::format::rosetta::RosettaStone::new()
.load_tensor_f32(merged.path(), "model.layers.0.self_attn.q_proj.weight")
.expect("q_proj present");
assert!(
q.iter().any(|&v| (v - 1.0).abs() > 1e-4),
"merged q_proj must differ from base — entrenar-style adapter was silently ignored"
);
}
#[test]
fn falsify_apr_merge_runnable_005_noop_merge_rejected() {
use aprender::format::v2::{AprV2Metadata, AprV2Writer};
let hidden = 32usize;
let base_file = NamedTempFile::with_suffix(".apr").expect("base tmp");
std::fs::write(
base_file.path(),
build_import_shaped_qwen2_base_v2(hidden, true),
)
.expect("write base");
let mut md = AprV2Metadata::new("mismatched-adapter");
md.custom
.insert("lora_rank".to_string(), serde_json::json!(4));
md.custom
.insert("lora_alpha".to_string(), serde_json::json!(8.0));
let mut w = AprV2Writer::new(md);
w.add_f32_tensor(
"some.unrelated.tensor.lora_a",
vec![4, hidden],
&vec![0.1_f32; 4 * hidden],
);
w.add_f32_tensor(
"some.unrelated.tensor.lora_b",
vec![hidden, 4],
&vec![0.1_f32; hidden * 4],
);
let adapter_file = NamedTempFile::with_suffix(".apr").expect("adapter tmp");
std::fs::write(adapter_file.path(), w.write().expect("write adapter"))
.expect("write adapter file");
let out_dir = tempfile::tempdir().expect("tmpdir");
let out = out_dir.path().join("merged.apr");
let res = run_merge(
Some(base_file.path()),
Some(adapter_file.path()),
Some(&out),
true,
);
assert!(
res.is_err(),
"a merge where zero adapter tensors match must fail loudly, got: {res:?}"
);
assert!(!out.exists(), "no-op merge must not leave an output file");
}
#[test]
fn falsify_apr_merge_runnable_004b_entrenar_adapter_merges_gguf_named_base() {
use aprender::format::v2::{AprV2Metadata, AprV2Writer};
let hidden = 32usize;
let rank = 4usize;
let mut md = AprV2Metadata::new("transformer_lm_q4k");
md.architecture = Some("qwen2".to_string());
md.hidden_size = Some(hidden);
md.vocab_size = Some(64);
md.intermediate_size = Some(hidden);
md.rms_norm_eps = Some(1e-6);
md.custom
.insert("num_hidden_layers".to_string(), serde_json::json!(1));
md.custom
.insert("num_attention_heads".to_string(), serde_json::json!(4));
md.custom
.insert("num_key_value_heads".to_string(), serde_json::json!(2));
let vocab: Vec<String> = (0..64).map(|i| format!("t{i}")).collect();
md.custom
.insert("tokenizer.vocabulary".to_string(), serde_json::json!(vocab));
md.custom
.insert("tokenizer.merges".to_string(), serde_json::json!(["t0 t1"]));
let mut w = AprV2Writer::new(md);
let sq = |n: usize| vec![0.02_f32; n];
w.add_f32_tensor("token_embd.weight", vec![64, hidden], &sq(64 * hidden));
w.add_f32_tensor(
"blk.0.attn_q.weight",
vec![hidden, hidden],
&vec![1.0_f32; hidden * hidden],
);
let base_file = NamedTempFile::with_suffix(".apr").expect("base tmp");
std::fs::write(base_file.path(), w.write().expect("write base")).expect("write base file");
let mut amd = AprV2Metadata::new("entrenar-adapter");
amd.custom
.insert("lora_rank".to_string(), serde_json::json!(rank));
amd.custom
.insert("lora_alpha".to_string(), serde_json::json!(8.0));
let mut aw = AprV2Writer::new(amd);
aw.add_f32_tensor(
"lora.0.q_proj.lora_a",
vec![rank, hidden],
&vec![0.3_f32; rank * hidden],
);
aw.add_f32_tensor(
"lora.0.q_proj.lora_b",
vec![hidden, rank],
&vec![0.5_f32; hidden * rank],
);
let adapter_file = NamedTempFile::with_suffix(".apr").expect("adapter tmp");
std::fs::write(adapter_file.path(), aw.write().expect("write adapter"))
.expect("write adapter file");
let merged = NamedTempFile::with_suffix(".apr").expect("merged tmp");
let res = run_merge(
Some(base_file.path()),
Some(adapter_file.path()),
Some(merged.path()),
true,
);
assert!(
res.is_ok(),
"entrenar adapter must merge against a GGUF-named base: {res:?}"
);
let q: Vec<f32> = aprender::format::rosetta::RosettaStone::new()
.load_tensor_f32(merged.path(), "blk.0.attn_q.weight")
.expect("attn_q present");
assert!(
q.iter().any(|&v| (v - 1.0).abs() > 1e-4),
"merged blk.0.attn_q.weight must differ from base — GGUF→HF projection map broken"
);
}