Borrow integrated shrinking approach from MiniThesis.

This commit is contained in:
KtorZ 2024-02-26 21:59:23 +01:00
parent 3762473a60
commit a703db4d14
No known key found for this signature in database
GPG Key ID: 33173CB6F77F4277
8 changed files with 773 additions and 305 deletions

View File

@ -339,10 +339,24 @@ fn infer_definition(
Type::Fn { ret, .. } => { Type::Fn { ret, .. } => {
let ann = tipo_to_annotation(ret, location)?; let ann = tipo_to_annotation(ret, location)?;
match ann { match ann {
Annotation::Tuple { elems, .. } if elems.len() == 2 => { Annotation::Constructor {
module,
name,
arguments,
..
} if module.as_ref().unwrap_or(&String::new()).is_empty()
&& name == "Option" =>
{
match &arguments[..] {
[Annotation::Tuple { elems, .. }] if elems.len() == 2 => {
Ok(elems.get(1).expect("Tuple has two elements").to_owned()) Ok(elems.get(1).expect("Tuple has two elements").to_owned())
} }
_ => todo!("Fuzzer returns something else than a 2-tuple? "), _ => {
todo!("expected a single generic argument unifying as 2-tuple")
}
}
}
_ => todo!("expected an Option<a>"),
} }
} }
Type::Var { .. } | Type::App { .. } | Type::Tuple { .. } => { Type::Var { .. } | Type::App { .. } | Type::Tuple { .. } => {

View File

@ -93,7 +93,7 @@ pub enum Error {
path: PathBuf, path: PathBuf,
verbose: bool, verbose: bool,
src: String, src: String,
evaluation_hint: Option<String>, assertion: Option<String>,
}, },
#[error( #[error(
@ -323,7 +323,7 @@ impl Diagnostic for Error {
Error::MissingManifest { .. } => Some(Box::new("Try running `aiken new <REPOSITORY/PROJECT>` to initialise a project with an example manifest.")), Error::MissingManifest { .. } => Some(Box::new("Try running `aiken new <REPOSITORY/PROJECT>` to initialise a project with an example manifest.")),
Error::TomlLoading { .. } => None, Error::TomlLoading { .. } => None,
Error::Format { .. } => None, Error::Format { .. } => None,
Error::TestFailure { evaluation_hint, .. } => match evaluation_hint { Error::TestFailure { assertion, .. } => match assertion {
None => None, None => None,
Some(hint) => Some(Box::new(hint.to_string())) Some(hint) => Some(Box::new(hint.to_string()))
}, },

View File

@ -21,13 +21,19 @@ use crate::blueprint::{
schema::{Annotated, Schema}, schema::{Annotated, Schema},
Blueprint, Blueprint,
}; };
use crate::{
config::Config,
error::{Error, Warning},
module::{CheckedModule, CheckedModules, ParsedModule, ParsedModules},
telemetry::Event,
};
use aiken_lang::{ use aiken_lang::{
ast::{ ast::{
Definition, Function, ModuleKind, Span, Tracing, TypedDataType, TypedFunction, Validator, Definition, Function, ModuleKind, Span, Tracing, TypedDataType, TypedFunction, Validator,
}, },
builtins, builtins,
expr::TypedExpr, expr::TypedExpr,
gen_uplc::builder::{cast_validator_args, DataTypeKey, FunctionAccessKey}, gen_uplc::builder::{DataTypeKey, FunctionAccessKey},
tipo::{Type, TypeInfo}, tipo::{Type, TypeInfo},
IdGenerator, IdGenerator,
}; };
@ -40,8 +46,7 @@ use pallas::ledger::{
primitives::babbage::{self as cardano, PolicyId}, primitives::babbage::{self as cardano, PolicyId},
traverse::ComputeHash, traverse::ComputeHash,
}; };
use script::{Assertion, Test, TestResult};
use script::{EvalHint, EvalInfo, PropertyTest, Test};
use std::{ use std::{
collections::HashMap, collections::HashMap,
fs::{self, File}, fs::{self, File},
@ -56,13 +61,6 @@ use uplc::{
PlutusData, PlutusData,
}; };
use crate::{
config::Config,
error::{Error, Warning},
module::{CheckedModule, CheckedModules, ParsedModule, ParsedModules},
telemetry::Event,
};
#[derive(Debug)] #[derive(Debug)]
pub struct Source { pub struct Source {
pub path: PathBuf, pub path: PathBuf,
@ -323,24 +321,15 @@ where
self.event_listener.handle_event(Event::RunningTests); self.event_listener.handle_event(Event::RunningTests);
} }
let results = self.run_tests(tests.iter().collect()); let results = self.run_tests(tests);
let errors: Vec<Error> = results let errors: Vec<Error> = results
.iter() .iter()
.filter_map(|e| { .filter_map(|e| {
if e.success { if e.is_success() {
None None
} else { } else {
Some(Error::TestFailure { Some(e.into_error(verbose))
name: e.test.name().to_string(),
path: e.test.input_path().to_path_buf(),
evaluation_hint: e
.test
.evaluation_hint()
.map(|hint| hint.to_string()),
src: e.test.program().to_pretty(),
verbose,
})
} }
}) })
.collect(); .collect();
@ -816,7 +805,7 @@ where
}) })
} }
let evaluation_hint = func_def.test_hint().map(|(bin_op, left_src, right_src)| { let assertion = func_def.test_hint().map(|(bin_op, left_src, right_src)| {
let left = generator let left = generator
.clone() .clone()
.generate_raw(&left_src, &module_name) .generate_raw(&left_src, &module_name)
@ -829,7 +818,7 @@ where
.try_into() .try_into()
.unwrap(); .unwrap();
EvalHint { Assertion {
bin_op, bin_op,
left, left,
right, right,
@ -846,7 +835,7 @@ where
name.to_string(), name.to_string(),
*can_error, *can_error,
program.try_into().unwrap(), program.try_into().unwrap(),
evaluation_hint, assertion,
); );
programs.push(test); programs.push(test);
@ -862,14 +851,16 @@ where
ret: body.tipo(), ret: body.tipo(),
}), }),
is_capture: false, is_capture: false,
args: vec![parameter.clone().into()], args: vec![parameter.into()],
body: Box::new(body.clone()), body: Box::new(body.clone()),
return_annotation: None, return_annotation: None,
}; };
let program = generator.clone().generate_raw(&body, &module_name); let program = generator
.clone()
let term = cast_validator_args(program.term, &[parameter.into()]); .generate_raw(&body, &module_name)
.try_into()
.unwrap();
let fuzzer: Program<NamedDeBruijn> = generator let fuzzer: Program<NamedDeBruijn> = generator
.clone() .clone()
@ -882,7 +873,7 @@ where
module_name, module_name,
name.to_string(), name.to_string(),
*can_error, *can_error,
Program { term, ..program }.try_into().unwrap(), program,
fuzzer, fuzzer,
); );
@ -893,36 +884,16 @@ where
Ok(programs) Ok(programs)
} }
fn run_tests<'a>(&'a self, tests: Vec<&'a Test>) -> Vec<EvalInfo<'a>> { fn run_tests(&self, tests: Vec<Test>) -> Vec<TestResult> {
// FIXME: Find a way to re-introduce parallel testing despite the references (which aren't use rayon::prelude::*;
// sizeable).
// We do now hold references to tests because the property tests results are all pointing
// to the same test, so we end up copying the same test over and over.
//
// So we might want to rework the evaluation result to avoid that and keep parallel testing
// possible.
// use rayon::prelude::*;
tests tests
.iter() .into_par_iter()
.flat_map(|test| match test { .map(|test| match test {
Test::UnitTest(unit_test) => { Test::UnitTest(unit_test) => unit_test.run(),
let mut result = unit_test.run(); // TODO: Get the seed from the command-line, defaulting to a random one when not
vec![test.report(&mut result)] // provided.
} Test::PropertyTest(property_test) => property_test.run(42),
Test::PropertyTest(ref property_test) => {
let mut seed = PropertyTest::new_seed(42);
let mut results = vec![];
for _ in 0..100 {
let (new_seed, sample) = property_test.sample(seed);
seed = new_seed;
let mut result = property_test.run(&sample);
results.push(test.report(&mut result));
}
results
}
}) })
.collect() .collect()
} }

