use assert_cmd::Command;
use predicates::prelude::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn write_last_target_csv() -> NamedTempFile {
let mut f = NamedTempFile::with_suffix(".csv").expect("create temp csv");
writeln!(f, "x0,x1,y").unwrap();
for i in 0..16 {
let a = i as f64;
let b = (i % 4) as f64;
writeln!(f, "{a},{b},{}", a + b).unwrap();
}
f.flush().unwrap();
f
}
fn write_first_target_csv() -> NamedTempFile {
let mut f = NamedTempFile::with_suffix(".csv").expect("create temp csv");
writeln!(f, "y,x0,x1").unwrap();
for i in 0..16 {
let a = i as f64;
let b = (i % 4) as f64;
writeln!(f, "{},{a},{b}", a + b).unwrap();
}
f.flush().unwrap();
f
}
fn write_product_csv() -> NamedTempFile {
let mut f = NamedTempFile::with_suffix(".csv").expect("create temp csv");
writeln!(f, "x0,x1,y").unwrap();
for i in 0..40 {
let a = 1.0 + 0.1 * i as f64;
let b = 0.5 + 0.07 * i as f64;
writeln!(f, "{a},{b},{}", a * b).unwrap();
}
f.flush().unwrap();
f
}
fn write_exp_csv() -> NamedTempFile {
let mut f = NamedTempFile::with_suffix(".csv").expect("create temp csv");
writeln!(f, "x0,y").unwrap();
for i in 0..30 {
let x = i as f64 * 0.1;
writeln!(f, "{x},{}", x.exp()).unwrap();
}
f.flush().unwrap();
f
}
#[test]
fn predict_round_trips_a_discovered_law() {
let csv = write_exp_csv();
let model = NamedTempFile::with_suffix(".json").expect("create temp model");
Command::cargo_bin("phop")
.unwrap()
.arg("discover")
.arg(csv.path())
.args(["--method", "enumerate"])
.args(["--format", "json"])
.args(["--max-epochs", "150"])
.arg("--quiet")
.args(["--output", model.path().to_str().unwrap()])
.assert()
.success();
Command::cargo_bin("phop")
.unwrap()
.arg("predict")
.arg(model.path())
.arg(csv.path())
.assert()
.success()
.stdout(predicate::str::contains("prediction"))
.stderr(predicate::str::contains("R²"));
}
#[test]
fn certify_prints_certified_range() {
let csv = write_exp_csv();
Command::cargo_bin("phop")
.unwrap()
.arg("discover")
.arg(csv.path())
.args(["--method", "enumerate"])
.args(["--max-epochs", "120"])
.arg("--certify")
.assert()
.success()
.stderr(predicate::str::contains("certified range"));
}
#[test]
fn help_succeeds_and_mentions_discover() {
Command::cargo_bin("phop")
.unwrap()
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("discover"));
}
#[test]
fn discover_json_format_parses_with_mse() {
let csv = write_last_target_csv();
let assert = Command::cargo_bin("phop")
.unwrap()
.arg("discover")
.arg(csv.path())
.args(["--format", "json"])
.args(["--max-epochs", "50"])
.args(["--population", "16"])
.args(["--top-k", "3"])
.assert()
.success();
let out = assert.get_output();
let stdout = String::from_utf8(out.stdout.clone()).expect("utf8 stdout");
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("stdout should be valid JSON");
assert!(parsed.is_array(), "JSON output should be an array");
let arr = parsed.as_array().unwrap();
assert!(!arr.is_empty(), "expected at least one solution");
assert!(
arr[0].get("mse").is_some(),
"each solution object must contain an 'mse' field"
);
}
#[test]
fn target_zero_selects_first_column() {
let csv = write_first_target_csv();
Command::cargo_bin("phop")
.unwrap()
.arg("discover")
.arg(csv.path())
.args(["--target", "0"])
.args(["--max-epochs", "50"])
.args(["--population", "16"])
.args(["--top-k", "3"])
.assert()
.success();
}
#[test]
fn target_by_name_selects_column() {
let csv = write_first_target_csv();
Command::cargo_bin("phop")
.unwrap()
.arg("discover")
.arg(csv.path())
.args(["--target", "y"])
.args(["--max-epochs", "50"])
.args(["--population", "16"])
.args(["--top-k", "3"])
.assert()
.success();
}
#[test]
fn unknown_target_column_errors() {
let csv = write_last_target_csv();
Command::cargo_bin("phop")
.unwrap()
.arg("discover")
.arg(csv.path())
.args(["--target", "nope"])
.args(["--max-epochs", "50"])
.args(["--population", "16"])
.assert()
.failure()
.stderr(predicate::str::contains("unknown column"));
}
#[test]
fn analyze_prints_cas_analysis() {
let csv = write_last_target_csv();
Command::cargo_bin("phop")
.unwrap()
.arg("discover")
.arg(csv.path())
.args(["--max-epochs", "50"])
.args(["--population", "16"])
.arg("--analyze")
.assert()
.success()
.stderr(predicate::str::contains("d/dx0"));
}
#[test]
fn features_subset_by_name_runs() {
let csv = write_last_target_csv();
Command::cargo_bin("phop")
.unwrap()
.arg("discover")
.arg(csv.path())
.args(["--features", "x0"])
.args(["--max-epochs", "50"])
.args(["--population", "16"])
.args(["--verbose"])
.assert()
.success()
.stderr(predicate::str::contains("1 feature(s) [x0]"));
}
#[test]
fn auto_method_reaches_rich_engine() {
let csv = write_product_csv();
let assert = Command::cargo_bin("phop")
.unwrap()
.arg("discover")
.arg(csv.path())
.args(["--method", "auto"])
.args(["--format", "json"])
.args(["--max-epochs", "50"])
.args(["--population", "16"])
.args(["--top-k", "5"])
.assert()
.success();
let out = assert.get_output();
let stdout = String::from_utf8(out.stdout.clone()).expect("utf8 stdout");
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("stdout should be valid JSON");
let arr = parsed.as_array().expect("JSON output should be an array");
assert!(!arr.is_empty(), "expected at least one solution");
assert!(
arr[0].get("source").is_some(),
"each merged solution must carry a 'source' field"
);
let best_affine_r2 = arr
.iter()
.filter(|s| s.get("source").and_then(serde_json::Value::as_str) == Some("affine"))
.filter_map(|s| s.get("r2").and_then(serde_json::Value::as_f64))
.fold(f64::MIN, f64::max);
assert!(
best_affine_r2 > 0.99,
"auto must reach an accurate affine member (best affine r2 = {best_affine_r2})"
);
}
#[test]
fn missing_csv_errors() {
Command::cargo_bin("phop")
.unwrap()
.arg("discover")
.arg("/this/path/does/not/exist.csv")
.args(["--max-epochs", "50"])
.args(["--population", "16"])
.assert()
.failure();
}
#[test]
fn quiet_suppresses_loaded_line() {
let csv = write_last_target_csv();
Command::cargo_bin("phop")
.unwrap()
.arg("discover")
.arg(csv.path())
.arg("--quiet")
.args(["--max-epochs", "50"])
.args(["--population", "16"])
.assert()
.success()
.stderr(predicate::str::contains("phop: loaded").not());
}