use std::collections::BTreeMap;
use std::path::Path;
use std::process::ExitCode;
use num_bigint::BigInt;
use num_rational::BigRational;
use proptest::strategy::{Strategy, ValueTree};
use proptest::test_runner::{Config, RngAlgorithm, TestRng, TestRunner};
use ridl_ir::v2;
use ridl_sem::expr::NumericBacking;
use ridl_sem::expr_eval::{EvalEnv, Value, eval_expr, parse_contract_expr};
use ridl_sem::scalar::{ExactValue, FloatRange, IntRange};
use ridl_sem::{Resolution, SymbolKind, testgen};
#[derive(Clone, Copy, Debug, clap::ValueEnum)]
pub enum TestFormat {
Text,
Json,
}
const LIVE_STATE_SKIP: &str = "skipped: reads live state — observer territory (E5)";
const SUSPECT: &str = "suspect: no sampled input satisfies this precondition";
const COMBINATION_CAVEAT: &str = "boundary combinations across parameters are not explored, so this may be a \
limit of the sampling rather than of the model";
struct PackageReport {
package: String,
ranges: Vec<RangeReport>,
contracts: Vec<ContractReport>,
}
struct RangeReport {
type_name: String,
status: RangeStatus,
}
enum RangeStatus {
Ok {
boundary: usize,
violations: usize,
},
Failed(String),
}
struct ContractReport {
id: String,
source: String,
is_ensure: bool,
status: ContractStatus,
}
enum ContractStatus {
Ok {
satisfied_boundary: usize,
satisfied_random: usize,
boundary: usize,
random: usize,
discarded: usize,
},
Suspect {
boundary: usize,
random: usize,
discarded: usize,
params: usize,
},
Constant {
holds: bool,
},
Skipped(String),
Error(String),
ObserverStub,
}
impl ContractStatus {
fn word(&self) -> &'static str {
match self {
ContractStatus::Ok { .. } => "ok",
ContractStatus::Constant { holds: true } => "constant-true",
ContractStatus::Constant { holds: false } => "constant-false",
ContractStatus::Suspect { .. } => "suspect",
ContractStatus::Skipped(_) => "skipped",
ContractStatus::Error(_) => "error",
ContractStatus::ObserverStub => "observer-stub",
}
}
}
impl PackageReport {
fn failed(&self) -> bool {
self.ranges
.iter()
.any(|range| matches!(range.status, RangeStatus::Failed(_)))
|| self
.contracts
.iter()
.any(|contract| matches!(contract.status, ContractStatus::Error(_)))
}
}
pub fn run(path: &Path, samples: usize, format: TestFormat) -> ExitCode {
if samples == 0 {
eprintln!("error: `--samples` must be at least 1");
return ExitCode::from(2);
}
let mut db = ridl_core::RidlDatabase::default();
let output = match ridlc::compile_workspace(&mut db, path) {
Ok(output) => output,
Err(err) => {
eprintln!("error: {}: {err}", path.display());
return ExitCode::from(2);
}
};
if output
.diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == ridl_core::diag::Severity::Error)
{
eprint!(
"{}",
ridl_core::diag::render(&output.diagnostics, &output.sources)
);
return ExitCode::from(2);
}
let names = Names::of(&output);
let reports: Vec<PackageReport> = output
.checked
.iter()
.zip(&output.resolutions)
.map(|(checked, resolution)| {
run_package(
&Home {
ir: &checked.ir,
resolution,
},
&names,
samples,
)
})
.collect();
match format {
TestFormat::Text => print!("{}", render_text(&reports)),
TestFormat::Json => println!("{}", render_json(&reports)),
}
if reports.iter().any(PackageReport::failed) {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}
fn run_package(home: &Home, names: &Names, samples: usize) -> PackageReport {
let package = home.ir;
let vocabulary = names.vocabulary(home);
let mut ranges = Vec::new();
for decl in &package.decls {
let Some(v2::decl::Kind::TypeDef(type_def)) = &decl.kind else {
continue;
};
if let Some(status) = check_range(type_def) {
ranges.push(RangeReport {
type_name: decl.name.clone(),
status,
});
}
}
let mut contracts = Vec::new();
for shape in package.shapes() {
for interaction in &shape.interface.interactions {
let (params, clauses) = match &interaction.kind {
Some(v2::decl::Kind::CommandDef(command)) => (&command.params, &command.contracts),
Some(v2::decl::Kind::QueryDef(query)) => (&query.params, &query.contracts),
_ => continue,
};
for clause in clauses {
contracts.push(run_contract(
home,
names,
&vocabulary,
params,
clause,
samples,
));
}
}
}
PackageReport {
package: package.name.clone(),
ranges,
contracts,
}
}
fn check_range(type_def: &v2::TypeDef) -> Option<RangeStatus> {
let constraint = type_def.constraint.as_ref()?;
let min = ExactValue::parse(constraint.min.as_deref()?)?;
let max = ExactValue::parse(constraint.max.as_deref()?)?;
let (boundary, violations) = match type_def.width.as_ref()? {
v2::type_def::Width::IntWidth(_) => {
let range = IntRange {
min: min.clone(),
max: max.clone(),
};
(
testgen::boundary_values(&range)
.into_iter()
.map(integer)
.collect::<Vec<_>>(),
testgen::violations(&range)
.into_iter()
.map(integer)
.collect::<Vec<_>>(),
)
}
v2::type_def::Width::FloatWidth(_) => {
let range = FloatRange {
min: min.clone(),
max: max.clone(),
step: constraint.step.as_deref().and_then(ExactValue::parse),
};
(
testgen::float_boundary_values(&range),
testgen::float_violations(&range),
)
}
};
for value in &boundary {
if !accepts(value, &min, &max) {
return Some(RangeStatus::Failed(format!(
"boundary value {} is rejected by the range [{}..{}]",
value.to_decimal_string(),
min.to_decimal_string(),
max.to_decimal_string()
)));
}
}
for value in &violations {
if accepts(value, &min, &max) {
return Some(RangeStatus::Failed(format!(
"violation value {} is accepted by the range [{}..{}]",
value.to_decimal_string(),
min.to_decimal_string(),
max.to_decimal_string()
)));
}
}
Some(RangeStatus::Ok {
boundary: boundary.len(),
violations: violations.len(),
})
}
fn accepts(value: &ExactValue, min: &ExactValue, max: &ExactValue) -> bool {
ridl_sem::scalar::range_accepts(value, Some(min), Some(max))
}
fn integer(value: i64) -> ExactValue {
ExactValue(BigRational::from_integer(BigInt::from(value)))
}
fn run_contract(
home: &Home,
names: &Names,
vocabulary: &BTreeMap<String, Value>,
params: &[v2::Param],
clause: &v2::Contract,
samples: usize,
) -> ContractReport {
let is_ensure = clause.kind == v2::ContractKind::Ensure as i32;
let report = |status| ContractReport {
id: clause.observer_id.clone(),
source: clause.source.clone(),
is_ensure,
status,
};
if is_ensure {
return report(ContractStatus::ObserverStub);
}
if !clause.signal_refs.is_empty() {
return report(ContractStatus::Skipped(LIVE_STATE_SKIP.to_string()));
}
let mut generators = Vec::new();
for name in &clause.param_refs {
let Some(param) = params.iter().find(|param| ¶m.name == name) else {
return report(ContractStatus::Skipped(format!(
"skipped: `{name}` is not a parameter of this interaction"
)));
};
match names.generator_for(home, param) {
Some(generator) => generators.push((name.clone(), generator)),
None => {
return report(ContractStatus::Skipped(format!(
"skipped: `{name}` has no generatable range"
)));
}
}
}
let Some(expr) = parse_contract_expr(&clause.source) else {
return report(ContractStatus::Error(format!(
"the canonical clause text `{}` does not parse back",
clause.source
)));
};
if generators.is_empty() {
let env = EvalEnv {
params: &[],
result: None,
consts: &|name: &str| vocabulary.get(name).cloned(),
};
return match eval_expr(&expr, &env) {
Ok(Value::Bool(true)) => report(ContractStatus::Constant { holds: true }),
Ok(Value::Bool(false)) => report(ContractStatus::Constant { holds: false }),
Ok(_) => report(ContractStatus::Error(
"the clause did not evaluate to a boolean".to_string(),
)),
Err(err) => report(ContractStatus::Error(format!(
"{err} while evaluating `{}`",
clause.source
))),
};
}
let mut runner = TestRunner::new_with_rng(
Config::default(),
TestRng::from_seed(
RngAlgorithm::ChaCha,
&seed(&home.ir.name, &clause.observer_id, &clause.source),
),
);
let consts = |name: &str| vocabulary.get(name).cloned();
let corpora: Vec<Vec<Value>> = generators
.iter()
.map(|(_, generator)| generator.boundary_corpus())
.collect();
let boundary_tuples = corpora.iter().map(Vec::len).max().unwrap_or(0);
let mut satisfied_boundary = 0usize;
let mut satisfied_random = 0usize;
let mut drawn_random = 0usize;
let mut discarded = 0usize;
for index in 0..(boundary_tuples + samples) {
let is_boundary = index < boundary_tuples;
let mut bindings = Vec::with_capacity(generators.len());
for (position, (name, generator)) in generators.iter().enumerate() {
let value = if is_boundary {
corpora
.get(position)
.filter(|corpus| !corpus.is_empty())
.map(|corpus| corpus[index % corpus.len()].clone())
} else {
draw(generator, &mut runner)
};
match value {
Some(value) => bindings.push((name.clone(), value)),
None if is_boundary => {
return report(ContractStatus::Error(format!(
"cannot bind the boundary value for `{name}`"
)));
}
None => break,
}
}
if bindings.len() != generators.len() {
discarded += 1;
continue;
}
if !is_boundary {
drawn_random += 1;
}
let env = EvalEnv {
params: &bindings,
result: None,
consts: &consts,
};
match eval_expr(&expr, &env) {
Ok(Value::Bool(true)) => {
if is_boundary {
satisfied_boundary += 1;
} else {
satisfied_random += 1;
}
}
Ok(Value::Bool(false)) => {}
Ok(_) => {
return report(ContractStatus::Error(
"the clause did not evaluate to a boolean".to_string(),
));
}
Err(err) => {
return report(ContractStatus::Error(format!(
"{err} while evaluating `{}`",
clause.source
)));
}
}
}
if satisfied_boundary + satisfied_random == 0 {
report(ContractStatus::Suspect {
boundary: boundary_tuples,
random: drawn_random,
discarded,
params: generators.len(),
})
} else {
report(ContractStatus::Ok {
satisfied_boundary,
satisfied_random,
boundary: boundary_tuples,
random: drawn_random,
discarded,
})
}
}
enum Generator {
Int(IntRange),
Float(FloatRange),
Duration(FloatRange),
}
impl Generator {
fn boundary_corpus(&self) -> Vec<Value> {
let (mut corpus, min, max) = match self {
Generator::Int(range) => (
testgen::boundary_values(range)
.into_iter()
.map(integer)
.collect::<Vec<_>>(),
&range.min,
&range.max,
),
Generator::Float(range) | Generator::Duration(range) => (
testgen::float_boundary_values(range),
&range.min,
&range.max,
),
};
let zero = ExactValue(BigRational::from_integer(BigInt::from(0)));
if ridl_sem::scalar::range_accepts(&zero, Some(min), Some(max))
&& !corpus.iter().any(|held| held.0 == zero.0)
{
corpus.push(zero);
}
corpus.into_iter().map(|value| self.bind(value)).collect()
}
fn bind(&self, drawn: ExactValue) -> Value {
match self {
Generator::Int(_) => Value::Num(drawn, NumericBacking::Integer),
Generator::Float(_) => Value::Num(drawn, NumericBacking::Float),
Generator::Duration(_) => Value::Dur(milliseconds_to_micros(drawn)),
}
}
fn range(&self) -> (&ExactValue, &ExactValue) {
match self {
Generator::Int(range) => (&range.min, &range.max),
Generator::Float(range) | Generator::Duration(range) => (&range.min, &range.max),
}
}
}
fn draw(generator: &Generator, runner: &mut TestRunner) -> Option<Value> {
let drawn = match generator {
Generator::Int(range) => {
integer(testgen::int_values(range).new_tree(runner).ok()?.current())
}
Generator::Float(range) | Generator::Duration(range) => {
let value = testgen::float_values(range)
.new_tree(runner)
.ok()?
.current();
if !value.is_finite() {
return None;
}
ExactValue::parse(&format!("{value}"))?
}
};
let (min, max) = generator.range();
ridl_sem::scalar::range_accepts(&drawn, Some(min), Some(max)).then(|| generator.bind(drawn))
}
const DURATION: (&str, &str) = ("ridl.std", "Duration");
fn milliseconds_to_micros(value: ExactValue) -> ExactValue {
ExactValue(value.0 * BigRational::from_integer(BigInt::from(1000)))
}
struct Home<'a> {
ir: &'a v2::Package,
resolution: &'a Resolution,
}
struct Names<'a> {
packages: BTreeMap<&'a str, &'a v2::Package>,
}
impl<'a> Names<'a> {
fn of(output: &'a ridlc::WorkspaceOutput) -> Names<'a> {
let mut packages: BTreeMap<&str, &v2::Package> = BTreeMap::new();
for checked in &output.checked {
packages.entry(&checked.ir.name).or_insert(&checked.ir);
}
packages
.entry(&output.std_ir.name)
.or_insert(&output.std_ir);
Names { packages }
}
fn split<'r>(home: &'r str, reference: &'r str) -> (&'r str, &'r str) {
match reference.rsplit_once('.') {
Some((package, name)) => (package, name),
None => (home, reference),
}
}
fn decl(&self, home: &Home<'a>, package: &str, name: &str) -> Option<&'a v2::decl::Kind> {
let target = if package == home.ir.name {
home.ir
} else {
*self.packages.get(package)?
};
target
.decls
.iter()
.find(|decl| decl.name == name)?
.kind
.as_ref()
}
fn vocabulary(&self, home: &Home<'a>) -> BTreeMap<String, Value> {
let mut bound = BTreeMap::new();
for (local, symbol) in &home.resolution.symbols {
match symbol.kind {
SymbolKind::Const => {
let Some(v2::decl::Kind::ConstDef(const_def)) =
self.decl(home, &symbol.package, &symbol.name)
else {
continue;
};
let Some(value) = self.const_value(home, &symbol.package, const_def) else {
continue;
};
bound.insert(local.clone(), value);
}
SymbolKind::Enum => {
let Some(v2::decl::Kind::EnumDef(enum_def)) =
self.decl(home, &symbol.package, &symbol.name)
else {
continue;
};
let reference = ridl_sem::expr::qualified_ref(symbol);
for member in &enum_def.values {
bound.insert(
format!("{local}.{}", member.name),
Value::EnumVal(reference.clone(), member.value),
);
}
}
_ => {}
}
}
bound
}
fn const_value(
&self,
home: &Home<'a>,
declaring: &str,
const_def: &v2::ConstDef,
) -> Option<Value> {
let value = ExactValue::parse(&const_def.value)?;
let spelled = if const_def.value.contains('.') {
NumericBacking::Float
} else {
NumericBacking::Integer
};
let Some(type_ref) = const_def.type_ref.as_deref() else {
return Some(Value::Num(value, spelled));
};
match type_ref {
"integer" => return Some(Value::Num(value, NumericBacking::Integer)),
"float" => return Some(Value::Num(value, NumericBacking::Float)),
_ => {}
}
let (package, name) = Names::split(declaring, type_ref);
if (package, name) == DURATION {
return Some(Value::Dur(milliseconds_to_micros(value)));
}
let backing = match self.decl(home, package, name) {
Some(v2::decl::Kind::TypeDef(type_def)) => match type_def.width.as_ref() {
Some(v2::type_def::Width::IntWidth(_)) => NumericBacking::Integer,
Some(v2::type_def::Width::FloatWidth(_)) => NumericBacking::Float,
None => spelled,
},
_ => spelled,
};
Some(Value::Num(value, backing))
}
fn generator_for(&self, home: &Home<'a>, param: &v2::Param) -> Option<Generator> {
let Some(v2::field_type::Kind::Named(reference)) = param.r#type.as_ref()?.kind.as_ref()
else {
return None;
};
let (package, name) = Names::split(&home.ir.name, reference);
let Some(v2::decl::Kind::TypeDef(type_def)) = self.decl(home, package, name) else {
return None;
};
let constraint = type_def.constraint.as_ref()?;
let min = ExactValue::parse(constraint.min.as_deref()?)?;
let max = ExactValue::parse(constraint.max.as_deref()?)?;
let step = constraint.step.as_deref().and_then(ExactValue::parse);
if (package, name) == DURATION {
return Some(Generator::Duration(FloatRange { min, max, step }));
}
match type_def.width.as_ref()? {
v2::type_def::Width::IntWidth(_) => Some(Generator::Int(IntRange { min, max })),
v2::type_def::Width::FloatWidth(_) => {
Some(Generator::Float(FloatRange { min, max, step }))
}
}
}
}
fn seed(package: &str, observer_id: &str, source: &str) -> [u8; 32] {
let mut seed = [0u8; 32];
for (lane, chunk) in seed.chunks_mut(8).enumerate() {
let material = format!("{package}\u{0}{observer_id}\u{0}{source}\u{0}{lane}");
chunk.copy_from_slice(&fnv1a(material.as_bytes()).to_le_bytes());
}
seed
}
fn fnv1a(bytes: &[u8]) -> u64 {
let mut hash = 0xcbf2_9ce4_8422_2325_u64;
for byte in bytes {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
fn render_text(reports: &[PackageReport]) -> String {
let mut out = String::new();
for report in reports {
out.push_str(&format!("package {}\n", report.package));
out.push_str(" ranges\n");
if report.ranges.is_empty() {
out.push_str(" (no constrained named types)\n");
}
for range in &report.ranges {
match &range.status {
RangeStatus::Ok {
boundary,
violations,
} => out.push_str(&format!(
" {} ok — {boundary} boundary accepted, {violations} violations rejected\n",
range.type_name
)),
RangeStatus::Failed(why) => {
out.push_str(&format!(" {} FAILED — {why}\n", range.type_name));
}
}
}
for (heading, ensure) in [("requires", false), ("ensures", true)] {
out.push_str(&format!(" {heading}\n"));
let mut any = false;
for contract in report
.contracts
.iter()
.filter(|contract| contract.is_ensure == ensure)
{
any = true;
out.push_str(&format!(" {} {}\n", contract.id, describe(contract)));
}
if !any {
out.push_str(" (none)\n");
}
}
let summary = Summary::of(report);
out.push_str(&format!(" {}\n", summary.line()));
if let Some(warning) = summary.warning() {
out.push_str(&format!(" {warning}\n"));
}
}
out
}
struct Summary {
requires: usize,
evaluated: usize,
suspect: usize,
constant_false: usize,
skipped: usize,
errors: usize,
ensures: usize,
}
impl Summary {
fn of(report: &PackageReport) -> Summary {
let mut summary = Summary {
requires: 0,
evaluated: 0,
suspect: 0,
constant_false: 0,
skipped: 0,
errors: 0,
ensures: 0,
};
for contract in &report.contracts {
if contract.is_ensure {
summary.ensures += 1;
continue;
}
summary.requires += 1;
match &contract.status {
ContractStatus::Ok { .. } | ContractStatus::Constant { holds: true } => {
summary.evaluated += 1;
}
ContractStatus::Constant { holds: false } => {
summary.evaluated += 1;
summary.constant_false += 1;
}
ContractStatus::Suspect { .. } => {
summary.evaluated += 1;
summary.suspect += 1;
}
ContractStatus::Skipped(_) => summary.skipped += 1,
ContractStatus::Error(_) => summary.errors += 1,
ContractStatus::ObserverStub => {}
}
}
summary
}
fn line(&self) -> String {
let mut parts = vec![format!(
"requires: {} total, {} evaluated",
self.requires, self.evaluated
)];
if self.suspect > 0 {
parts.push(format!("{} suspect", self.suspect));
}
if self.constant_false > 0 {
parts.push(format!("{} constant-false", self.constant_false));
}
if self.skipped > 0 {
parts.push(format!("{} skipped", self.skipped));
}
if self.errors > 0 {
parts.push(format!("{} errored", self.errors));
}
format!(
"summary — {}; ensures: {} listed",
parts.join(", "),
self.ensures
)
}
fn warning(&self) -> Option<String> {
(self.requires > 0 && self.evaluated == 0).then(|| {
format!(
"WARNING: no require clause was evaluated — this run tested no \
precondition ({} skipped of {})",
self.skipped, self.requires
)
})
}
fn nothing_evaluated(&self) -> bool {
self.requires > 0 && self.evaluated == 0
}
}
fn describe(contract: &ContractReport) -> String {
match &contract.status {
ContractStatus::Ok {
satisfied_boundary,
satisfied_random,
boundary,
random,
discarded,
} => {
format!(
"ok — {satisfied_boundary} boundary + {satisfied_random} random of {} satisfied{} ({})",
boundary + random,
discarded_note(*discarded),
contract.source
)
}
ContractStatus::Suspect {
boundary,
random,
discarded,
params,
} => {
format!(
"{SUSPECT} — 0/{} ({boundary} boundary + {random} random){}{} ({})",
boundary + random,
discarded_note(*discarded),
if *params > 1 {
format!("; {COMBINATION_CAVEAT}")
} else {
String::new()
},
contract.source
)
}
ContractStatus::Constant { holds } => format!(
"{} — reads no parameter, evaluated once ({})",
if *holds {
"ok, constant"
} else {
"constant FALSE"
},
contract.source
),
ContractStatus::Skipped(why) => format!("{why} ({})", contract.source),
ContractStatus::Error(why) => format!("ERROR — {why}"),
ContractStatus::ObserverStub => {
format!("observer stub — not evaluated ({})", contract.source)
}
}
}
fn discarded_note(discarded: usize) -> String {
if discarded == 0 {
String::new()
} else {
format!(", {discarded} discarded as out of range")
}
}
fn render_json(reports: &[PackageReport]) -> String {
let packages: Vec<serde_json::Value> = reports
.iter()
.map(|report| {
let ranges: Vec<serde_json::Value> = report
.ranges
.iter()
.map(|range| match &range.status {
RangeStatus::Ok {
boundary,
violations,
} => serde_json::json!({
"type": range.type_name,
"status": "ok",
"boundary": boundary,
"violations": violations,
}),
RangeStatus::Failed(why) => serde_json::json!({
"type": range.type_name,
"status": "failed",
"detail": why,
}),
})
.collect();
let contracts: Vec<serde_json::Value> = report
.contracts
.iter()
.map(|contract| {
let (satisfied, samples, boundary, random, discarded) = match &contract.status {
ContractStatus::Ok {
satisfied_boundary,
satisfied_random,
boundary,
random,
discarded,
} => (
Some(satisfied_boundary + satisfied_random),
Some(boundary + random),
Some(*boundary),
Some(*random),
Some(*discarded),
),
ContractStatus::Suspect {
boundary,
random,
discarded,
..
} => (
Some(0),
Some(boundary + random),
Some(*boundary),
Some(*random),
Some(*discarded),
),
_ => (None, None, None, None, None),
};
let detail = match &contract.status {
ContractStatus::Skipped(why) => Some(why.clone()),
ContractStatus::Error(why) => Some(why.clone()),
ContractStatus::Suspect { params, .. } if *params > 1 => {
Some(format!("{SUSPECT}; {COMBINATION_CAVEAT}"))
}
ContractStatus::Suspect { .. } => Some(SUSPECT.to_string()),
_ => None,
};
serde_json::json!({
"id": contract.id,
"status": contract.status.word(),
"satisfied": satisfied,
"samples": samples,
"boundary_samples": boundary,
"random_samples": random,
"discarded_samples": discarded,
"source": contract.source,
"detail": detail,
})
})
.collect();
let summary = Summary::of(report);
serde_json::json!({
"package": report.package,
"contracts": contracts,
"ranges": ranges,
"summary": {
"requires_total": summary.requires,
"requires_evaluated": summary.evaluated,
"requires_suspect": summary.suspect,
"requires_constant_false": summary.constant_false,
"requires_skipped": summary.skipped,
"requires_errored": summary.errors,
"ensures_listed": summary.ensures,
"nothing_evaluated": summary.nothing_evaluated(),
},
})
})
.collect();
serde_json::Value::Array(packages).to_string()
}