View File

@ -7,8 +7,8 @@ pub fn ansi_len(s: &str) -> usize {
.count() .count()
} }
pub fn len_longest_line(s: &str) -> usize { pub fn len_longest_line(zero: usize, s: &str) -> usize {
s.lines().fold(0, |max, l| { s.lines().fold(zero, |max, l| {
let n = ansi_len(l); let n = ansi_len(l);
if n > max { if n > max {
n n
@ -23,7 +23,7 @@ pub fn boxed(title: &str, content: &str) -> String {
} }
pub fn boxed_with(title: &str, content: &str, border_style: fn(&str) -> String) -> String { pub fn boxed_with(title: &str, content: &str, border_style: fn(&str) -> String) -> String {
let n = len_longest_line(content); let n = len_longest_line(ansi_len(title) + 1, content);
let content = content let content = content
.lines() .lines()
@ -62,7 +62,7 @@ pub fn open_box(
border_style: fn(&str) -> String, border_style: fn(&str) -> String,
) -> String { ) -> String {
let i = ansi_len(content.lines().collect::<Vec<_>>().first().unwrap()); let i = ansi_len(content.lines().collect::<Vec<_>>().first().unwrap());
let j = len_longest_line(content); let j = len_longest_line(ansi_len(title) + 1, content);
let k = ansi_len(footer); let k = ansi_len(footer);
let content = content let content = content
@ -79,7 +79,11 @@ pub fn open_box(
let bottom = format!( let bottom = format!(
"{} {}", "{} {}",
pad_right(border_style(""), j - k + 1, &border_style("")), pad_right(
border_style(""),
if j < k { 0 } else { j + 1 - k },
&border_style("")
),
footer footer
); );

View File

@ -1,16 +1,38 @@
use crate::{pretty, ExBudget}; use crate::{pretty, ExBudget};
use aiken_lang::ast::BinOp; use aiken_lang::ast::BinOp;
use pallas::codec::utils::Int;
use pallas::ledger::primitives::alonzo::{BigInt, Constr, PlutusData};
use std::{ use std::{
borrow::Borrow, borrow::Borrow,
fmt::{self, Display}, fmt::{self, Display},
path::{Path, PathBuf}, path::PathBuf,
rc::Rc, rc::Rc,
}; };
use uplc::{ use uplc::{
ast::{Constant, Data, NamedDeBruijn, Program, Term}, ast::{Constant, Data, NamedDeBruijn, Program, Term},
machine::eval_result::EvalResult, machine::{eval_result::EvalResult, value::from_pallas_bigint},
}; };
// ----------------------------------------------------------------------------
//
// Test
//
// Aiken supports two kinds of tests: unit and property. A unit test is a simply
// UPLC program which returns must be a lambda that returns a boolean.
//
// A property on the other-hand is a template for generating tests, which is also
// a lambda but that takes an extra argument. The argument is generated from a
// fuzzer which is meant to yield random values in a pseudo-random (albeit seeded)
// sequence. On failures, the value that caused a failure is simplified using an
// approach similar to what's described in MiniThesis<https://github.com/DRMacIver/minithesis>,
// which is a simplified version of Hypothesis, a property-based testing framework
// with integrated shrinking.
//
// Our approach could perhaps be called "microthesis", as it implements a subset of
// minithesis. More specifically, we do not currently support pre-conditions, nor
// targets.
// ----------------------------------------------------------------------------
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Test { pub enum Test {
UnitTest(UnitTest), UnitTest(UnitTest),
@ -26,7 +48,7 @@ impl Test {
name: String, name: String,
can_error: bool, can_error: bool,
program: Program<NamedDeBruijn>, program: Program<NamedDeBruijn>,
evaluation_hint: Option<EvalHint>, assertion: Option<Assertion>,
) -> Test { ) -> Test {
Test::UnitTest(UnitTest { Test::UnitTest(UnitTest {
input_path, input_path,
@ -34,7 +56,7 @@ impl Test {
name, name,
program, program,
can_error, can_error,
evaluation_hint, assertion,
}) })
} }
@ -55,57 +77,13 @@ impl Test {
fuzzer, fuzzer,
}) })
} }
pub fn name(&self) -> &str {
match self {
Test::UnitTest(test) => &test.name,
Test::PropertyTest(test) => &test.name,
}
} }
pub fn module(&self) -> &str { // ----------------------------------------------------------------------------
match self { //
Test::UnitTest(test) => &test.module, // UnitTest
Test::PropertyTest(test) => &test.module, //
} // ----------------------------------------------------------------------------
}
pub fn input_path(&self) -> &Path {
match self {
Test::UnitTest(test) => &test.input_path,
Test::PropertyTest(test) => &test.input_path,
}
}
pub fn program(&self) -> &Program<NamedDeBruijn> {
match self {
Test::UnitTest(test) => &test.program,
Test::PropertyTest(test) => &test.program,
}
}
pub fn evaluation_hint(&self) -> Option<&EvalHint> {
match self {
Test::UnitTest(test) => test.evaluation_hint.as_ref(),
Test::PropertyTest(_) => None,
}
}
pub fn report<'a>(&'a self, eval_result: &mut EvalResult) -> EvalInfo<'a> {
let can_error = match self {
Test::UnitTest(test) => test.can_error,
Test::PropertyTest(test) => test.can_error,
};
EvalInfo {
test: self,
success: !eval_result.failed(can_error),
spent_budget: eval_result.cost(),
logs: eval_result.logs(),
output: eval_result.result().ok(),
}
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct UnitTest { pub struct UnitTest {
@ -114,17 +92,30 @@ pub struct UnitTest {
pub name: String, pub name: String,
pub can_error: bool, pub can_error: bool,
pub program: Program<NamedDeBruijn>, pub program: Program<NamedDeBruijn>,
pub evaluation_hint: Option<EvalHint>, pub assertion: Option<Assertion>,
} }
unsafe impl Send for UnitTest {} unsafe impl Send for UnitTest {}
impl UnitTest { impl UnitTest {
pub fn run(&self) -> EvalResult { pub fn run(self) -> TestResult {
self.program.clone().eval(ExBudget::max()) let mut eval_result = self.program.clone().eval(ExBudget::max());
TestResult::UnitTestResult(UnitTestResult {
test: self.to_owned(),
success: !eval_result.failed(self.can_error),
spent_budget: eval_result.cost(),
logs: eval_result.logs(),
output: eval_result.result().ok(),
})
} }
} }
// ----------------------------------------------------------------------------
//
// PropertyTest
//
// ----------------------------------------------------------------------------
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PropertyTest { pub struct PropertyTest {
pub input_path: PathBuf, pub input_path: PathBuf,
@ -138,46 +129,514 @@ pub struct PropertyTest {
unsafe impl Send for PropertyTest {} unsafe impl Send for PropertyTest {}
impl PropertyTest { impl PropertyTest {
pub fn new_seed(seed: u32) -> Term<NamedDeBruijn> { const MAX_TEST_RUN: usize = 100;
Term::Constant(Rc::new(Constant::Data(Data::constr(
0, /// Run a property test from a given seed. The property is run at most MAX_TEST_RUN times. It
vec![ /// may stops earlier on failure; in which case a 'counterexample' is returned.
Data::integer(seed.into()), pub fn run(self, seed: u32) -> TestResult {
Data::integer(0.into()), // Size let n = PropertyTest::MAX_TEST_RUN;
],
)))) let (counterexample, iterations) = match self.run_n_times(n, seed, None) {
None => (None, n),
Some((remaining, counterexample)) => (Some(counterexample), n - remaining + 1),
};
TestResult::PropertyTestResult(PropertyTestResult {
test: self,
counterexample,
iterations,
})
} }
pub fn sample(&self, seed: Term<NamedDeBruijn>) -> (Term<NamedDeBruijn>, Term<NamedDeBruijn>) { fn run_n_times(
let term = self.fuzzer.apply_term(&seed).eval(ExBudget::max()).result(); &self,
remaining: usize,
seed: u32,
counterexample: Option<(usize, Term<NamedDeBruijn>)>,
) -> Option<(usize, Term<NamedDeBruijn>)> {
// We short-circuit failures in case we have any. The counterexample is already simplified
// at this point.
if remaining > 0 && counterexample.is_none() {
let (next_seed, counterexample) = self.run_once(seed);
self.run_n_times(
remaining - 1,
next_seed,
counterexample.map(|c| (remaining, c)),
)
} else {
counterexample
}
}
if let Ok(Term::Constant(rc)) = term { fn run_once(&self, seed: u32) -> (u32, Option<Term<NamedDeBruijn>>) {
match &rc.borrow() { let (next_prng, value) = Prng::from_seed(seed)
Constant::ProtoPair(_, _, new_seed, value) => ( .sample(&self.fuzzer)
Term::Constant(new_seed.clone()), .expect("running seeded Prng cannot fail.");
Term::Constant(value.clone()),
), let result = self.program.apply_term(&value).eval(ExBudget::max());
_ => todo!("Fuzzer yielded a new seed that isn't an integer?"),
if let Prng::Seeded {
seed: next_seed, ..
} = next_prng
{
if result.failed(self.can_error) {
let mut counterexample = Counterexample {
result,
value,
choices: next_prng.choices(),
can_error: self.can_error,
program: &self.program,
fuzzer: &self.fuzzer,
};
if !counterexample.choices.is_empty() {
counterexample.simplify();
}
(next_seed, Some(counterexample.value))
} else {
(next_seed, None)
} }
} else { } else {
todo!("Fuzzer yielded something else than a pair? {:#?}", term) unreachable!("Prng constructed from a seed necessarily yield a seed.");
}
} }
} }
pub fn run(&self, sample: &Term<NamedDeBruijn>) -> EvalResult { // ----------------------------------------------------------------------------
self.program.apply_term(sample).eval(ExBudget::max()) //
// Prng
//
// ----------------------------------------------------------------------------
#[derive(Debug)]
pub enum Prng {
Seeded {
seed: u32,
choices: Vec<u32>,
uplc: PlutusData,
},
Replayed {
choices: Vec<u32>,
uplc: PlutusData,
},
}
impl Prng {
/// Constructor tag for Prng's 'Seeded'
const SEEDED: u64 = 0;
/// Constructor tag for Prng's 'Replayed'
const REPLAYED: u64 = 1;
/// Constructor tag for Option's 'Some'
const OK: u64 = 0;
/// Constructor tag for Option's 'None'
const ERR: u64 = 1;
pub fn uplc(&self) -> PlutusData {
match self {
Prng::Seeded { uplc, .. } => uplc.clone(),
Prng::Replayed { uplc, .. } => uplc.clone(),
} }
} }
pub fn choices(&self) -> Vec<u32> {
match self {
Prng::Seeded { choices, .. } => {
let mut choices = choices.to_vec();
choices.reverse();
choices
}
Prng::Replayed { choices, .. } => choices.to_vec(),
}
}
/// Construct a Pseudo-random number generator from a seed.
pub fn from_seed(seed: u32) -> Prng {
Prng::Seeded {
seed,
choices: vec![],
uplc: Data::constr(
Prng::SEEDED,
vec![
Data::integer(seed.into()), // Prng's seed
Data::list(vec![]), // Random choices
],
),
}
}
/// Construct a Pseudo-random number generator from a pre-defined list of choices.
pub fn from_choices(choices: &[u32]) -> Prng {
Prng::Replayed {
choices: choices.to_vec(),
uplc: Data::constr(
Prng::REPLAYED,
vec![Data::list(
choices.iter().map(|i| Data::integer((*i).into())).collect(),
)],
),
}
}
/// Generate a pseudo-random value from a fuzzer using the given PRNG.
pub fn sample(&self, fuzzer: &Program<NamedDeBruijn>) -> Option<(Prng, Term<NamedDeBruijn>)> {
let result = fuzzer
.apply_data(self.uplc())
.eval(ExBudget::max())
.result()
.expect("Fuzzer crashed?");
Prng::from_result(result)
}
/// Obtain a Prng back from a fuzzer execution. As a reminder, fuzzers have the following
/// signature:
///
/// type Fuzzer<a> = fn(Prng) -> Option<(Prng, a)>
///
/// In nominal scenarios (i.e. when the fuzzer is made from a seed and evolve pseudo-randomly),
/// it cannot yield 'None'. When replayed however, we can't easily guarantee that the changes
/// made during shrinking aren't breaking underlying invariants (if only, because we run out of
/// values to replay). In such case, the replayed sequence is simply invalid and the fuzzer
/// aborted altogether with 'None'.
pub fn from_result(result: Term<NamedDeBruijn>) -> Option<(Self, Term<NamedDeBruijn>)> {
/// Interpret the given 'PlutusData' as one of two Prng constructors.
fn as_prng(cst: &PlutusData) -> Prng {
if let PlutusData::Constr(Constr { tag, fields, .. }) = cst {
if *tag == 121 + Prng::SEEDED {
if let [seed, PlutusData::Array(choices)] = &fields[..] {
return Prng::Seeded {
seed: as_u32(seed),
choices: choices.iter().map(as_u32).collect(),
uplc: cst.clone(),
};
}
}
if *tag == 121 + Prng::REPLAYED {
return Prng::Replayed {
choices: fields.iter().map(as_u32).collect(),
uplc: cst.clone(),
};
}
}
panic!("Malformed Prng: {cst:#?}")
}
fn as_u32(field: &PlutusData) -> u32 {
if let PlutusData::BigInt(BigInt::Int(Int(i))) = field {
return u32::try_from(*i).expect("Choice doesn't fit in u32?");
}
panic!("Malformed choice's value: {field:#?}")
}
/// Convert wrapped integer & bytearrays as raw constant terms. Because fuzzer
/// return a pair, those values end up being wrapped in 'Data', but test
/// functions will expect them in their raw constant form.
///
/// Anything else is Data, so we're good.
fn as_value(data: &PlutusData) -> Term<NamedDeBruijn> {
Term::Constant(Rc::new(match data {
PlutusData::BigInt(n) => Constant::Integer(from_pallas_bigint(n)),
PlutusData::BoundedBytes(bytes) => Constant::ByteString(bytes.clone().into()),
_ => Constant::Data(data.clone()),
}))
}
if let Term::Constant(rc) = &result {
if let Constant::Data(PlutusData::Constr(Constr { tag, fields, .. })) = &rc.borrow() {
if *tag == 121 + Prng::OK {
if let [PlutusData::Array(elems)] = &fields[..] {
if let [new_seed, value] = &elems[..] {
return Some((as_prng(new_seed), as_value(value)));
}
}
}
// May occurs when replaying a fuzzer from a shrinked sequence of
// choices. If we run out of choices, or a choice end up being
// invalid as per the expectation, the fuzzer can't go further and
// fail.
if *tag == 121 + Prng::ERR {
return None;
}
}
}
// In principle, this cannot happen provided that the 'result' was produced from a
// type-checked fuzzer. The type-checker enforces that fuzzers are of the right shape
// describe above.
unreachable!("Fuzzer yielded a malformed result? {result:#?}")
}
}
// ----------------------------------------------------------------------------
//
// Counterexample
//
// A counterexample is constructed on test failures.
//
// ----------------------------------------------------------------------------
#[derive(Debug)]
pub struct Counterexample<'a> {
pub value: Term<NamedDeBruijn>,
pub choices: Vec<u32>,
pub result: EvalResult,
pub can_error: bool,
pub program: &'a Program<NamedDeBruijn>,
pub fuzzer: &'a Program<NamedDeBruijn>,
}
impl<'a> Counterexample<'a> {
fn consider(&mut self, choices: &[u32]) -> bool {
if choices == self.choices {
return true;
}
// TODO: Memoize test cases & choices in a cache. Due to the nature of
// our integrated shrinking approach, we may end up re-executing the same
// test cases many times. Given that tests are fully deterministic, we can
// memoize the already seen choices to avoid re-running the generators and
// the test (which can be quite expensive).
match Prng::from_choices(choices).sample(self.fuzzer) {
// Shrinked choices led to an impossible generation.
None => false,
// Shrinked choices let to a new valid generated value, now, is it better?
Some((_, value)) => {
let result = self.program.apply_term(&value).eval(ExBudget::max());
// If the test no longer fails, it isn't better as we're only
// interested in counterexamples.
if !result.failed(self.can_error) {
return false;
}
// If these new choices are shorter or smaller, then we pick them
// as new choices and inform that it's been an improvement.
if choices.len() <= self.choices.len() || choices < &self.choices {
self.value = value;
self.choices = choices.to_vec();
true
} else {
false
}
}
}
}
/// Try to simplify a 'Counterexample' by manipulating the random sequence of generated values
/// (a.k.a. choices). While the implementation is quite involved, the strategy is rather simple
/// at least conceptually:
///
/// Each time a (seeded) fuzzer generates a new value and a new seed, it also stores the
/// generated value in a vector, which we call 'choices'. If we re-run the test case with this
/// exact choice sequence, we end up with the exact same outcome.
///
/// But, we can tweak chunks of this sequence in hope to generate a _smaller sequence_, thus
/// generally resulting in a _smaller counterexample_. Each transformations is applied on
/// chunks of size 8, 4, 2 and 1; until we no longer make progress (i.e. hit a fix point).
///
/// As per MiniThesis, we consider the following transformations:
///
/// - Deleting chunks
/// - Transforming chunks into sequence of zeroes
/// - Decrementing chunks of values
/// - Replacing chunks of values
/// - Sorting chunks
/// - Redistribute values between nearby pairs
fn simplify(&mut self) {
let mut prev;
loop {
prev = self.choices.clone();
// Delete choices by chunks of size 8, 4, 2, 1.
let mut k: isize = 8;
while k > 0 {
let mut i: isize = (self.choices.len() as isize) - k - 1;
while i >= 0 {
if i >= self.choices.len() as isize {
i -= 1;
continue;
}
let mut choices = self.choices[0..(i + k) as usize].to_vec();
if !self.consider(&choices) {
// Perform an extra reduction step that decrease the size of choices near
// the end, to cope with dependencies between choices, e.g. drawing a
// number as a list length, and then drawing that many elements.
//
// This isn't perfect, but allows to make progresses in many cases.
if i > 0 && *choices.get((i - 1) as usize).unwrap_or(&0) > 0 {
choices[(i - 1) as usize] -= 1;
if self.consider(&choices) {
i += 1;
}
}
i -= 1;
}
}
k /= 2;
}
// Now we try replacing region of choices with zeroes. Note that unlike the above we
// skip k = 1 because we handle that in the next step. Often (but not always) a block
// of all zeroes is the smallest value that a region can be.
let mut k: isize = 8;
while k > 1 {
let mut i: isize = self.choices.len() as isize - k;
while i >= 0 {
i -= if self.zeroes(i, k) { k } else { 1 }
}
k /= 2
}
// TODO: Remaining shrinking strategies...
//
// - Swaps
// - Sorting
// - Pair adjustments
// If we've reached a fixed point, then we cannot shrink further. We've reached a
// (local) minimum, which is as good as a counterexample we'll get with this approach.
if prev.as_slice() == self.choices.as_slice() {
break;
}
}
}
// Replace a block between indices 'i' and 'k' by zeroes.
fn zeroes(&mut self, i: isize, k: isize) -> bool {
let mut choices = self.choices.clone();
for j in i..(i + k) {
if j >= self.choices.len() as isize {
return false;
}
choices[j as usize] = 0;
}
self.consider(&choices)
}
}
// ----------------------------------------------------------------------------
//
// TestResult
//
// ----------------------------------------------------------------------------
#[derive(Debug)]
pub enum TestResult {
UnitTestResult(UnitTestResult),
PropertyTestResult(PropertyTestResult),
}
unsafe impl Send for TestResult {}
impl TestResult {
pub fn is_success(&self) -> bool {
match self {
TestResult::UnitTestResult(UnitTestResult { success, .. }) => *success,
TestResult::PropertyTestResult(PropertyTestResult {
counterexample,
test,
..
}) => {
if test.can_error {
counterexample.is_some()
} else {
counterexample.is_none()
}
}
}
}
pub fn module(&self) -> &str {
match self {
TestResult::UnitTestResult(UnitTestResult { ref test, .. }) => test.module.as_str(),
TestResult::PropertyTestResult(PropertyTestResult { ref test, .. }) => {
test.module.as_str()
}
}
}
pub fn title(&self) -> &str {
match self {
TestResult::UnitTestResult(UnitTestResult { ref test, .. }) => test.name.as_str(),
TestResult::PropertyTestResult(PropertyTestResult { ref test, .. }) => {
test.name.as_str()
}
}
}
pub fn logs(&self) -> &[String] {
match self {
TestResult::UnitTestResult(UnitTestResult { ref logs, .. }) => logs.as_slice(),
TestResult::PropertyTestResult(..) => &[],
}
}
pub fn into_error(&self, verbose: bool) -> crate::Error {
let (name, path, assertion, src) = match self {
TestResult::UnitTestResult(UnitTestResult { test, .. }) => (
test.name.to_string(),
test.input_path.to_path_buf(),
test.assertion.as_ref().map(|hint| hint.to_string()),
test.program.to_pretty(),
),
TestResult::PropertyTestResult(PropertyTestResult { test, .. }) => (
test.name.to_string(),
test.input_path.to_path_buf(),
None,
test.program.to_pretty(),
),
};
crate::Error::TestFailure {
name,
path,
assertion,
src,
verbose,
}
}
}
#[derive(Debug)]
pub struct UnitTestResult {
pub success: bool,
pub spent_budget: ExBudget,
pub output: Option<Term<NamedDeBruijn>>,
pub logs: Vec<String>,
pub test: UnitTest,
}
unsafe impl Send for UnitTestResult {}
#[derive(Debug)]
pub struct PropertyTestResult {
pub test: PropertyTest,
pub counterexample: Option<Term<NamedDeBruijn>>,
pub iterations: usize,
}
unsafe impl Send for PropertyTestResult {}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct EvalHint { pub struct Assertion {
pub bin_op: BinOp, pub bin_op: BinOp,
pub left: Program<NamedDeBruijn>, pub left: Program<NamedDeBruijn>,
pub right: Program<NamedDeBruijn>, pub right: Program<NamedDeBruijn>,
pub can_error: bool, pub can_error: bool,
} }
impl Display for EvalHint { impl Display for Assertion {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let unlimited_budget = ExBudget { let unlimited_budget = ExBudget {
mem: i64::MAX, mem: i64::MAX,
@ -240,12 +699,3 @@ impl Display for EvalHint {
f.write_str(&msg) f.write_str(&msg)
} }
} }
#[derive(Debug)]
pub struct EvalInfo<'a> {
pub success: bool,
pub spent_budget: ExBudget,
pub output: Option<Term<NamedDeBruijn>>,
pub logs: Vec<String>,
pub test: &'a Test,
}

View File

@ -1,9 +1,6 @@
use crate::pretty; use crate::pretty;
use crate::script::EvalInfo; use crate::script::{PropertyTestResult, TestResult, UnitTestResult};
use owo_colors::{ use owo_colors::{OwoColorize, Stream::Stderr};
OwoColorize,
Stream::{self, Stderr},
};
use std::{collections::BTreeMap, fmt::Display, path::PathBuf}; use std::{collections::BTreeMap, fmt::Display, path::PathBuf};
use uplc::machine::cost_model::ExBudget; use uplc::machine::cost_model::ExBudget;
@ -11,7 +8,7 @@ pub trait EventListener {
fn handle_event(&self, _event: Event) {} fn handle_event(&self, _event: Event) {}
} }
pub enum Event<'a> { pub enum Event {
StartingCompilation { StartingCompilation {
name: String, name: String,
version: String, version: String,
@ -35,12 +32,9 @@ pub enum Event<'a> {
name: String, name: String,
path: PathBuf, path: PathBuf,
}, },
EvaluatingFunction {
results: Vec<EvalInfo<'a>>,
},
RunningTests, RunningTests,
FinishedTests { FinishedTests {
tests: Vec<EvalInfo<'a>>, tests: Vec<TestResult>,
}, },
WaitingForBuildDirLock, WaitingForBuildDirLock,
ResolvingPackages { ResolvingPackages {
@ -164,20 +158,6 @@ impl EventListener for Terminal {
name.if_supports_color(Stderr, |s| s.bright_blue()), name.if_supports_color(Stderr, |s| s.bright_blue()),
); );
} }
Event::EvaluatingFunction { results } => {
eprintln!(
"{}\n",
" Evaluating function ..."
.if_supports_color(Stderr, |s| s.bold())
.if_supports_color(Stderr, |s| s.purple())
);
let (max_mem, max_cpu) = find_max_execution_units(&results);
for eval_info in &results {
println!(" {}", fmt_eval(eval_info, max_mem, max_cpu, Stderr))
}
}
Event::RunningTests => { Event::RunningTests => {
eprintln!( eprintln!(
"{} {}\n", "{} {}\n",
@ -190,19 +170,19 @@ impl EventListener for Terminal {
Event::FinishedTests { tests } => { Event::FinishedTests { tests } => {
let (max_mem, max_cpu) = find_max_execution_units(&tests); let (max_mem, max_cpu) = find_max_execution_units(&tests);
for (module, infos) in &group_by_module(&tests) { for (module, results) in &group_by_module(&tests) {
let title = module let title = module
.if_supports_color(Stderr, |s| s.bold()) .if_supports_color(Stderr, |s| s.bold())
.if_supports_color(Stderr, |s| s.blue()) .if_supports_color(Stderr, |s| s.blue())
.to_string(); .to_string();
let tests = infos let tests = results
.iter() .iter()
.map(|eval_info| fmt_test(eval_info, max_mem, max_cpu, true)) .map(|r| fmt_test(r, max_mem, max_cpu, true))
.collect::<Vec<String>>() .collect::<Vec<String>>()
.join("\n"); .join("\n");
let summary = fmt_test_summary(infos, true); let summary = fmt_test_summary(results, true);
eprintln!( eprintln!(
"{}\n", "{}\n",
@ -269,22 +249,9 @@ impl EventListener for Terminal {
} }
} }
fn fmt_test(eval_info: &EvalInfo, max_mem: usize, max_cpu: usize, styled: bool) -> String { fn fmt_test(result: &TestResult, max_mem: usize, max_cpu: usize, styled: bool) -> String {
let EvalInfo { // Status
success, let mut test = if result.is_success() {
test,
spent_budget,
logs,
..
} = eval_info;
let ExBudget { mem, cpu } = spent_budget;
let mem_pad = pretty::pad_left(mem.to_string(), max_mem, " ");
let cpu_pad = pretty::pad_left(cpu.to_string(), max_cpu, " ");
let test = format!(
"{status} [mem: {mem_unit}, cpu: {cpu_unit}] {module}",
status = if *success {
pretty::style_if(styled, "PASS".to_string(), |s| { pretty::style_if(styled, "PASS".to_string(), |s| {
s.if_supports_color(Stderr, |s| s.bold()) s.if_supports_color(Stderr, |s| s.bold())
.if_supports_color(Stderr, |s| s.green()) .if_supports_color(Stderr, |s| s.green())
@ -296,49 +263,97 @@ fn fmt_test(eval_info: &EvalInfo, max_mem: usize, max_cpu: usize, styled: bool)
.if_supports_color(Stderr, |s| s.red()) .if_supports_color(Stderr, |s| s.red())
.to_string() .to_string()
}) })
}, };
// Execution units / iteration steps
match result {
TestResult::UnitTestResult(UnitTestResult { spent_budget, .. }) => {
let ExBudget { mem, cpu } = spent_budget;
let mem_pad = pretty::pad_left(mem.to_string(), max_mem, " ");
let cpu_pad = pretty::pad_left(cpu.to_string(), max_cpu, " ");
test = format!(
"{test} [mem: {mem_unit}, cpu: {cpu_unit}]",
mem_unit = pretty::style_if(styled, mem_pad, |s| s mem_unit = pretty::style_if(styled, mem_pad, |s| s
.if_supports_color(Stderr, |s| s.cyan()) .if_supports_color(Stderr, |s| s.cyan())
.to_string()), .to_string()),
cpu_unit = pretty::style_if(styled, cpu_pad, |s| s cpu_unit = pretty::style_if(styled, cpu_pad, |s| s
.if_supports_color(Stderr, |s| s.cyan()) .if_supports_color(Stderr, |s| s.cyan())
.to_string()), .to_string()),
module = pretty::style_if(styled, test.name().to_string(), |s| s );
}
TestResult::PropertyTestResult(PropertyTestResult { iterations, .. }) => {
test = pretty::pad_right(
format!(
"{test} [after {} test{}]",
pretty::pad_left(iterations.to_string(), 3, " "),
if *iterations > 1 { "s" } else { "" }
),
14 + max_mem + max_cpu,
" ",
);
}
}
// Title
test = format!(
"{test} {title}",
title = pretty::style_if(styled, result.title().to_string(), |s| s
.if_supports_color(Stderr, |s| s.bright_blue()) .if_supports_color(Stderr, |s| s.bright_blue())
.to_string()), .to_string())
); );
let logs = if logs.is_empty() { // CounterExample
String::new() // if let TestResult::PropertyTestResult(PropertyTestResult {
} else { // counterexample: Some(counterexample),
logs.iter() // ..
// }) = result
// {
// test = format!(
// "{test}\n{}",
// pretty::boxed_with(
// &pretty::style_if(styled, "counterexample".to_string(), |s| s
// .if_supports_color(Stderr, |s| s.red())
// .if_supports_color(Stderr, |s| s.bold())
// .to_string()),
// &counterexample.to_pretty(),
// |s| s.red().to_string()
// )
// )
// }
// Traces
if !result.logs().is_empty() {
test = format!(
"{test}\n{logs}",
logs = result
.logs()
.iter()
.map(|line| { .map(|line| {
format!( format!(
"{arrow} {styled_line}", "{arrow} {styled_line}",
arrow = "".if_supports_color(Stderr, |s| s.bright_yellow()), arrow = "".if_supports_color(Stderr, |s| s.bright_yellow()),
styled_line = line styled_line = line
.split('\n') .split('\n')
.map(|l| format!("{}", l.if_supports_color(Stderr, |s| s.bright_black()))) .map(|l| format!(
"{}",
l.if_supports_color(Stderr, |s| s.bright_black())
))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n") .join("\n")
) )
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n") .join("\n")
);
}; };
if logs.is_empty() {
test test
} else {
[test, logs].join("\n")
}
} }
fn fmt_test_summary(tests: &[&EvalInfo], styled: bool) -> String { fn fmt_test_summary(tests: &[&TestResult], styled: bool) -> String {
let (n_passed, n_failed) = tests let (n_passed, n_failed) = tests.iter().fold((0, 0), |(n_passed, n_failed), result| {
.iter() if result.is_success() {
.fold((0, 0), |(n_passed, n_failed), test_info| {
if test_info.success {
(n_passed + 1, n_failed) (n_passed + 1, n_failed)
} else { } else {
(n_passed, n_failed + 1) (n_passed, n_failed + 1)
@ -360,44 +375,21 @@ fn fmt_test_summary(tests: &[&EvalInfo], styled: bool) -> String {
) )
} }
fn fmt_eval(eval_info: &EvalInfo, max_mem: usize, max_cpu: usize, stream: Stream) -> String { fn group_by_module(results: &Vec<TestResult>) -> BTreeMap<String, Vec<&TestResult>> {
let EvalInfo {
output,
test,
spent_budget,
..
} = eval_info;
let ExBudget { mem, cpu } = spent_budget;
format!(
" {}::{} [mem: {}, cpu: {}]\n\n ╰─▶ {}",
test.module().if_supports_color(stream, |s| s.blue()),
test.name().if_supports_color(stream, |s| s.bright_blue()),
pretty::pad_left(mem.to_string(), max_mem, " "),
pretty::pad_left(cpu.to_string(), max_cpu, " "),
output
.as_ref()
.map(|x| format!("{x}"))
.unwrap_or_else(|| "Error.".to_string()),
)
}
fn group_by_module<'a>(infos: &'a Vec<EvalInfo<'a>>) -> BTreeMap<String, Vec<&'a EvalInfo<'a>>> {
let mut modules = BTreeMap::new(); let mut modules = BTreeMap::new();
for eval_info in infos { for r in results {
let xs: &mut Vec<&EvalInfo> = modules let xs: &mut Vec<&TestResult> = modules.entry(r.module().to_string()).or_default();
.entry(eval_info.test.module().to_string()) xs.push(r);
.or_default();
xs.push(eval_info);
} }
modules modules
} }
fn find_max_execution_units(xs: &[EvalInfo]) -> (usize, usize) { fn find_max_execution_units(xs: &[TestResult]) -> (usize, usize) {
let (max_mem, max_cpu) = xs.iter().fold( let (max_mem, max_cpu) = xs
(0, 0), .iter()
|(max_mem, max_cpu), EvalInfo { spent_budget, .. }| { .fold((0, 0), |(max_mem, max_cpu), test| match test {
TestResult::PropertyTestResult(..) => (max_mem, max_cpu),
TestResult::UnitTestResult(UnitTestResult { spent_budget, .. }) => {
if spent_budget.mem >= max_mem && spent_budget.cpu >= max_cpu { if spent_budget.mem >= max_mem && spent_budget.cpu >= max_cpu {
(spent_budget.mem, spent_budget.cpu) (spent_budget.mem, spent_budget.cpu)
} else if spent_budget.mem > max_mem { } else if spent_budget.mem > max_mem {
@ -407,8 +399,8 @@ fn find_max_execution_units(xs: &[EvalInfo]) -> (usize, usize) {
} else { } else {
(max_mem, max_cpu) (max_mem, max_cpu)
} }
}, }
); });
(max_mem.to_string().len(), max_cpu.to_string().len()) (max_mem.to_string().len(), max_cpu.to_string().len())
} }

View File

@ -40,7 +40,7 @@ impl EvalResult {
} else { } else {
self.result.is_err() self.result.is_err()
|| matches!(self.result, Ok(Term::Error)) || matches!(self.result, Ok(Term::Error))
|| matches!(self.result, Ok(Term::Constant(ref con)) if matches!(con.as_ref(), Constant::Bool(false))) || !matches!(self.result, Ok(Term::Constant(ref con)) if matches!(con.as_ref(), Constant::Bool(true)))
} }
} }

View File

@ -1,12 +1,49 @@
use aiken/builtin
const max_int: Int = 255
type PRNG { type PRNG {
seed: Int, Seeded { seed: Int, choices: List<Int> }
size: Int, Replayed { choices: List<Int> }
} }
fn any_int(prng: PRNG) { fn any_int(prng: PRNG) -> Option<(PRNG, Int)> {
(prng, prng.seed) when prng is {
Seeded { seed, choices } -> {
let digest =
seed
|> builtin.integer_to_bytearray(True, 32, _)
|> builtin.blake2b_256()
let choice =
digest
|> builtin.index_bytearray(0)
let new_seed =
digest
|> builtin.slice_bytearray(1, 4, _)
|> builtin.bytearray_to_integer(True, _)
Some((Seeded { seed: new_seed, choices: [choice, ..choices] }, choice))
} }
test prop_test_foo(n via any_int) { Replayed { choices } ->
n > 0 when choices is {
[] -> None
[head, ..tail] ->
if head >= 0 && head <= max_int {
Some((Replayed { choices: tail }, head))
} else {
None
}
}
}
}
test prop_foo_1(n via any_int) {
n >= 0 && n <= 255
}
test prop_foo_2(n via any_int) fail {
n < 100
} }