use std::collections::{HashMap, HashSet};
use std::io::Write;
use std::path::Path;
use assert_cmd::{Command, cargo::cargo_bin};
use predicates::prelude::*;
use tempfile::NamedTempFile;
fn fixture(name: &str) -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/fixtures")
.join(name)
}
fn shogiesa() -> Command {
Command::cargo_bin("shogiesa").unwrap()
}
fn fake_usi_engine_bin() -> std::path::PathBuf {
static ONCE: std::sync::OnceLock<()> = std::sync::OnceLock::new();
ONCE.get_or_init(|| {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
let status = std::process::Command::new(cargo)
.args(["build", "-p", "fake-usi-engine"])
.status()
.expect("failed to run cargo build");
assert!(status.success(), "failed to build fake-usi-engine");
});
cargo_bin("fake-usi-engine")
}
#[test]
fn extract_creates_jsonl() {
let out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"extract",
"--input",
fixture("sample.csa").to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
])
.assert()
.success();
let content = std::fs::read_to_string(out.path()).unwrap();
let lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect();
assert_eq!(lines.len(), 5, "sample.csa has 5 moves → 5 positions");
for line in &lines {
let v: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(v["schema_version"], shogiesa_core::SCHEMA_VERSION);
}
}
#[test]
fn extract_ply_filter_flag() {
let out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"extract",
"--input",
fixture("sample.csa").to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
"--min-ply",
"3",
"--max-ply",
"4",
])
.assert()
.success();
let content = std::fs::read_to_string(out.path()).unwrap();
let lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect();
assert_eq!(lines.len(), 2);
}
#[test]
fn report_shows_stats() {
let out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"extract",
"--input",
fixture("sample.csa").to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
])
.assert()
.success();
shogiesa()
.args(["report", "--input", out.path().to_str().unwrap()])
.assert()
.success()
.stdout(predicate::str::contains("positions"))
.stdout(predicate::str::contains("phase distribution"))
.stdout(predicate::str::contains("duplicate SFENs"))
.stdout(predicate::str::contains("balance warnings"));
}
#[test]
fn report_shows_labeled_diagnostics() {
let pos = NamedTempFile::new().unwrap();
let obs = NamedTempFile::new().unwrap();
shogiesa()
.args([
"extract",
"--input",
fixture("sample.csa").to_str().unwrap(),
"--out",
pos.path().to_str().unwrap(),
])
.assert()
.success();
shogiesa()
.args([
"label",
"--input",
pos.path().to_str().unwrap(),
"--engine",
fake_usi_engine_bin().to_str().unwrap(),
"--depths",
"4,6",
"--multipv",
"2",
"--out",
obs.path().to_str().unwrap(),
])
.assert()
.success();
shogiesa()
.args(["report", "--input", obs.path().to_str().unwrap()])
.assert()
.success()
.stdout(predicate::str::contains("cp/mate ratio"))
.stdout(predicate::str::contains("avg score swing"))
.stdout(predicate::str::contains("avg policy margin"))
.stdout(predicate::str::contains("score swing distribution"))
.stdout(predicate::str::contains("eval bucket x phase"))
.stdout(predicate::str::contains("eval bucket x side"))
.stdout(predicate::str::contains("multipv coverage"))
.stdout(predicate::str::contains("score bound (multipv candidates)"))
.stdout(predicate::str::contains("score bound (observations)"))
.stdout(predicate::str::contains("exact"));
}
#[test]
fn report_hides_multipv_coverage_without_multipv() {
let pos = NamedTempFile::new().unwrap();
let obs = NamedTempFile::new().unwrap();
shogiesa()
.args([
"extract",
"--input",
fixture("sample.csa").to_str().unwrap(),
"--out",
pos.path().to_str().unwrap(),
])
.assert()
.success();
shogiesa()
.args([
"label",
"--input",
pos.path().to_str().unwrap(),
"--engine",
fake_usi_engine_bin().to_str().unwrap(),
"--depths",
"4",
"--out",
obs.path().to_str().unwrap(),
])
.assert()
.success();
shogiesa()
.args(["report", "--input", obs.path().to_str().unwrap()])
.assert()
.success()
.stdout(predicate::str::contains("multipv coverage").not());
}
#[test]
fn report_shows_engine_disagreement() {
let pos = NamedTempFile::new().unwrap();
let obs1 = NamedTempFile::new().unwrap();
let obs2 = NamedTempFile::new().unwrap();
shogiesa()
.args([
"extract",
"--input",
fixture("sample.csa").to_str().unwrap(),
"--out",
pos.path().to_str().unwrap(),
])
.assert()
.success();
shogiesa()
.args([
"label",
"--input",
pos.path().to_str().unwrap(),
"--engine",
fake_usi_engine_bin().to_str().unwrap(),
"--engine-name",
"engineA",
"--depths",
"4",
"--out",
obs1.path().to_str().unwrap(),
])
.assert()
.success();
shogiesa()
.args([
"label",
"--input",
obs1.path().to_str().unwrap(),
"--engine",
fake_usi_engine_bin().to_str().unwrap(),
"--engine-name",
"engineB",
"--engine-option",
"Bestmove=2g2f",
"--depths",
"4",
"--out",
obs2.path().to_str().unwrap(),
])
.assert()
.success();
shogiesa()
.args(["report", "--input", obs2.path().to_str().unwrap()])
.assert()
.success()
.stdout(predicate::str::contains(
"engine disagree: 5 (100.0% of 5 multi-engine positions)",
));
}
#[test]
fn validate_clean_data_exits_0() {
let out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"extract",
"--input",
fixture("sample.csa").to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
])
.assert()
.success();
shogiesa()
.args(["validate", "--input", out.path().to_str().unwrap()])
.assert()
.success()
.stdout(predicate::str::contains("OK"));
}
fn position_with_version(
schema_version: u32,
observations: serde_json::Value,
stability: Option<serde_json::Value>,
) -> serde_json::Value {
let mut v = serde_json::json!({
"schema_version": schema_version,
"sfen": "lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL b - 1",
"source": { "kind": "csa", "path": "test.csa", "ply": 1 },
"tags": { "phase": "opening", "side_to_move": "black", "in_check": false, "has_capture": false },
"observations": observations,
});
if let Some(s) = stability {
v["stability"] = s;
}
v
}
fn assert_schema_compat(record: serde_json::Value) {
let f = make_labeled_jsonl(&[record]);
shogiesa()
.args([
"validate",
"--input",
f.path().to_str().unwrap(),
"--strict",
])
.assert()
.success();
shogiesa()
.args(["report", "--input", f.path().to_str().unwrap()])
.assert()
.success();
let packed = NamedTempFile::new().unwrap();
shogiesa()
.args([
"pack",
"--input",
f.path().to_str().unwrap(),
"--out",
packed.path().to_str().unwrap(),
])
.assert()
.success();
let unpacked = NamedTempFile::new().unwrap();
shogiesa()
.args([
"unpack",
"--input",
packed.path().to_str().unwrap(),
"--out",
unpacked.path().to_str().unwrap(),
])
.assert()
.success();
let content = std::fs::read_to_string(unpacked.path()).unwrap();
assert_eq!(content.lines().filter(|l| !l.trim().is_empty()).count(), 1);
}
#[test]
fn schema_v1_minimal_round_trips() {
assert_schema_compat(position_with_version(
1,
serde_json::json!([obs("7g7f", 50, 4)]),
None,
));
}
#[test]
fn schema_v2_policy_margin_round_trips() {
assert_schema_compat(position_with_version(
2,
serde_json::json!([obs_with_margin("7g7f", 50, 4, 30)]),
None,
));
}
#[test]
fn schema_v3_engine_stability_round_trips() {
assert_schema_compat(position_with_version(
3,
serde_json::json!([obs_with_margin("7g7f", 50, 4, 30)]),
Some(serde_json::json!({
"score_swing_cp": null,
"bestmove_agreement": true,
"engine_bestmove_agreement": true,
"engine_score_swing_cp": 20
})),
));
}
#[test]
fn schema_v4_candidates_round_trips() {
let mut observation = obs_with_margin("7g7f", 50, 4, 30);
observation["candidates"] = serde_json::json!([
{ "multipv": 1, "bestmove": "7g7f", "score": { "kind": "cp", "value": 50 }, "score_bound": "exact", "pv": null },
{ "multipv": 2, "bestmove": "2g2f", "score": { "kind": "cp", "value": 20 }, "score_bound": "lowerbound", "pv": null }
]);
assert_schema_compat(position_with_version(
4,
serde_json::json!([observation]),
Some(serde_json::json!({
"score_swing_cp": null,
"bestmove_agreement": true,
"engine_bestmove_agreement": true,
"engine_score_swing_cp": 20
})),
));
}
#[test]
fn schema_v5_score_bound_round_trips() {
assert_schema_compat(position_with_version(
5,
serde_json::json!([obs_with_margin("7g7f", 50, 4, 30)]),
Some(serde_json::json!({
"score_swing_cp": null,
"bestmove_agreement": true,
"engine_bestmove_agreement": true,
"engine_score_swing_cp": 20
})),
));
}
#[test]
fn validate_broken_json_shows_warn_but_exits_0() {
let mut f = NamedTempFile::new().unwrap();
writeln!(f, "{{not valid json}}").unwrap();
f.flush().unwrap();
shogiesa()
.args(["validate", "--input", f.path().to_str().unwrap()])
.assert()
.success() .stdout(predicate::str::contains("WARN"));
}
#[test]
fn validate_strict_clean_data_exits_0() {
let out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"extract",
"--input",
fixture("sample.csa").to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
])
.assert()
.success();
shogiesa()
.args([
"validate",
"--input",
out.path().to_str().unwrap(),
"--strict",
])
.assert()
.success()
.stdout(predicate::str::contains("OK"));
}
#[test]
fn validate_strict_broken_json_exits_1() {
let mut f = NamedTempFile::new().unwrap();
writeln!(f, "{{not valid json}}").unwrap();
f.flush().unwrap();
shogiesa()
.args([
"validate",
"--input",
f.path().to_str().unwrap(),
"--strict",
])
.assert()
.failure();
}
#[test]
fn validate_strict_tag_mismatch_exits_1() {
let bad_record = serde_json::json!({
"schema_version": 1,
"sfen": "lnsgkgsnl/1r5b1/ppppppppp/9/9/2P6/PP1PPPPPP/1B5R1/LNSGKGSNL w - 2",
"source": { "kind": "csa", "path": "test.csa", "ply": 1 },
"tags": { "phase": "opening", "side_to_move": "black", "in_check": false, "has_capture": false },
"observations": []
});
let mut f = NamedTempFile::new().unwrap();
writeln!(f, "{}", bad_record).unwrap();
f.flush().unwrap();
shogiesa()
.args([
"validate",
"--input",
f.path().to_str().unwrap(),
"--strict",
])
.assert()
.failure()
.stdout(predicate::str::contains("tag mismatch"));
}
#[test]
fn label_adds_observations() {
let pos = NamedTempFile::new().unwrap();
let obs = NamedTempFile::new().unwrap();
shogiesa()
.args([
"extract",
"--input",
fixture("sample.csa").to_str().unwrap(),
"--out",
pos.path().to_str().unwrap(),
])
.assert()
.success();
shogiesa()
.args([
"label",
"--input",
pos.path().to_str().unwrap(),
"--engine",
fake_usi_engine_bin().to_str().unwrap(),
"--depths",
"4,6",
"--out",
obs.path().to_str().unwrap(),
])
.assert()
.success();
let content = std::fs::read_to_string(obs.path()).unwrap();
let lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect();
assert_eq!(lines.len(), 5);
for line in &lines {
let v: serde_json::Value = serde_json::from_str(line).unwrap();
let obs = v["observations"].as_array().unwrap();
assert_eq!(obs.len(), 2, "expected 2 observations (depth 4 and 6)");
assert_eq!(obs[0]["score"]["kind"], "cp");
assert_eq!(obs[0]["score"]["value"], 100);
assert_eq!(obs[0]["bestmove"], "7g7f");
}
}
#[test]
fn label_multipv_populates_margin() {
let pos = NamedTempFile::new().unwrap();
let obs = NamedTempFile::new().unwrap();
shogiesa()
.args([
"extract",
"--input",
fixture("sample.csa").to_str().unwrap(),
"--out",
pos.path().to_str().unwrap(),
])
.assert()
.success();
shogiesa()
.args([
"label",
"--input",
pos.path().to_str().unwrap(),
"--engine",
fake_usi_engine_bin().to_str().unwrap(),
"--depths",
"4",
"--multipv",
"2",
"--out",
obs.path().to_str().unwrap(),
])
.assert()
.success();
let content = std::fs::read_to_string(obs.path()).unwrap();
for line in content.lines().filter(|l| !l.trim().is_empty()) {
let v: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(v["observations"][0]["policy_margin_cp"], 310);
}
}
#[test]
fn label_appends_to_existing_observations() {
let pos = NamedTempFile::new().unwrap();
let obs1 = NamedTempFile::new().unwrap();
let obs2 = NamedTempFile::new().unwrap();
shogiesa()
.args([
"extract",
"--input",
fixture("sample.csa").to_str().unwrap(),
"--out",
pos.path().to_str().unwrap(),
])
.assert()
.success();
shogiesa()
.args([
"label",
"--input",
pos.path().to_str().unwrap(),
"--engine",
fake_usi_engine_bin().to_str().unwrap(),
"--depths",
"4",
"--out",
obs1.path().to_str().unwrap(),
])
.assert()
.success();
shogiesa()
.args([
"label",
"--input",
obs1.path().to_str().unwrap(),
"--engine",
fake_usi_engine_bin().to_str().unwrap(),
"--depths",
"6",
"--out",
obs2.path().to_str().unwrap(),
])
.assert()
.success();
let content = std::fs::read_to_string(obs2.path()).unwrap();
let first_line = content.lines().next().unwrap();
let v: serde_json::Value = serde_json::from_str(first_line).unwrap();
assert_eq!(v["observations"].as_array().unwrap().len(), 2);
}
#[test]
fn label_skip_existing_avoids_duplicate() {
let pos = NamedTempFile::new().unwrap();
let obs1 = NamedTempFile::new().unwrap();
let obs2 = NamedTempFile::new().unwrap();
shogiesa()
.args([
"extract",
"--input",
fixture("sample.csa").to_str().unwrap(),
"--out",
pos.path().to_str().unwrap(),
])
.assert()
.success();
shogiesa()
.args([
"label",
"--input",
pos.path().to_str().unwrap(),
"--engine",
fake_usi_engine_bin().to_str().unwrap(),
"--depths",
"4",
"--out",
obs1.path().to_str().unwrap(),
])
.assert()
.success();
shogiesa()
.args([
"label",
"--input",
obs1.path().to_str().unwrap(),
"--engine",
fake_usi_engine_bin().to_str().unwrap(),
"--depths",
"4,6",
"--skip-existing",
"--out",
obs2.path().to_str().unwrap(),
])
.assert()
.success();
let content = std::fs::read_to_string(obs2.path()).unwrap();
let first_line = content.lines().next().unwrap();
let v: serde_json::Value = serde_json::from_str(first_line).unwrap();
let obs = v["observations"].as_array().unwrap();
assert_eq!(
obs.len(),
2,
"depth 4 skipped (not duplicated), depth 6 added"
);
let depths: Vec<u64> = obs.iter().map(|o| o["depth"].as_u64().unwrap()).collect();
assert_eq!(depths, vec![4, 6]);
}
#[test]
fn label_skip_existing_no_duplicate_on_early_stop_divergence() {
let pos = NamedTempFile::new().unwrap();
let obs1 = NamedTempFile::new().unwrap();
let obs2 = NamedTempFile::new().unwrap();
shogiesa()
.args([
"extract",
"--input",
fixture("sample.csa").to_str().unwrap(),
"--out",
pos.path().to_str().unwrap(),
])
.assert()
.success();
shogiesa()
.args([
"label",
"--input",
pos.path().to_str().unwrap(),
"--engine",
fake_usi_engine_bin().to_str().unwrap(),
"--engine-option",
"EarlyStopDepth=5",
"--depths",
"8",
"--out",
obs1.path().to_str().unwrap(),
])
.assert()
.success();
shogiesa()
.args([
"label",
"--input",
obs1.path().to_str().unwrap(),
"--engine",
fake_usi_engine_bin().to_str().unwrap(),
"--engine-option",
"EarlyStopDepth=5",
"--depths",
"8",
"--skip-existing",
"--out",
obs2.path().to_str().unwrap(),
])
.assert()
.success();
let content = std::fs::read_to_string(obs2.path()).unwrap();
let first_line = content.lines().next().unwrap();
let v: serde_json::Value = serde_json::from_str(first_line).unwrap();
let obs = v["observations"].as_array().unwrap();
assert_eq!(
obs.len(),
1,
"under-reached depth must be replaced, not duplicated, across skip-existing re-runs"
);
assert_eq!(obs[0]["depth"], 5);
}
#[test]
fn label_replace_existing_overwrites() {
let pos = NamedTempFile::new().unwrap();
let obs1 = NamedTempFile::new().unwrap();
let obs2 = NamedTempFile::new().unwrap();
shogiesa()
.args([
"extract",
"--input",
fixture("sample.csa").to_str().unwrap(),
"--out",
pos.path().to_str().unwrap(),
])
.assert()
.success();
shogiesa()
.args([
"label",
"--input",
pos.path().to_str().unwrap(),
"--engine",
fake_usi_engine_bin().to_str().unwrap(),
"--depths",
"4",
"--out",
obs1.path().to_str().unwrap(),
])
.assert()
.success();
shogiesa()
.args([
"label",
"--input",
obs1.path().to_str().unwrap(),
"--engine",
fake_usi_engine_bin().to_str().unwrap(),
"--depths",
"4",
"--replace-existing",
"--out",
obs2.path().to_str().unwrap(),
])
.assert()
.success();
let content = std::fs::read_to_string(obs2.path()).unwrap();
let first_line = content.lines().next().unwrap();
let v: serde_json::Value = serde_json::from_str(first_line).unwrap();
let obs = v["observations"].as_array().unwrap();
assert_eq!(obs.len(), 1, "replaced, not duplicated");
assert_eq!(obs[0]["depth"], 4);
}
#[test]
fn label_skip_and_replace_existing_conflict() {
let f = make_labeled_jsonl(&[position("opening", serde_json::json!([]))]);
let out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"label",
"--input",
f.path().to_str().unwrap(),
"--engine",
fake_usi_engine_bin().to_str().unwrap(),
"--depths",
"4",
"--skip-existing",
"--replace-existing",
"--out",
out.path().to_str().unwrap(),
])
.assert()
.failure();
}
#[test]
fn label_manifest_records_engine_and_depths() {
let f = make_labeled_jsonl(&[position("opening", serde_json::json!([]))]);
let out = NamedTempFile::new().unwrap();
let manifest_path = NamedTempFile::new().unwrap();
shogiesa()
.args([
"label",
"--input",
f.path().to_str().unwrap(),
"--engine",
fake_usi_engine_bin().to_str().unwrap(),
"--depths",
"4,6",
"--multipv",
"2",
"--out",
out.path().to_str().unwrap(),
"--manifest",
manifest_path.path().to_str().unwrap(),
])
.assert()
.success();
let manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(manifest_path.path()).unwrap()).unwrap();
assert_eq!(manifest["command"], "label");
assert_eq!(manifest["engine_name"], "FakeUsiEngine");
assert_eq!(manifest["depths"], serde_json::json!([4, 6]));
assert_eq!(manifest["multipv"], 2);
assert_eq!(manifest["records_read"], 1);
assert_eq!(manifest["records_kept"], 1);
assert_eq!(manifest["records_dropped"], 0);
assert_eq!(manifest["engine_launch_failures"], 0);
}
fn make_labeled_jsonl(records: &[serde_json::Value]) -> NamedTempFile {
let mut f = NamedTempFile::new().unwrap();
for rec in records {
writeln!(f, "{rec}").unwrap();
}
f.flush().unwrap();
f
}
fn obs(bestmove: &str, score_cp: i32, depth: u32) -> serde_json::Value {
serde_json::json!({
"engine": "test",
"engine_version": null,
"depth": depth,
"score": { "kind": "cp", "value": score_cp },
"bestmove": bestmove,
"nodes": null,
"time_ms": null,
"pv": null
})
}
fn obs_with_engine(engine: &str, bestmove: &str, score_cp: i32, depth: u32) -> serde_json::Value {
serde_json::json!({
"engine": engine,
"engine_version": null,
"depth": depth,
"score": { "kind": "cp", "value": score_cp },
"bestmove": bestmove,
"nodes": null,
"time_ms": null,
"pv": null
})
}
fn obs_mate(bestmove: &str, moves: i32, depth: u32) -> serde_json::Value {
serde_json::json!({
"engine": "test",
"engine_version": null,
"depth": depth,
"score": { "kind": "mate", "moves": moves },
"bestmove": bestmove,
"nodes": null,
"time_ms": null,
"pv": null
})
}
fn obs_with_margin(bestmove: &str, score_cp: i32, depth: u32, margin: i32) -> serde_json::Value {
serde_json::json!({
"engine": "test",
"engine_version": null,
"depth": depth,
"score": { "kind": "cp", "value": score_cp },
"bestmove": bestmove,
"nodes": null,
"time_ms": null,
"pv": null,
"policy_margin_cp": margin
})
}
fn position(phase: &str, observations: serde_json::Value) -> serde_json::Value {
serde_json::json!({
"schema_version": 1,
"sfen": "lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL b - 1",
"source": { "kind": "csa", "path": "test.csa", "ply": 1 },
"tags": { "phase": phase, "side_to_move": "black", "in_check": false, "has_capture": false },
"observations": observations
})
}
#[test]
fn filter_no_observations_excluded() {
let f = make_labeled_jsonl(&[position("opening", serde_json::json!([]))]);
let out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"filter",
"--input",
f.path().to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
])
.assert()
.success();
let content = std::fs::read_to_string(out.path()).unwrap();
assert_eq!(content.lines().filter(|l| !l.trim().is_empty()).count(), 0);
}
#[test]
fn filter_bestmove_agreement_passes() {
let f = make_labeled_jsonl(&[position(
"middlegame",
serde_json::json!([obs("7g7f", 50, 4), obs("7g7f", 55, 6),]),
)]);
let out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"filter",
"--input",
f.path().to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
"--require-bestmove-agreement",
])
.assert()
.success();
let content = std::fs::read_to_string(out.path()).unwrap();
assert_eq!(content.lines().filter(|l| !l.trim().is_empty()).count(), 1);
}
#[test]
fn filter_bestmove_disagreement_excluded() {
let f = make_labeled_jsonl(&[position(
"middlegame",
serde_json::json!([
obs("7g7f", 50, 4),
obs("2b3c", 55, 6), ]),
)]);
let out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"filter",
"--input",
f.path().to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
"--require-bestmove-agreement",
])
.assert()
.success();
let content = std::fs::read_to_string(out.path()).unwrap();
assert_eq!(content.lines().filter(|l| !l.trim().is_empty()).count(), 0);
}
#[test]
fn filter_score_swing_excluded() {
let f = make_labeled_jsonl(&[position(
"middlegame",
serde_json::json!([
obs("7g7f", 50, 4),
obs("7g7f", 300, 6), ]),
)]);
let out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"filter",
"--input",
f.path().to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
"--max-score-swing-cp",
"150",
])
.assert()
.success();
let content = std::fs::read_to_string(out.path()).unwrap();
assert_eq!(content.lines().filter(|l| !l.trim().is_empty()).count(), 0);
}
#[test]
fn filter_require_engine_agreement_single_engine_passes() {
let f = make_labeled_jsonl(&[position(
"middlegame",
serde_json::json!([
obs_with_engine("engineA", "7g7f", 50, 4),
obs_with_engine("engineA", "2g2f", 55, 6),
]),
)]);
let out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"filter",
"--input",
f.path().to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
"--require-engine-agreement",
])
.assert()
.success();
let content = std::fs::read_to_string(out.path()).unwrap();
assert_eq!(content.lines().filter(|l| !l.trim().is_empty()).count(), 1);
}
#[test]
fn filter_require_engine_agreement_excludes_disagreement() {
let f = make_labeled_jsonl(&[position(
"middlegame",
serde_json::json!([
obs_with_engine("engineA", "7g7f", 50, 4),
obs_with_engine("engineB", "2g2f", 55, 4), ]),
)]);
let out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"filter",
"--input",
f.path().to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
"--require-engine-agreement",
])
.assert()
.success();
let content = std::fs::read_to_string(out.path()).unwrap();
assert_eq!(content.lines().filter(|l| !l.trim().is_empty()).count(), 0);
}
#[test]
fn filter_max_engine_score_swing_cp_excludes_large_cross_engine_swing() {
let f = make_labeled_jsonl(&[position(
"middlegame",
serde_json::json!([
obs_with_engine("engineA", "7g7f", 50, 4),
obs_with_engine("engineB", "7g7f", 300, 4), ]),
)]);
let out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"filter",
"--input",
f.path().to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
"--max-engine-score-swing-cp",
"150",
])
.assert()
.success();
let content = std::fs::read_to_string(out.path()).unwrap();
assert_eq!(content.lines().filter(|l| !l.trim().is_empty()).count(), 0);
}
#[test]
fn filter_min_policy_margin_cp() {
let f = make_labeled_jsonl(&[
position(
"middlegame",
serde_json::json!([obs_with_margin("7g7f", 100, 4, 20)]),
), position(
"middlegame",
serde_json::json!([obs_with_margin("8h2b+", 350, 4, 310)]),
), position("middlegame", serde_json::json!([obs("7g7f", 100, 4)])), ]);
let out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"filter",
"--input",
f.path().to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
"--min-policy-margin-cp",
"50",
])
.assert()
.success();
let content = std::fs::read_to_string(out.path()).unwrap();
assert_eq!(content.lines().filter(|l| !l.trim().is_empty()).count(), 2);
}
#[test]
fn filter_require_exact_score_excludes_non_exact() {
let mut non_exact = obs("7g7f", 100, 4);
non_exact["score_bound"] = serde_json::json!("lowerbound");
let f = make_labeled_jsonl(&[
position("middlegame", serde_json::json!([non_exact])),
position("middlegame", serde_json::json!([obs("8h2b+", 100, 4)])), ]);
let out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"filter",
"--input",
f.path().to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
"--require-exact-score",
])
.assert()
.success();
let content = std::fs::read_to_string(out.path()).unwrap();
assert_eq!(content.lines().filter(|l| !l.trim().is_empty()).count(), 1);
}
#[test]
fn filter_require_policy_margin_excludes_missing_margin() {
let f = make_labeled_jsonl(&[
position("middlegame", serde_json::json!([obs("7g7f", 100, 4)])), position(
"middlegame",
serde_json::json!([obs_with_margin("8h2b+", 100, 4, 50)]),
), ]);
let out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"filter",
"--input",
f.path().to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
"--require-policy-margin",
])
.assert()
.success();
let content = std::fs::read_to_string(out.path()).unwrap();
assert_eq!(content.lines().filter(|l| !l.trim().is_empty()).count(), 1);
}
#[test]
fn filter_min_depth_reached_excludes_shallow_but_exempts_mate() {
let f = make_labeled_jsonl(&[
position("middlegame", serde_json::json!([obs("7g7f", 100, 6)])), position("middlegame", serde_json::json!([obs("8h2b+", 100, 10)])), position("middlegame", serde_json::json!([obs_mate("7g7f", 3, 6)])), ]);
let out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"filter",
"--input",
f.path().to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
"--min-depth-reached",
"10",
])
.assert()
.success();
let content = std::fs::read_to_string(out.path()).unwrap();
assert_eq!(content.lines().filter(|l| !l.trim().is_empty()).count(), 2);
}
#[test]
fn filter_explain_out_records_rejected_with_full_reasons() {
let f = make_labeled_jsonl(&[
position("opening", serde_json::json!([obs("7g7f", 50, 4)])),
position("opening", serde_json::json!([obs_mate("7g7f", 3, 4)])),
]);
let out = NamedTempFile::new().unwrap();
let explain_out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"filter",
"--input",
f.path().to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
"--exclude-mate",
"--explain-out",
explain_out.path().to_str().unwrap(),
])
.assert()
.success();
let kept = std::fs::read_to_string(out.path()).unwrap();
assert_eq!(kept.lines().filter(|l| !l.trim().is_empty()).count(), 1);
let rejected: Vec<serde_json::Value> = std::fs::read_to_string(explain_out.path())
.unwrap()
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert_eq!(rejected.len(), 1);
assert_eq!(rejected[0]["quality"]["keep"], false);
assert_eq!(
rejected[0]["quality"]["reasons"],
serde_json::json!(["mate"])
);
assert!(rejected[0]["record"]["observations"].is_array());
}
#[test]
fn filter_explain_out_works_standalone_with_dry_run() {
let f = make_labeled_jsonl(&[position(
"opening",
serde_json::json!([obs_mate("7g7f", 3, 4)]),
)]);
let explain_out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"filter",
"--input",
f.path().to_str().unwrap(),
"--exclude-mate",
"--dry-run",
"--explain-out",
explain_out.path().to_str().unwrap(),
])
.assert()
.success();
let rejected = std::fs::read_to_string(explain_out.path()).unwrap();
assert_eq!(rejected.lines().filter(|l| !l.trim().is_empty()).count(), 1);
}
#[test]
fn filter_exclude_mate() {
let f = make_labeled_jsonl(&[
position("middlegame", serde_json::json!([obs("7g7f", 50, 4)])),
position("middlegame", serde_json::json!([obs_mate("7g7f", 3, 4)])),
]);
let out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"filter",
"--input",
f.path().to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
"--exclude-mate",
])
.assert()
.success();
let content = std::fs::read_to_string(out.path()).unwrap();
assert_eq!(content.lines().filter(|l| !l.trim().is_empty()).count(), 1);
}
#[test]
fn filter_eval_range() {
let f = make_labeled_jsonl(&[
position("middlegame", serde_json::json!([obs("7g7f", 1500, 4)])), position("middlegame", serde_json::json!([obs("7g7f", 100, 4)])), position("middlegame", serde_json::json!([obs("7g7f", -1500, 4)])), ]);
let out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"filter",
"--input",
f.path().to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
"--eval-min=-1200",
"--eval-max=1200",
])
.assert()
.success();
let content = std::fs::read_to_string(out.path()).unwrap();
assert_eq!(content.lines().filter(|l| !l.trim().is_empty()).count(), 1);
}
#[test]
fn filter_phase() {
let f = make_labeled_jsonl(&[
position("opening", serde_json::json!([obs("7g7f", 50, 4)])),
position("middlegame", serde_json::json!([obs("7g7f", 50, 4)])),
position("endgame", serde_json::json!([obs("7g7f", 50, 4)])),
]);
let out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"filter",
"--input",
f.path().to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
"--phase",
"middlegame,endgame",
])
.assert()
.success();
let content = std::fs::read_to_string(out.path()).unwrap();
assert_eq!(content.lines().filter(|l| !l.trim().is_empty()).count(), 2);
}
fn position_with_flags(in_check: bool, has_capture: bool) -> serde_json::Value {
serde_json::json!({
"schema_version": 1,
"sfen": "lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL b - 1",
"source": { "kind": "csa", "path": "test.csa", "ply": 1 },
"tags": { "phase": "middlegame", "side_to_move": "black", "in_check": in_check, "has_capture": has_capture },
"observations": [obs("7g7f", 50, 4)]
})
}
#[test]
fn filter_exclude_in_check() {
let f = make_labeled_jsonl(&[
position_with_flags(false, false),
position_with_flags(true, false),
]);
let out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"filter",
"--input",
f.path().to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
"--exclude-in-check",
])
.assert()
.success();
let content = std::fs::read_to_string(out.path()).unwrap();
assert_eq!(content.lines().filter(|l| !l.trim().is_empty()).count(), 1);
}
#[test]
fn filter_exclude_capture() {
let f = make_labeled_jsonl(&[
position_with_flags(false, false),
position_with_flags(false, true),
]);
let out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"filter",
"--input",
f.path().to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
"--exclude-capture",
])
.assert()
.success();
let content = std::fs::read_to_string(out.path()).unwrap();
assert_eq!(content.lines().filter(|l| !l.trim().is_empty()).count(), 1);
}
#[test]
fn stability_populates_swing_and_agreement() {
let f = make_labeled_jsonl(&[position(
"middlegame",
serde_json::json!([obs("7g7f", 50, 4), obs("7g7f", 300, 6)]),
)]);
let out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"stability",
"--input",
f.path().to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
])
.assert()
.success();
let content = std::fs::read_to_string(out.path()).unwrap();
let v: serde_json::Value = serde_json::from_str(content.lines().next().unwrap()).unwrap();
assert_eq!(v["stability"]["score_swing_cp"], 250);
assert_eq!(v["stability"]["bestmove_agreement"], true);
}
#[test]
fn stability_detects_disagreement() {
let f = make_labeled_jsonl(&[position(
"middlegame",
serde_json::json!([obs("7g7f", 50, 4), obs("2b3c", 50, 6)]),
)]);
let out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"stability",
"--input",
f.path().to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
])
.assert()
.success();
let content = std::fs::read_to_string(out.path()).unwrap();
let v: serde_json::Value = serde_json::from_str(content.lines().next().unwrap()).unwrap();
assert_eq!(v["stability"]["score_swing_cp"], 0);
assert_eq!(v["stability"]["bestmove_agreement"], false);
}
#[test]
fn stability_skips_unlabeled() {
let f = make_labeled_jsonl(&[position("opening", serde_json::json!([]))]);
let out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"stability",
"--input",
f.path().to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
])
.assert()
.success();
let content = std::fs::read_to_string(out.path()).unwrap();
let v: serde_json::Value = serde_json::from_str(content.lines().next().unwrap()).unwrap();
assert!(
v["stability"].is_null(),
"no observations → stability absent"
);
}
fn game_pos(ply: u32, score_cp: i32) -> serde_json::Value {
serde_json::json!({
"schema_version": 1,
"sfen": "lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL b - 1",
"source": { "kind": "csa", "path": "game.csa", "ply": ply },
"tags": { "phase": "opening", "side_to_move": "black", "in_check": false, "has_capture": false },
"observations": [{ "engine": "e", "engine_version": null, "depth": 8,
"score": { "kind": "cp", "value": score_cp },
"bestmove": "7g7f", "nodes": null, "time_ms": null, "pv": null }]
})
}
#[test]
fn mine_detects_blunder_and_window() {
let f = make_labeled_jsonl(&[game_pos(1, 50), game_pos(2, 300), game_pos(3, 310)]);
let out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"mine",
"--input",
f.path().to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
"--blunder-threshold",
"150",
"--blunder-window",
"1",
])
.assert()
.success();
let content = std::fs::read_to_string(out.path()).unwrap();
assert_eq!(content.lines().filter(|l| !l.trim().is_empty()).count(), 3);
}
#[test]
fn mine_no_blunder_empty_output() {
let f = make_labeled_jsonl(&[game_pos(1, 50), game_pos(2, 60)]);
let out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"mine",
"--input",
f.path().to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
"--blunder-threshold",
"150",
])
.assert()
.success();
let content = std::fs::read_to_string(out.path()).unwrap();
assert_eq!(content.lines().filter(|l| !l.trim().is_empty()).count(), 0);
}
#[test]
fn mine_losing_threshold() {
let f = make_labeled_jsonl(&[game_pos(1, 100), game_pos(2, -600), game_pos(3, -580)]);
let out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"mine",
"--input",
f.path().to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
"--blunder-threshold",
"9999",
"--losing-threshold",
"500",
])
.assert()
.success();
let content = std::fs::read_to_string(out.path()).unwrap();
assert_eq!(content.lines().filter(|l| !l.trim().is_empty()).count(), 2);
}
#[test]
fn balance_by_phase_defaults_to_min_bucket() {
let f = make_labeled_jsonl(&[
position("opening", serde_json::json!([obs("7g7f", 50, 4)])),
position("opening", serde_json::json!([obs("7g7f", 60, 4)])),
position("middlegame", serde_json::json!([obs("7g7f", 100, 4)])),
position("middlegame", serde_json::json!([obs("7g7f", 110, 4)])),
position("middlegame", serde_json::json!([obs("7g7f", 120, 4)])),
position("endgame", serde_json::json!([obs("7g7f", 200, 4)])),
]);
let out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"balance",
"--input",
f.path().to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
"--by",
"phase",
])
.assert()
.success();
let content = std::fs::read_to_string(out.path()).unwrap();
assert_eq!(content.lines().filter(|l| !l.trim().is_empty()).count(), 3);
}
#[test]
fn balance_target_override() {
let f = make_labeled_jsonl(&[
position("opening", serde_json::json!([obs("7g7f", 50, 4)])),
position("opening", serde_json::json!([obs("7g7f", 60, 4)])),
position("middlegame", serde_json::json!([obs("7g7f", 100, 4)])),
position("middlegame", serde_json::json!([obs("7g7f", 110, 4)])),
position("middlegame", serde_json::json!([obs("7g7f", 120, 4)])),
position("endgame", serde_json::json!([obs("7g7f", 200, 4)])),
]);
let out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"balance",
"--input",
f.path().to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
"--by",
"phase",
"--target",
"2",
])
.assert()
.success();
let content = std::fs::read_to_string(out.path()).unwrap();
assert_eq!(content.lines().filter(|l| !l.trim().is_empty()).count(), 5);
}
#[test]
fn split_by_source_creates_one_file_per_game() {
use std::io::Write;
let rec_a = serde_json::json!({
"schema_version": 1, "sfen": "lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL b - 1",
"source": { "kind": "csa", "path": "game_a.csa", "ply": 1 },
"tags": { "phase": "opening", "side_to_move": "black", "in_check": false, "has_capture": false },
"observations": []
});
let rec_b = serde_json::json!({
"schema_version": 1, "sfen": "lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL b - 1",
"source": { "kind": "csa", "path": "game_b.csa", "ply": 1 },
"tags": { "phase": "opening", "side_to_move": "black", "in_check": false, "has_capture": false },
"observations": []
});
let mut f = NamedTempFile::new().unwrap();
writeln!(f, "{rec_a}").unwrap();
writeln!(f, "{rec_b}").unwrap();
f.flush().unwrap();
let out_dir = tempfile::tempdir().unwrap();
shogiesa()
.args([
"split",
"--input",
f.path().to_str().unwrap(),
"--by-source",
"--out-dir",
out_dir.path().to_str().unwrap(),
])
.assert()
.success();
let files: Vec<_> = std::fs::read_dir(out_dir.path()).unwrap().collect();
assert_eq!(
files.len(),
3,
"one file per source game, plus manifest.json"
);
let manifest: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(out_dir.path().join("manifest.json")).unwrap(),
)
.unwrap();
assert_eq!(manifest["total_positions"], 2);
assert_eq!(manifest["files"]["game_a.csa.jsonl"], 1);
assert_eq!(manifest["files"]["game_b.csa.jsonl"], 1);
}
fn source_record(path: &str, ply: u32) -> serde_json::Value {
serde_json::json!({
"schema_version": 1,
"sfen": "lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL b - 1",
"source": { "kind": "csa", "path": path, "ply": ply },
"tags": { "phase": "opening", "side_to_move": "black", "in_check": false, "has_capture": false },
"observations": []
})
}
#[test]
fn split_train_valid_test_no_leakage() {
use std::io::Write;
let mut f = NamedTempFile::new().unwrap();
for src in ["game_a.csa", "game_b.csa", "game_c.csa"] {
for ply in [1u32, 2] {
writeln!(f, "{}", source_record(src, ply)).unwrap();
}
}
f.flush().unwrap();
let dir = tempfile::tempdir().unwrap();
let train = dir.path().join("train.jsonl");
let valid = dir.path().join("valid.jsonl");
let test = dir.path().join("test.jsonl");
shogiesa()
.args([
"split",
"--input",
f.path().to_str().unwrap(),
"--train",
train.to_str().unwrap(),
"--valid",
valid.to_str().unwrap(),
"--test",
test.to_str().unwrap(),
"--valid-frac",
"0.34",
"--test-frac",
"0.34",
"--seed",
"7",
])
.assert()
.success();
let mut source_to_files: HashMap<String, HashSet<&str>> = HashMap::new();
let mut total_lines = 0usize;
for (name, path) in [("train", &train), ("valid", &valid), ("test", &test)] {
let content = std::fs::read_to_string(path).unwrap();
for line in content.lines().filter(|l| !l.trim().is_empty()) {
let v: serde_json::Value = serde_json::from_str(line).unwrap();
let src = v["source"]["path"].as_str().unwrap().to_string();
source_to_files.entry(src).or_default().insert(name);
total_lines += 1;
}
}
assert_eq!(total_lines, 6, "no positions dropped");
assert_eq!(
source_to_files.len(),
3,
"all 3 sources should appear somewhere"
);
for (src, files) in &source_to_files {
assert_eq!(
files.len(),
1,
"source {src} leaked across splits: {files:?}"
);
}
let manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(dir.path().join("manifest.json")).unwrap())
.unwrap();
assert_eq!(manifest["total_positions"], 6);
let split_total: u64 = ["train", "valid", "test"]
.iter()
.map(|s| manifest["splits"][s]["positions"].as_u64().unwrap())
.sum();
assert_eq!(split_total, 6);
}
#[test]
fn split_train_valid_test_keeps_variation_with_mainline() {
use std::io::Write;
let mut f = NamedTempFile::new().unwrap();
for src in ["game_a.kif", "game_b.kif", "game_c.kif", "game_d.kif"] {
writeln!(f, "{}", source_record(src, 1)).unwrap();
writeln!(f, "{}", source_record(&format!("{src}#var1@2"), 2)).unwrap();
}
f.flush().unwrap();
let dir = tempfile::tempdir().unwrap();
let train = dir.path().join("train.jsonl");
let valid = dir.path().join("valid.jsonl");
let test = dir.path().join("test.jsonl");
shogiesa()
.args([
"split",
"--input",
f.path().to_str().unwrap(),
"--train",
train.to_str().unwrap(),
"--valid",
valid.to_str().unwrap(),
"--test",
test.to_str().unwrap(),
"--valid-frac",
"0.34",
"--test-frac",
"0.34",
"--seed",
"7",
])
.assert()
.success();
let mut path_to_file: HashMap<String, &str> = HashMap::new();
for (name, path) in [("train", &train), ("valid", &valid), ("test", &test)] {
let content = std::fs::read_to_string(path).unwrap();
for line in content.lines().filter(|l| !l.trim().is_empty()) {
let v: serde_json::Value = serde_json::from_str(line).unwrap();
let src = v["source"]["path"].as_str().unwrap().to_string();
path_to_file.insert(src, name);
}
}
for src in ["game_a.kif", "game_b.kif", "game_c.kif", "game_d.kif"] {
let mainline_file = path_to_file[src];
let variation_file = path_to_file[&format!("{src}#var1@2")];
assert_eq!(
mainline_file, variation_file,
"{src}'s mainline and variation landed in different splits"
);
}
}
#[test]
fn split_train_valid_test_deterministic_with_seed() {
use std::io::Write;
let mut f = NamedTempFile::new().unwrap();
for src in ["game_a.csa", "game_b.csa", "game_c.csa", "game_d.csa"] {
writeln!(f, "{}", source_record(src, 1)).unwrap();
}
f.flush().unwrap();
let run = || {
let dir = tempfile::tempdir().unwrap();
let train = dir.path().join("train.jsonl");
let valid = dir.path().join("valid.jsonl");
let test = dir.path().join("test.jsonl");
shogiesa()
.args([
"split",
"--input",
f.path().to_str().unwrap(),
"--train",
train.to_str().unwrap(),
"--valid",
valid.to_str().unwrap(),
"--test",
test.to_str().unwrap(),
"--valid-frac",
"0.25",
"--test-frac",
"0.25",
"--seed",
"42",
])
.assert()
.success();
(
std::fs::read_to_string(&train).unwrap(),
std::fs::read_to_string(&valid).unwrap(),
std::fs::read_to_string(&test).unwrap(),
)
};
assert_eq!(run(), run());
}
#[test]
fn split_train_valid_test_requires_all_three_paths() {
let f = make_labeled_jsonl(&[position("opening", serde_json::json!([]))]);
let dir = tempfile::tempdir().unwrap();
shogiesa()
.args([
"split",
"--input",
f.path().to_str().unwrap(),
"--train",
dir.path().join("train.jsonl").to_str().unwrap(),
])
.assert()
.failure();
}
#[test]
fn sample_returns_exact_count() {
let f = make_labeled_jsonl(&[
position("opening", serde_json::json!([obs("7g7f", 50, 4)])),
position("middlegame", serde_json::json!([obs("7g7f", 100, 4)])),
position("middlegame", serde_json::json!([obs("7g7f", 110, 4)])),
position("endgame", serde_json::json!([obs("7g7f", 200, 4)])),
position("endgame", serde_json::json!([obs("7g7f", 210, 4)])),
]);
let out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"sample",
"--input",
f.path().to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
"--count",
"3",
])
.assert()
.success();
let content = std::fs::read_to_string(out.path()).unwrap();
assert_eq!(content.lines().filter(|l| !l.trim().is_empty()).count(), 3);
}
#[test]
fn sample_deterministic_with_seed() {
let f = make_labeled_jsonl(&[
position("opening", serde_json::json!([obs("7g7f", 50, 4)])),
position("middlegame", serde_json::json!([obs("7g7f", 100, 4)])),
position("middlegame", serde_json::json!([obs("7g7f", 110, 4)])),
position("endgame", serde_json::json!([obs("7g7f", 200, 4)])),
]);
let out1 = NamedTempFile::new().unwrap();
let out2 = NamedTempFile::new().unwrap();
for out in [&out1, &out2] {
shogiesa()
.args([
"sample",
"--input",
f.path().to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
"--count",
"2",
"--seed",
"42",
])
.assert()
.success();
}
assert_eq!(
std::fs::read_to_string(out1.path()).unwrap(),
std::fs::read_to_string(out2.path()).unwrap(),
"same seed → same output"
);
}
#[test]
fn extract_dedup_zobrist_removes_duplicates() {
let out = NamedTempFile::new().unwrap();
shogiesa()
.args([
"extract",
"--input",
fixture("sample.csa").to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
"--dedup-zobrist",
])
.assert()
.success();
let content = std::fs::read_to_string(out.path()).unwrap();
assert_eq!(content.lines().filter(|l| !l.trim().is_empty()).count(), 5);
}
#[test]
fn filter_end_to_end_with_label() {
let pos = NamedTempFile::new().unwrap();
let obs_file = NamedTempFile::new().unwrap();
let filtered = NamedTempFile::new().unwrap();
shogiesa()
.args([
"extract",
"--input",
fixture("sample.csa").to_str().unwrap(),
"--out",
pos.path().to_str().unwrap(),
])
.assert()
.success();
shogiesa()
.args([
"label",
"--input",
pos.path().to_str().unwrap(),
"--engine",
fake_usi_engine_bin().to_str().unwrap(),
"--depths",
"4,6",
"--out",
obs_file.path().to_str().unwrap(),
])
.assert()
.success();
shogiesa()
.args([
"filter",
"--input",
obs_file.path().to_str().unwrap(),
"--out",
filtered.path().to_str().unwrap(),
"--require-bestmove-agreement",
"--eval-min=-1200",
"--eval-max=1200",
])
.assert()
.success();
let content = std::fs::read_to_string(filtered.path()).unwrap();
assert_eq!(content.lines().filter(|l| !l.trim().is_empty()).count(), 5);
}
#[test]
fn filter_manifest_records_drop_reasons_and_config() {
let f = make_labeled_jsonl(&[
position("opening", serde_json::json!([obs("7g7f", 50, 4)])),
position("opening", serde_json::json!([obs_mate("7g7f", 3, 4)])),
]);
let out = NamedTempFile::new().unwrap();
let manifest_path = NamedTempFile::new().unwrap();
shogiesa()
.args([
"filter",
"--input",
f.path().to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
"--exclude-mate",
"--manifest",
manifest_path.path().to_str().unwrap(),
])
.assert()
.success();
let manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(manifest_path.path()).unwrap()).unwrap();
assert!(!manifest["git_sha"].as_str().unwrap().is_empty());
assert_eq!(manifest["schema_version"], shogiesa_core::SCHEMA_VERSION);
assert_eq!(manifest["command"], "filter");
assert_eq!(manifest["records_read"], 2);
assert_eq!(manifest["records_kept"], 1);
assert_eq!(manifest["records_dropped"], 1);
assert_eq!(manifest["drop_reasons"]["mate"], 1);
assert_eq!(manifest["filter_config"]["exclude_mate"], true);
}
#[test]
fn filter_dry_run_reports_without_writing_output() {
let f = make_labeled_jsonl(&[
position("opening", serde_json::json!([obs("7g7f", 50, 4)])),
position("opening", serde_json::json!([obs_mate("7g7f", 3, 4)])),
]);
shogiesa()
.args([
"filter",
"--input",
f.path().to_str().unwrap(),
"--exclude-mate",
"--dry-run",
])
.assert()
.success()
.stderr(predicate::str::contains(
"done (dry run): 2 read, 1 passed, 1 filtered",
))
.stderr(predicate::str::contains("mate"));
}
#[test]
fn filter_requires_out_unless_dry_run() {
let f = make_labeled_jsonl(&[position("opening", serde_json::json!([obs("7g7f", 50, 4)]))]);
shogiesa()
.args(["filter", "--input", f.path().to_str().unwrap()])
.assert()
.failure();
}
#[test]
fn filter_dry_run_with_manifest_writes_manifest_no_output_file() {
let f = make_labeled_jsonl(&[
position("opening", serde_json::json!([obs("7g7f", 50, 4)])),
position("opening", serde_json::json!([obs_mate("7g7f", 3, 4)])),
]);
let manifest_path = NamedTempFile::new().unwrap();
shogiesa()
.args([
"filter",
"--input",
f.path().to_str().unwrap(),
"--exclude-mate",
"--dry-run",
"--manifest",
manifest_path.path().to_str().unwrap(),
])
.assert()
.success();
let manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(manifest_path.path()).unwrap()).unwrap();
assert_eq!(manifest["records_read"], 2);
assert_eq!(manifest["records_kept"], 1);
assert_eq!(manifest["records_dropped"], 1);
}
#[test]
fn balance_manifest_records_counts() {
let f = make_labeled_jsonl(&[
position("opening", serde_json::json!([obs("7g7f", 50, 4)])),
position("opening", serde_json::json!([obs("7g7f", 60, 4)])),
position("middlegame", serde_json::json!([obs("7g7f", 100, 4)])),
]);
let out = NamedTempFile::new().unwrap();
let manifest_path = NamedTempFile::new().unwrap();
shogiesa()
.args([
"balance",
"--input",
f.path().to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
"--by",
"phase",
"--manifest",
manifest_path.path().to_str().unwrap(),
])
.assert()
.success();
let manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(manifest_path.path()).unwrap()).unwrap();
assert_eq!(manifest["command"], "balance");
assert_eq!(manifest["records_read"], 3);
assert_eq!(manifest["records_kept"], 2); assert_eq!(manifest["records_dropped"], 1);
assert_eq!(manifest["labeled_records"], 2);
}
#[test]
fn sample_manifest_records_counts() {
let f = make_labeled_jsonl(&[
position("opening", serde_json::json!([obs("7g7f", 50, 4)])),
position("opening", serde_json::json!([obs("7g7f", 60, 4)])),
position("opening", serde_json::json!([obs("7g7f", 70, 4)])),
]);
let out = NamedTempFile::new().unwrap();
let manifest_path = NamedTempFile::new().unwrap();
shogiesa()
.args([
"sample",
"--input",
f.path().to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
"--count",
"2",
"--manifest",
manifest_path.path().to_str().unwrap(),
])
.assert()
.success();
let manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(manifest_path.path()).unwrap()).unwrap();
assert_eq!(manifest["command"], "sample");
assert_eq!(manifest["records_read"], 3);
assert_eq!(manifest["records_kept"], 2);
assert_eq!(manifest["records_dropped"], 1);
}
#[test]
fn pack_manifest_records_counts() {
let f = make_labeled_jsonl(&[
position("opening", serde_json::json!([obs("7g7f", 50, 4)])),
position("opening", serde_json::json!([obs("7g7f", 60, 4)])),
]);
let out = NamedTempFile::new().unwrap();
let manifest_path = NamedTempFile::new().unwrap();
shogiesa()
.args([
"pack",
"--input",
f.path().to_str().unwrap(),
"--out",
out.path().to_str().unwrap(),
"--manifest",
manifest_path.path().to_str().unwrap(),
])
.assert()
.success();
let manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(manifest_path.path()).unwrap()).unwrap();
assert_eq!(manifest["command"], "pack");
assert_eq!(
manifest["pack_format_version"],
shogiesa_pack::FORMAT_VERSION
);
assert_eq!(manifest["records_read"], 2);
assert_eq!(manifest["records_kept"], 2);
assert_eq!(manifest["records_dropped"], 0);
}
#[test]
fn same_input_file_produces_same_manifest_input_hash_across_commands() {
let f = make_labeled_jsonl(&[
position("opening", serde_json::json!([obs("7g7f", 50, 4)])),
position("opening", serde_json::json!([obs("7g7f", 60, 4)])),
]);
let filter_out = NamedTempFile::new().unwrap();
let filter_manifest = NamedTempFile::new().unwrap();
shogiesa()
.args([
"filter",
"--input",
f.path().to_str().unwrap(),
"--out",
filter_out.path().to_str().unwrap(),
"--manifest",
filter_manifest.path().to_str().unwrap(),
])
.assert()
.success();
let sample_out = NamedTempFile::new().unwrap();
let sample_manifest = NamedTempFile::new().unwrap();
shogiesa()
.args([
"sample",
"--input",
f.path().to_str().unwrap(),
"--out",
sample_out.path().to_str().unwrap(),
"--count",
"2",
"--manifest",
sample_manifest.path().to_str().unwrap(),
])
.assert()
.success();
let filter_json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(filter_manifest.path()).unwrap()).unwrap();
let sample_json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(sample_manifest.path()).unwrap()).unwrap();
assert_eq!(filter_json["input_hash"], sample_json["input_hash"]);
}
#[test]
fn manifest_common_keys_present_across_commands() {
let common_keys = [
"shogiesa_version",
"git_sha",
"schema_version",
"pack_format_version",
"command",
"args",
"input_path",
"input_hash",
"records_read",
"records_kept",
"records_dropped",
"observations_total",
"observations_with_candidates",
];
let f = make_labeled_jsonl(&[
position("opening", serde_json::json!([obs("7g7f", 50, 4)])),
position("middlegame", serde_json::json!([obs("2g2f", 30, 4)])),
]);
let mut manifests = Vec::new();
let label_out = NamedTempFile::new().unwrap();
let label_manifest = NamedTempFile::new().unwrap();
shogiesa()
.args([
"label",
"--input",
f.path().to_str().unwrap(),
"--engine",
fake_usi_engine_bin().to_str().unwrap(),
"--depths",
"4",
"--out",
label_out.path().to_str().unwrap(),
"--manifest",
label_manifest.path().to_str().unwrap(),
])
.assert()
.success();
manifests.push(("label", label_manifest));
let filter_out = NamedTempFile::new().unwrap();
let filter_manifest = NamedTempFile::new().unwrap();
shogiesa()
.args([
"filter",
"--input",
f.path().to_str().unwrap(),
"--out",
filter_out.path().to_str().unwrap(),
"--manifest",
filter_manifest.path().to_str().unwrap(),
])
.assert()
.success();
manifests.push(("filter", filter_manifest));
let sample_out = NamedTempFile::new().unwrap();
let sample_manifest = NamedTempFile::new().unwrap();
shogiesa()
.args([
"sample",
"--input",
f.path().to_str().unwrap(),
"--out",
sample_out.path().to_str().unwrap(),
"--count",
"1",
"--manifest",
sample_manifest.path().to_str().unwrap(),
])
.assert()
.success();
manifests.push(("sample", sample_manifest));
let balance_out = NamedTempFile::new().unwrap();
let balance_manifest = NamedTempFile::new().unwrap();
shogiesa()
.args([
"balance",
"--input",
f.path().to_str().unwrap(),
"--out",
balance_out.path().to_str().unwrap(),
"--by",
"phase",
"--manifest",
balance_manifest.path().to_str().unwrap(),
])
.assert()
.success();
manifests.push(("balance", balance_manifest));
let pack_out = NamedTempFile::new().unwrap();
let pack_manifest = NamedTempFile::new().unwrap();
shogiesa()
.args([
"pack",
"--input",
f.path().to_str().unwrap(),
"--out",
pack_out.path().to_str().unwrap(),
"--manifest",
pack_manifest.path().to_str().unwrap(),
])
.assert()
.success();
manifests.push(("pack", pack_manifest));
for (command, manifest_file) in manifests {
let manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(manifest_file.path()).unwrap()).unwrap();
for key in common_keys {
assert!(
manifest.get(key).is_some(),
"{command} manifest missing common key {key:?}: {manifest}"
);
}
}
}
#[test]
fn help_lists_manifest_and_dry_run_flags() {
shogiesa()
.args(["label", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--manifest"));
shogiesa()
.args(["filter", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--dry-run"));
}