#[cfg(unix)]
use std::io::{BufRead as _, BufReader};
use std::{
env, fs,
io::{Read, Write as _},
path::{Path, PathBuf},
process::{Child, ChildStdin, Command, Stdio},
thread,
time::{Duration, Instant}
};
use pretty_assertions::assert_eq;
use proptest::{
option, prelude::*, sample::select, test_runner::TestCaseError
};
use serde_json::{Map, Value, json};
use crate::{
Budget, BuildError, Distribution, Evaluator, Function, Passes, Unobserved,
Weight, compile,
compiler::compile_unoptimized,
support::{
compile_valid, on_small_stack_within, optimize,
read_distribution_test_cases
},
tests::{
propagation::resum,
property::{
NAMES, OPERATORS, agree, binding, check_within, variable,
with_parameters
}
}
};
const BLESS: &str = "XDY_BLESS_FIXTURES";
const FIXTURES: &[(&str, &str)] = &[
("arithmetic", "{a}, {b}: -({a} * {b} - {a} / {b} % 3 ^ {b})"),
("constant", "7"),
("custom_dice", "2D[-1, 0, 1, 3, 5]"),
("drop_lowest", "4D6 drop lowest"),
("dynamic_count", "(1D3)D3"),
("dynamic_custom_count", "{x}: {x}D[1, 3]"),
(
"dynamic_drops",
"{n}, {m}: 5D6 drop lowest {n} drop highest {m}"
),
("dynamic_faces", "3D(2D6)"),
("dynamic_range", "[1D3:2D6]"),
("external", "1D6 + {y}"),
("max", "{x}: {x}D1"),
("range", "[1:6]"),
("shared_register", "{x}@(3D6) + {x}"),
("standard_dice", "3D6")
];
fn fixture_directory() -> PathBuf
{
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("lean")
.join("XdyTest")
.join("Fixtures")
}
fn fixture_json(source: &str) -> String
{
let function = compile(source)
.unwrap_or_else(|e| panic!("{source}: compilation error: {e}"));
serde_json::to_string(&function).unwrap() + "\n"
}
#[test]
fn test_lean_fixtures_are_current()
{
let directory = fixture_directory();
if env::var_os(BLESS).is_some()
{
for (name, source) in FIXTURES
{
fs::write(
directory.join(format!("{name}.json")),
fixture_json(source)
)
.unwrap();
}
return
}
let stale = FIXTURES
.iter()
.filter(|(name, source)| {
let path = directory.join(format!("{name}.json"));
fs::read_to_string(path).ok() != Some(fixture_json(source))
})
.map(|(name, _)| *name)
.collect::<Vec<_>>();
assert!(
stale.is_empty(),
"stale Lean fixtures: {stale:?}; rerun with {BLESS}=1 to rewrite \
them, then run `just lean`"
);
let unlisted = fs::read_dir(&directory)
.unwrap()
.map(|entry| entry.unwrap().path())
.filter(|path| path.extension().is_some_and(|e| e == "json"))
.filter(|path| {
let stem = path.file_stem().unwrap().to_str().unwrap();
FIXTURES.iter().all(|(name, _)| *name != stem)
})
.collect::<Vec<_>>();
assert!(
unlisted.is_empty(),
"Lean fixtures missing from FIXTURES: {unlisted:?}"
);
}
const ORACLE: &str = "XDY_ORACLE";
const DISTRIBUTION_TEST_SOURCE: &str =
include_str!("../../tests/test_distributions.txt");
fn oracle_path() -> PathBuf
{
env::var_os(ORACLE).map(PathBuf::from).unwrap_or_else(|| {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("lean")
.join(".lake")
.join("build")
.join("bin")
.join(format!("xdy-oracle{}", env::consts::EXE_SUFFIX))
})
}
fn report_directory() -> PathBuf
{
env::var_os("CARGO_TARGET_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("target")
})
.join("oracle")
}
#[derive(Debug)]
enum Unanswered
{
Refused(String),
Late
}
struct RunningOracle
{
child: Child,
_lifeline: ChildStdin
}
impl RunningOracle
{
fn ask(
oracle: &Path,
function: &Function,
args: &[i32],
externs: &[(&str, i32)]
) -> Self
{
let externs = externs
.iter()
.map(|(name, value)| (name.to_string(), json!(value)))
.collect::<Map<_, _>>();
let request = json!({
"function": function,
"arguments": args,
"externals": externs
});
let mut child = Command::new(oracle)
.arg("--lifeline")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap_or_else(|e| {
panic!(
"cannot run the Lean oracle at {}: {e}; build it with \
`just lean`, or name it with {ORACLE}",
oracle.display()
)
});
let mut oracle = Self {
_lifeline: child.stdin.take().unwrap(),
child
};
writeln!(oracle._lifeline, "{request}").unwrap();
oracle
}
}
impl Drop for RunningOracle
{
fn drop(&mut self)
{
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn ask_oracle(
oracle: &Path,
function: &Function,
args: &[i32],
externs: &[(&str, i32)],
deadline: Duration
) -> Result<Distribution, Unanswered>
{
let mut running = RunningOracle::ask(oracle, function, args, externs);
let drain = |mut pipe: Box<dyn Read + Send>| {
thread::spawn(move || {
let mut bytes = Vec::new();
pipe.read_to_end(&mut bytes).unwrap();
bytes
})
};
let stdout = drain(Box::new(running.child.stdout.take().unwrap()));
let stderr = drain(Box::new(running.child.stderr.take().unwrap()));
let start = Instant::now();
let status = loop
{
if let Some(status) = running.child.try_wait().unwrap()
{
break status
}
if start.elapsed() >= deadline
{
return Err(Unanswered::Late)
}
thread::sleep(Duration::from_millis(1));
};
let (stdout, stderr) = (stdout.join().unwrap(), stderr.join().unwrap());
if !status.success()
{
return Err(Unanswered::Refused(
String::from_utf8_lossy(&stderr).trim().to_owned()
))
}
let response: Value = serde_json::from_slice(&stdout).unwrap();
let weight = |value: &Value| {
let text = value.as_str().unwrap();
text.parse::<Weight>()
.unwrap_or_else(|e| panic!("malformed weight {text}: {e}"))
};
let distribution = Distribution::from_weights(
response["outcomes"].as_array().unwrap().iter().map(|pair| {
let outcome = i32::try_from(pair[0].as_i64().unwrap()).unwrap();
(outcome, weight(&pair[1]))
})
)
.expect("the oracle answered no outcomes");
assert_eq!(distribution.total(), &weight(&response["total"]));
Ok(distribution)
}
fn report_case(
report: &mut String,
previous: Option<&str>,
source: &str,
args: &[i32],
externs: &[(&str, i32)],
distribution: &Distribution
)
{
if previous != Some(source)
{
if previous.is_some()
{
report.push('\n');
}
report.push_str(source);
report.push('\n');
}
report.push_str("=\n");
if !args.is_empty()
{
let args = args.iter().map(i32::to_string).collect::<Vec<_>>();
report.push_str(&format!("args: {}\n", args.join(", ")));
}
if !externs.is_empty()
{
let externs = externs
.iter()
.map(|(name, value)| format!("{name}={value}"))
.collect::<Vec<_>>();
report.push_str(&format!("externs: {}\n", externs.join(", ")));
}
report.push_str(&distribution.to_string());
}
#[test]
#[ignore = "oracle: run with just oracle"]
fn test_oracle_distribution_corpus()
{
let oracle = oracle_path();
let cases = read_distribution_test_cases(DISTRIBUTION_TEST_SOURCE);
let mut report = String::new();
let mut previous = None;
let mut disagreeing = Vec::new();
for (index, (source, args, externs, expected)) in cases.iter().enumerate()
{
let case = format!("case {}: {source} {args:?} {externs:?}", index + 1);
let function = optimize(compile_valid(source), Passes::all());
let actual =
ask_oracle(&oracle, &function, args, externs, Duration::MAX)
.unwrap_or_else(|e| panic!("{case}: unanswered: {e:?}"));
let expected = Distribution::from_weights(
expected
.iter()
.map(|&(outcome, count)| (outcome, Weight::from(count)))
)
.unwrap();
let agrees = agree(&expected, &actual);
if !agrees
{
disagreeing.push(case);
}
let recorded = if agrees { &expected } else { &actual };
report_case(&mut report, previous, source, args, externs, recorded);
previous = Some(source);
}
let directory = report_directory();
fs::create_dir_all(&directory).unwrap();
let path = directory.join("test_distributions.txt");
fs::write(&path, &report).unwrap();
println!(
"{} cases, of which {} disagree with the oracle:",
cases.len(),
disagreeing.len()
);
disagreeing.iter().for_each(|case| println!(" {case}"));
println!("the corpus, corrected by the oracle: {}", path.display());
assert!(
disagreeing.is_empty(),
"{} cases disagree with the oracle; see {}",
disagreeing.len(),
path.display()
);
}
#[cfg(unix)]
const DOOMED: &str = "XDY_ORACLE_DOOMED";
#[cfg(unix)]
const ORPHAN_MARKER: &str = "xdy-oracle-orphan:";
#[cfg(unix)]
const ORPHAN_DEADLINE: Duration = Duration::from_secs(10);
#[test]
#[ignore = "oracle: run with just oracle"]
#[cfg(unix)]
fn test_oracle_dies_with_its_parent()
{
let oracle = oracle_path();
if env::var_os(DOOMED).is_some()
{
let function = compile("20D20").unwrap();
let running = RunningOracle::ask(&oracle, &function, &[], &[]);
println!("{ORPHAN_MARKER}{}", running.child.id());
loop
{
thread::sleep(Duration::from_secs(60));
}
}
assert!(
oracle.exists(),
"no Lean oracle at {}; build it with `just lean`, or name it with \
{ORACLE}",
oracle.display()
);
let test = thread::current().name().unwrap().to_owned();
let mut parent = Command::new(env::current_exe().unwrap())
.args([
&test,
"--exact",
"--nocapture",
"--include-ignored",
"--test-threads=1"
])
.env(DOOMED, "1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let pid = BufReader::new(parent.stdout.take().unwrap())
.lines()
.find_map(|line| {
let line = line.unwrap();
let (_, pid) = line.split_once(ORPHAN_MARKER)?;
Some(pid.trim().to_owned())
});
parent.kill().unwrap();
parent.wait().unwrap();
let pid = pid.expect("the doomed parent never started the oracle");
let alive = || {
Command::new("kill")
.args(["-0", &pid])
.stderr(Stdio::null())
.status()
.unwrap()
.success()
};
let start = Instant::now();
while alive()
{
if start.elapsed() >= ORPHAN_DEADLINE
{
let _ = Command::new("kill").args(["-9", &pid]).status();
panic!(
"the oracle (pid {pid}) outlived its parent by {:?}",
ORPHAN_DEADLINE
);
}
thread::sleep(Duration::from_millis(10));
}
}
const ORACLE_CASES: u32 = 1_000;
const ORACLE_BUDGET: Budget = Budget {
steps: 1_000_000,
cells: 1_000_000
};
const ORACLE_DEADLINE: Duration = Duration::from_secs(2);
const ORACLE_TIMEOUT: Duration = Duration::from_secs(600);
#[test]
#[ignore = "oracle: run with just oracle"]
fn test_oracle_random_programs()
{
let oracle = oracle_path();
assert!(
oracle.exists(),
"no Lean oracle at {}; build it with `just lean`, or name it with \
{ORACLE}",
oracle.display()
);
on_small_stack_within(ORACLE_TIMEOUT, || {
check_within(
ORACLE_CASES,
file!(),
|| {
(
small_program(),
[small_binding(), small_binding()],
[
small_binding(),
small_binding(),
small_binding(),
small_binding(),
small_binding()
]
)
},
|(source, args, externals)| {
check_oracle(&oracle, &source, &args, &externals, |f| f)
}
)
});
}
#[test]
#[ignore = "oracle: run with just oracle"]
fn test_oracle_resummed_programs()
{
let oracle = oracle_path();
assert!(
oracle.exists(),
"no Lean oracle at {}; build it with `just lean`, or name it with \
{ORACLE}",
oracle.display()
);
on_small_stack_within(ORACLE_TIMEOUT, || {
check_within(
ORACLE_CASES,
file!(),
|| {
(
small_program(),
[small_binding(), small_binding()],
[
small_binding(),
small_binding(),
small_binding(),
small_binding(),
small_binding()
],
prop::collection::vec(any::<bool>(), 1..8)
)
},
|(source, args, externals, chosen)| {
check_oracle(&oracle, &source, &args, &externals, |f| {
resum(f, &chosen)
})
}
)
});
}
fn check_oracle(
oracle: &Path,
source: &str,
args: &[i32],
externals: &[i32],
rewrite: impl Fn(Function) -> Function
) -> Result<(), TestCaseError>
{
let Ok(unoptimized) = compile_unoptimized(source)
else
{
return Ok(())
};
let optimized = optimize(unoptimized.clone(), Passes::all());
let args = &args[..unoptimized.arity()];
for function in [unoptimized, optimized]
{
let function = rewrite(function);
let externals = NAMES
.iter()
.zip(externals)
.filter(|(name, _)| function.externals.contains(&name.to_string()))
.map(|(name, value)| (*name, *value))
.collect::<Vec<_>>();
let mut evaluator = Evaluator::new(function.clone());
for (name, value) in &externals
{
evaluator.bind(name, *value).unwrap();
}
let bounds = evaluator
.bounds_over(
args.iter().map(|arg| Some((*arg).into())),
externals
.iter()
.map(|(name, value)| (*name, (*value).into()))
)
.unwrap();
let propagated = match evaluator
.plan_distribution(args.iter().copied())
.unwrap()
.build(ORACLE_BUDGET, &Unobserved)
{
Ok(distribution) => distribution,
Err(BuildError::BudgetExhausted { .. }) => continue,
Err(e) =>
{
return Err(TestCaseError::fail(format!("build failed: {e}")))
},
};
for (outcome, _) in &propagated
{
prop_assert!(
bounds.value.contains(outcome),
"outcome {} out of bounds {}\nfunction:\n{}",
outcome,
bounds.value,
function
);
}
if let Some(count) = bounds.count
&& count < u128::MAX
{
prop_assert_eq!(
propagated.total(),
&Weight::from(count),
"total is not the count\nfunction:\n{}",
function
);
}
let answer = match ask_oracle(
oracle,
&function,
args,
&externals,
ORACLE_DEADLINE
)
{
Ok(answer) => answer,
Err(Unanswered::Late) => continue,
Err(Unanswered::Refused(e)) =>
{
return Err(TestCaseError::fail(format!(
"the oracle refused: {e}"
)))
},
};
prop_assert!(
agree(&propagated, &answer),
"disagrees with the oracle\nfunction:\n{}\npropagated:\n{}\n\
oracle:\n{}",
function,
propagated,
answer
);
prop_assert_eq!(propagated.mean(), answer.mean(), "means differ");
}
Ok(())
}
const SMALL_MAX_DEPTH: u32 = 4;
const SMALL_TARGET_SIZE: u32 = 16;
fn small_program() -> impl Strategy<Value = String>
{
let expression = || small_expression().boxed();
let body = prop_oneof![
1 => expression(),
2 => small_dice(expression()),
1 => (expression(), expression())
.prop_map(|(start, end)| format!("[{}:{}]", start, end))
];
with_parameters(body)
}
fn small_expression() -> impl Strategy<Value = String>
{
let leaf = prop_oneof![small_constant(), variable()];
leaf.prop_recursive(SMALL_MAX_DEPTH, SMALL_TARGET_SIZE, 3, |inner| {
prop_oneof![
2 => (inner.clone(), select(OPERATORS), inner.clone()).prop_map(
|(left, op, right)| format!("{} {} {}", left, op, right)
),
1 => inner.clone().prop_map(|operand| format!("-{}", operand)),
2 => (variable(), inner.clone())
.prop_map(|(name, e)| format!("{}@({})", name, e)),
2 => (inner.clone(), inner.clone())
.prop_map(|(start, end)| format!("[{}:{}]", start, end)),
4 => small_dice(inner)
]
})
}
fn small_dice(inner: BoxedStrategy<String>) -> impl Strategy<Value = String>
{
let faces = prop_oneof![
3 => small_atom(inner.clone(), small_size()),
1 => prop::collection::vec(-2i32..=4, 1..4).prop_map(|faces| {
let faces = faces.iter().map(i32::to_string).collect::<Vec<_>>();
format!("[{}]", faces.join(", "))
})
];
let drop = small_atom(inner.clone(), small_constant()).prop_map(|drop| {
if drop.starts_with('-')
{
format!("({})", drop)
}
else
{
drop
}
});
let clause = (select(&["lowest", "highest"][..]), option::of(drop))
.prop_map(|(direction, drop)| match drop
{
Some(drop) => format!(" drop {} {}", direction, drop),
None => format!(" drop {}", direction)
});
(
small_atom(inner, small_size()),
faces,
prop::collection::vec(clause, 0..3)
)
.prop_map(|(count, faces, clauses)| {
format!("{}D{}{}", count, faces, clauses.concat())
})
}
fn small_atom(
inner: BoxedStrategy<String>,
constant: impl Strategy<Value = String> + 'static
) -> impl Strategy<Value = String>
{
prop_oneof![
2 => constant,
1 => variable(),
2 => inner.clone().prop_map(|e| format!("({})", e)),
1 => (variable(), inner).prop_map(|(name, e)| format!("{}@({})", name, e))
]
}
fn small_constant() -> impl Strategy<Value = String>
{
(-2i32..=4).prop_map(|n| n.to_string())
}
fn small_size() -> impl Strategy<Value = String>
{
prop_oneof![4 => 1i32..=3, 1 => -2i32..=0].prop_map(|n| n.to_string())
}
fn small_binding() -> impl Strategy<Value = i32>
{
prop_oneof![3 => -2i32..=4, 1 => binding()]
}