use reqwest::Client;
use serde_json::json;
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
const STARTUP_TIMEOUT: Duration = Duration::from_secs(180);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
const SELF_PROMPTS: &[&str] = &[
"Explain entropy in one paragraph.",
"List the planets in our solar system.",
"Translate 'good morning' to French and Japanese.",
"What is 17 times 23?",
"Write a haiku about autumn leaves.",
"Describe what gravity is.",
"Name three classical composers.",
"What does TCP stand for?",
"Define the word 'serendipity'.",
"What is the capital of Brazil?",
];
const MAX_TOKENS: u32 = 50;
fn ferrum_bin() -> PathBuf {
if let Ok(bin) = std::env::var("CARGO_BIN_EXE_ferrum") {
return PathBuf::from(bin);
}
let current = std::env::current_exe().expect("test exe path");
let dir = current
.parent()
.and_then(|p| p.parent())
.expect("target dir");
let mut bin = dir.join("ferrum");
if cfg!(windows) {
bin.set_extension("exe");
}
assert!(bin.exists(), "ferrum binary not found at {}", bin.display());
bin
}
fn free_port() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port");
listener.local_addr().expect("local_addr").port()
}
struct ServerFixture {
base_url: String,
child: Child,
}
impl ServerFixture {
async fn spawn(model: &str) -> Self {
let port = free_port();
let base_url = format!("http://127.0.0.1:{port}");
let child = Command::new(ferrum_bin())
.args(["serve", model, "--port", &port.to_string()])
.env("NO_COLOR", "1")
.env("FERRUM_PREFIX_CACHE", "0") .stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn ferrum serve");
let probe = Client::new();
let healthz = format!("{base_url}/health");
let start = Instant::now();
loop {
if start.elapsed() > STARTUP_TIMEOUT {
panic!("server did not become healthy within {STARTUP_TIMEOUT:?}");
}
let ok = probe
.get(&healthz)
.timeout(Duration::from_secs(2))
.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false);
if ok {
break;
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
Self { base_url, child }
}
fn chat_url(&self) -> String {
format!("{}/v1/chat/completions", self.base_url)
}
}
impl Drop for ServerFixture {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
async fn greedy_complete(client: &Client, fx: &ServerFixture, model: &str, prompt: &str) -> String {
let body = json!({
"model": model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": MAX_TOKENS,
"temperature": 0.0,
"stream": false,
});
let resp = client
.post(fx.chat_url())
.timeout(REQUEST_TIMEOUT)
.json(&body)
.send()
.await
.expect("HTTP send");
assert!(resp.status().is_success(), "HTTP {}", resp.status());
let v: serde_json::Value = resp.json().await.expect("JSON parse");
v["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("")
.to_string()
}
fn divergence_rate(a: &[String], b: &[String]) -> f64 {
assert_eq!(a.len(), b.len());
let mut total = 0usize;
let mut diff = 0usize;
for (sa, sb) in a.iter().zip(b.iter()) {
let wa: Vec<&str> = sa.split_whitespace().collect();
let wb: Vec<&str> = sb.split_whitespace().collect();
let n = wa.len().min(wb.len());
let extra = (wa.len() as i64 - wb.len() as i64).unsigned_abs() as usize;
total += n + extra;
for i in 0..n {
if wa[i] != wb[i] {
diff += 1;
}
}
diff += extra; }
if total == 0 {
0.0
} else {
diff as f64 / total as f64
}
}
async fn run_corpus(model: &str) -> Vec<String> {
let fx = ServerFixture::spawn(model).await;
let client = Client::new();
let mut out = Vec::with_capacity(SELF_PROMPTS.len());
for prompt in SELF_PROMPTS {
out.push(greedy_complete(&client, &fx, model, prompt).await);
}
drop(fx);
out
}
fn hf_cached(repo_dir_substring: &str) -> bool {
let hf = std::env::var("HF_HOME").unwrap_or_else(|_| {
format!(
"{}/.cache/huggingface",
std::env::var("HOME").unwrap_or_default()
)
});
let hub = Path::new(&hf).join("hub");
if !hub.exists() {
return false;
}
std::fs::read_dir(&hub)
.map(|rd| {
rd.filter_map(Result::ok)
.any(|e| e.file_name().to_string_lossy().contains(repo_dir_substring))
})
.unwrap_or(false)
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "loads real model — run with `cargo test -- --ignored`"]
async fn self_determinism_qwen3_0p6b() {
eprintln!("\n── L2 sub-test 1: self-determinism (Qwen3-0.6B greedy temp=0) ──");
let model = "qwen3:0.6b";
let a = run_corpus(model).await;
let b = run_corpus(model).await;
let rate = divergence_rate(&a, &b);
eprintln!(
" {} prompts × ~{} tokens → divergence_rate = {:.4}",
a.len(),
MAX_TOKENS,
rate
);
for (i, (sa, sb)) in a.iter().zip(b.iter()).enumerate() {
if sa != sb {
eprintln!(" prompt {} diverged:", i);
eprintln!(" run A: {sa:?}");
eprintln!(" run B: {sb:?}");
}
}
assert!(
rate < 0.001,
"greedy non-determinism detected: divergence_rate={rate:.4} (should be 0)"
);
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "needs paired FP16/INT4 model variants in HF cache"]
async fn paired_quant_drift_qwen2p5_3b() {
let fp16 = "Qwen/Qwen2.5-3B-Instruct";
let int4 = "Qwen/Qwen2.5-3B-Instruct-GPTQ-Int4";
if !hf_cached("Qwen2.5-3B-Instruct-GPTQ-Int4") {
eprintln!("\n⚠ skip: {int4} not in HF cache. To enable, download both variants:");
eprintln!(" huggingface-cli download {fp16}");
eprintln!(" huggingface-cli download {int4}");
return;
}
if !hf_cached("Qwen2.5-3B-Instruct") {
eprintln!("⚠ skip: paired FP16 variant {fp16} not in HF cache.");
return;
}
eprintln!("\n── L2 sub-test 2: paired-quant drift ({fp16} ↔ {int4}) ──");
let a = run_corpus(fp16).await;
let b = run_corpus(int4).await;
let rate = divergence_rate(&a, &b);
eprintln!(
" {} prompts × ~{} tokens → divergence_rate = {:.4}",
a.len(),
MAX_TOKENS,
rate
);
assert!(
rate < 0.10,
"INT4 quant drift exceeds 10%: divergence_rate={rate:.4}"
);
}