From 26e563a9beec643b0cd9a82468fb0dcc4c7724e6 Mon Sep 17 00:00:00 2001 From: KtorZ Date: Sun, 3 Mar 2024 03:16:36 +0100 Subject: [PATCH] Hardened property-based testing framework. More tests, less bugs. Those end-to-end tests are useful. Both for controlling the behavior of the shrinker, but also to double check the reification of Plutus Data back into untyped expressions. I had to work-around a few things to get opaque type and private types play nice. Also found a weird bug due to how we apply parameters after unique debruijn indexes have been also applied. A work-around is to re-intern the program. --- crates/aiken-lang/src/ast.rs | 28 ++ crates/aiken-lang/src/builtins.rs | 10 + crates/aiken-lang/src/expr.rs | 153 ++++--- crates/aiken-lang/src/gen_uplc.rs | 15 +- crates/aiken-lang/src/gen_uplc/builder.rs | 21 +- crates/aiken-lang/src/tipo/error.rs | 23 +- crates/aiken-lang/src/tipo/infer.rs | 33 +- crates/aiken-project/src/lib.rs | 11 +- crates/aiken-project/src/test_framework.rs | 494 +++++++++++++++++---- 9 files changed, 589 insertions(+), 199 deletions(-) diff --git a/crates/aiken-lang/src/ast.rs b/crates/aiken-lang/src/ast.rs index 23cf2925..83f943a5 100644 --- a/crates/aiken-lang/src/ast.rs +++ b/crates/aiken-lang/src/ast.rs @@ -258,6 +258,34 @@ pub struct FunctionAccessKey { pub type TypedDataType = DataType>; impl TypedDataType { + pub fn bool() -> Self { + DataType { + constructors: vec![ + RecordConstructor { + location: Span::empty(), + name: "False".to_string(), + arguments: vec![], + doc: None, + sugar: false, + }, + RecordConstructor { + location: Span::empty(), + name: "True".to_string(), + arguments: vec![], + doc: None, + sugar: false, + }, + ], + doc: None, + location: Span::empty(), + name: "Bool".to_string(), + opaque: false, + parameters: vec![], + public: true, + typed_parameters: vec![], + } + } + pub fn prng() -> Self { DataType { constructors: vec![ diff --git a/crates/aiken-lang/src/builtins.rs b/crates/aiken-lang/src/builtins.rs index 4417a89b..0c34dc20 100644 --- a/crates/aiken-lang/src/builtins.rs +++ b/crates/aiken-lang/src/builtins.rs @@ -1220,6 +1220,16 @@ pub fn prelude_data_types(id_gen: &IdGenerator) -> IndexMap, + data_types: &IndexMap, data: PlutusData, tipo: &Type, ) -> Result { @@ -594,6 +595,36 @@ impl UntypedExpr { } } + // NOTE: Opaque types are tricky. We can't tell from a type only if it is + // opaque or not. We have to lookup its datatype definition. + // + // Also, we can't -- in theory -- peak into an opaque type. More so, if it + // has a single constructor with a single argument, it is an zero-cost + // wrapper. That means the underlying PlutusData has no residue of that + // wrapper. So we have to manually reconstruct it before crawling further + // down the type tree. + if check_replaceable_opaque_type(tipo, data_types) { + let DataType { name, .. } = lookup_data_type_by_tipo(data_types, tipo) + .expect("Type just disappeared from known types? {tipo:?}"); + + let inner_type = convert_opaque_type(&tipo.clone().into(), data_types, false); + + let value = UntypedExpr::reify(data_types, data, &inner_type)?; + + return Ok(UntypedExpr::Call { + location: Span::empty(), + arguments: vec![CallArg { + label: None, + location: Span::empty(), + value, + }], + fun: Box::new(UntypedExpr::Var { + name, + location: Span::empty(), + }), + }); + } + match data { PlutusData::BigInt(ref i) => Ok(UntypedExpr::UInt { location: Span::empty(), @@ -655,66 +686,50 @@ impl UntypedExpr { tag as usize - 1280 + 7 }; - if let Type::App { module, name, .. } = tipo { - let module = if module.is_empty() { "aiken" } else { module }; - if let Some(type_info) = data_types.get(module) { - if let Some(constructors) = type_info.types_constructors.get(name) { - let constructor = &constructors[ix]; - return if fields.is_empty() { - Ok(UntypedExpr::Var { - location: Span::empty(), - name: constructor.to_string(), - }) - } else { - // NOTE: When the type is _private_, we cannot see the - // value constructor and so we default to showing a - // placeholder. - let arguments = match type_info.values.get(constructor) { - None => Ok(fields - .iter() - .map(|_| CallArg { - label: None, - location: Span::empty(), - value: UntypedExpr::Var { - name: "".to_string(), + if let Type::App { .. } = tipo { + if let Some(DataType { constructors, .. }) = + lookup_data_type_by_tipo(data_types, tipo) + { + let constructor = &constructors[ix]; + + return if fields.is_empty() { + Ok(UntypedExpr::Var { + location: Span::empty(), + name: constructor.name.to_string(), + }) + } else { + let arguments = fields + .into_iter() + .zip(constructor.arguments.iter()) + .map( + |( + field, + RecordConstructorArg { + ref label, + ref tipo, + .. + }, + )| { + UntypedExpr::reify(data_types, field, tipo).map(|value| { + CallArg { + label: label.clone(), location: Span::empty(), - }, + value, + } }) - .collect()), - Some(value) => { - let types = - if let Type::Fn { args, .. } = value.tipo.as_ref() { - &args[..] - } else { - &[] - }; + }, + ) + .collect::, _>>()?; - fields - .into_iter() - .zip(types) - .map(|(field, tipo)| { - UntypedExpr::reify(data_types, field, tipo).map( - |value| CallArg { - label: None, - location: Span::empty(), - value, - }, - ) - }) - .collect::, _>>() - } - }?; - - Ok(UntypedExpr::Call { + Ok(UntypedExpr::Call { + location: Span::empty(), + arguments, + fun: Box::new(UntypedExpr::Var { + name: constructor.name.to_string(), location: Span::empty(), - arguments, - fun: Box::new(UntypedExpr::Var { - name: constructor.to_string(), - location: Span::empty(), - }), - }) - }; - } + }), + }) + }; } } diff --git a/crates/aiken-lang/src/gen_uplc.rs b/crates/aiken-lang/src/gen_uplc.rs index 248cdbf4..e37e9821 100644 --- a/crates/aiken-lang/src/gen_uplc.rs +++ b/crates/aiken-lang/src/gen_uplc.rs @@ -53,7 +53,7 @@ use uplc::{ #[derive(Clone)] pub struct CodeGenerator<'a> { /// immutable index maps - pub functions: IndexMap, + functions: IndexMap, data_types: IndexMap, module_types: IndexMap<&'a String, &'a TypeInfo>, module_src: IndexMap, @@ -255,7 +255,7 @@ impl<'a> CodeGenerator<'a> { panic!("Dangling expressions without an assignment") }; - let replaced_type = convert_opaque_type(tipo, &self.data_types); + let replaced_type = convert_opaque_type(tipo, &self.data_types, true); let air_value = self.build(value, module_build_name, &[]); @@ -895,7 +895,7 @@ impl<'a> CodeGenerator<'a> { if props.full_check { let mut index_map = IndexMap::new(); - let non_opaque_tipo = convert_opaque_type(tipo, &self.data_types); + let non_opaque_tipo = convert_opaque_type(tipo, &self.data_types, true); let val = AirTree::local_var(name, tipo.clone()); @@ -933,7 +933,7 @@ impl<'a> CodeGenerator<'a> { let name = &format!("__discard_expect_{}", name); let mut index_map = IndexMap::new(); - let non_opaque_tipo = convert_opaque_type(tipo, &self.data_types); + let non_opaque_tipo = convert_opaque_type(tipo, &self.data_types, true); let val = AirTree::local_var(name, tipo.clone()); @@ -1326,7 +1326,7 @@ impl<'a> CodeGenerator<'a> { msg_func: Option, ) -> AirTree { assert!(tipo.get_generic().is_none()); - let tipo = &convert_opaque_type(tipo, &self.data_types); + let tipo = &convert_opaque_type(tipo, &self.data_types, true); if tipo.is_primitive() { // Since we would return void anyway and ignore then we can just return value here and ignore @@ -2775,7 +2775,7 @@ impl<'a> CodeGenerator<'a> { let param = AirTree::local_var(&arg_name, data()); - let actual_type = convert_opaque_type(&arg.tipo, &self.data_types); + let actual_type = convert_opaque_type(&arg.tipo, &self.data_types, true); let msg_func = match self.tracing { TraceLevel::Silent => None, @@ -3632,12 +3632,13 @@ impl<'a> CodeGenerator<'a> { let mut function_def_types = function_def .arguments .iter() - .map(|arg| convert_opaque_type(&arg.tipo, &self.data_types)) + .map(|arg| convert_opaque_type(&arg.tipo, &self.data_types, true)) .collect_vec(); function_def_types.push(convert_opaque_type( &function_def.return_type, &self.data_types, + true, )); let mono_types: IndexMap> = if !function_def_types.is_empty() { diff --git a/crates/aiken-lang/src/gen_uplc/builder.rs b/crates/aiken-lang/src/gen_uplc/builder.rs index d08cc208..29435e66 100644 --- a/crates/aiken-lang/src/gen_uplc/builder.rs +++ b/crates/aiken-lang/src/gen_uplc/builder.rs @@ -347,6 +347,7 @@ pub fn get_arg_type_name(tipo: &Type) -> String { pub fn convert_opaque_type( t: &Rc, data_types: &IndexMap, + deep: bool, ) -> Rc { if check_replaceable_opaque_type(t, data_types) && matches!(t.as_ref(), Type::App { .. }) { let data_type = lookup_data_type_by_tipo(data_types, t).unwrap(); @@ -363,7 +364,11 @@ pub fn convert_opaque_type( let mono_type = find_and_replace_generics(generic_type, &mono_types); - convert_opaque_type(&mono_type, data_types) + if deep { + convert_opaque_type(&mono_type, data_types, deep) + } else { + mono_type + } } else { match t.as_ref() { Type::App { @@ -374,7 +379,7 @@ pub fn convert_opaque_type( } => { let mut new_args = vec![]; for arg in args { - let arg = convert_opaque_type(arg, data_types); + let arg = convert_opaque_type(arg, data_types, deep); new_args.push(arg); } Type::App { @@ -388,11 +393,11 @@ pub fn convert_opaque_type( Type::Fn { args, ret } => { let mut new_args = vec![]; for arg in args { - let arg = convert_opaque_type(arg, data_types); + let arg = convert_opaque_type(arg, data_types, deep); new_args.push(arg); } - let ret = convert_opaque_type(ret, data_types); + let ret = convert_opaque_type(ret, data_types, deep); Type::Fn { args: new_args, @@ -402,7 +407,7 @@ pub fn convert_opaque_type( } Type::Var { tipo: var_tipo } => { if let TypeVar::Link { tipo } = &var_tipo.borrow().clone() { - convert_opaque_type(tipo, data_types) + convert_opaque_type(tipo, data_types, deep) } else { t.clone() } @@ -410,7 +415,7 @@ pub fn convert_opaque_type( Type::Tuple { elems } => { let mut new_elems = vec![]; for arg in elems { - let arg = convert_opaque_type(arg, data_types); + let arg = convert_opaque_type(arg, data_types, deep); new_elems.push(arg); } Type::Tuple { elems: new_elems }.into() @@ -420,7 +425,7 @@ pub fn convert_opaque_type( } pub fn check_replaceable_opaque_type( - t: &Rc, + t: &Type, data_types: &IndexMap, ) -> bool { let data_type = lookup_data_type_by_tipo(data_types, t); @@ -633,7 +638,7 @@ pub fn erase_opaque_type_operations( let mut held_types = air_tree.mut_held_types(); while let Some(tipo) = held_types.pop() { - *tipo = convert_opaque_type(tipo, data_types); + *tipo = convert_opaque_type(tipo, data_types, true); } } diff --git a/crates/aiken-lang/src/tipo/error.rs b/crates/aiken-lang/src/tipo/error.rs index 1dacbf9e..c8c49c21 100644 --- a/crates/aiken-lang/src/tipo/error.rs +++ b/crates/aiken-lang/src/tipo/error.rs @@ -1,7 +1,7 @@ use super::Type; -use crate::error::ExtraData; use crate::{ ast::{Annotation, BinOp, CallArg, LogicalOpChainKind, Span, UntypedPattern}, + error::ExtraData, expr::{self, UntypedExpr}, format::Formatter, levenshtein, @@ -261,7 +261,9 @@ You can use '{discard}' and numbers to distinguish between similar names. #[error("I found a type definition that has a function type in it. This is not allowed.\n")] #[diagnostic(code("illegal::function_in_type"))] - #[diagnostic(help("Data-types can't hold functions. If you want to define method-like functions, group the type definition and the methods under a common namespace in a standalone module."))] + #[diagnostic(help( + "Data-types can't hold functions. If you want to define method-like functions, group the type definition and the methods under a common namespace in a standalone module." + ))] FunctionTypeInData { #[label] location: Span, @@ -477,9 +479,13 @@ If you really meant to return that last expression, try to replace it with the f "I stumbled upon an invalid (non-local) clause guard '{}'.\n", name.if_supports_color(Stdout, |s| s.purple()) )] - #[diagnostic(url("https://aiken-lang.org/language-tour/control-flow#checking-equality-and-ordering-in-patterns"))] + #[diagnostic(url( + "https://aiken-lang.org/language-tour/control-flow#checking-equality-and-ordering-in-patterns" + ))] #[diagnostic(code("illegal::clause_guard"))] - #[diagnostic(help("There are some conditions regarding what can be used in a guard. Values must be either local to the function, or defined as module constants. You can't use functions or records in there."))] + #[diagnostic(help( + "There are some conditions regarding what can be used in a guard. Values must be either local to the function, or defined as module constants. You can't use functions or records in there." + ))] NonLocalClauseGuardVariable { #[label] location: Span, @@ -492,7 +498,7 @@ If you really meant to return that last expression, try to replace it with the f #[diagnostic(url("https://aiken-lang.org/language-tour/primitive-types#tuples"))] #[diagnostic(code("illegal::tuple_index"))] #[diagnostic(help( - r#"Because you used a tuple-index on an element, I assumed it had to be a tuple or some kind, but instead I found: + r#"Because you used a tuple-index on an element, I assumed it had to be a tuple but instead I found something of type: ╰─▶ {type_info}"#, type_info = tipo.to_pretty(0).if_supports_color(Stdout, |s| s.red()) @@ -637,7 +643,9 @@ You can help me by providing a type-annotation for 'x', as such: #[error("I almost got caught in an endless loop while inferring a recursive type.\n")] #[diagnostic(url("https://aiken-lang.org/language-tour/custom-types#type-annotations"))] #[diagnostic(code("missing::type_annotation"))] - #[diagnostic(help("I have several aptitudes, but inferring recursive types isn't one them. It is still possible to define recursive types just fine, but I will need a little help in the form of type annotation to infer their types should they show up."))] + #[diagnostic(help( + "I have several aptitudes, but inferring recursive types isn't one them. It is still possible to define recursive types just fine, but I will need a little help in the form of type annotation to infer their types should they show up." + ))] RecursiveType { #[label] location: Span, @@ -961,7 +969,8 @@ The best thing to do from here is to remove it."#))] #[error("I choked on a generic type left in an outward-facing interface.\n")] #[diagnostic(code("illegal::generic_in_abi"))] #[diagnostic(help( - "Functions of the outer-most parts of a project, such as a validator or a property-based test, must be fully instantiated. That means they can no longer carry unbound generic variables. The type must be fully-known at this point since many structural validation must occur to ensure a safe boundary between the on-chain and off-chain worlds."))] + "Functions of the outer-most parts of a project, such as a validator or a property-based test, must be fully instantiated. That means they can no longer carry unbound generic variables. The type must be fully-known at this point since many structural validation must occur to ensure a safe boundary between the on-chain and off-chain worlds." + ))] GenericLeftAtBoundary { #[label("unbound generic at boundary")] location: Span, diff --git a/crates/aiken-lang/src/tipo/infer.rs b/crates/aiken-lang/src/tipo/infer.rs index 0386c668..c81399f0 100644 --- a/crates/aiken-lang/src/tipo/infer.rs +++ b/crates/aiken-lang/src/tipo/infer.rs @@ -1,5 +1,10 @@ -use std::{borrow::Borrow, collections::HashMap, ops::Deref, rc::Rc}; - +use super::{ + environment::{generalise, EntityKind, Environment}, + error::{Error, UnifyErrorSituation, Warning}, + expr::ExprTyper, + hydrator::Hydrator, + TypeInfo, ValueConstructor, ValueConstructorVariant, +}; use crate::{ ast::{ Annotation, Arg, ArgName, ArgVia, DataType, Definition, Function, Layer, ModuleConstant, @@ -14,14 +19,7 @@ use crate::{ tipo::{Span, Type, TypeVar}, IdGenerator, }; - -use super::{ - environment::{generalise, EntityKind, Environment}, - error::{Error, UnifyErrorSituation, Warning}, - expr::ExprTyper, - hydrator::Hydrator, - TypeInfo, ValueConstructor, ValueConstructorVariant, -}; +use std::{borrow::Borrow, collections::HashMap, ops::Deref, rc::Rc}; impl UntypedModule { pub fn infer( @@ -347,6 +345,21 @@ fn infer_definition( let (inferred_annotation, inner_type) = infer_fuzzer(environment, &typed_via.tipo(), &arg.via.location())?; + // Replace the pre-registered type for the test function, to allow inferring + // the function body with the right type arguments. + let scope = environment + .scope + .get_mut(&f.name) + .expect("Could not find preregistered type for test"); + if let Type::Fn { ref ret, .. } = scope.tipo.as_ref() { + scope.tipo = Rc::new(Type::Fn { + ret: ret.clone(), + args: vec![inner_type.clone()], + }) + } + + // Ensure that the annotation, if any, matches the type inferred from the + // Fuzzer. if let Some(ref provided_annotation) = arg.annotation { let hydrator: &mut Hydrator = hydrators.get_mut(&f.name).unwrap(); diff --git a/crates/aiken-project/src/lib.rs b/crates/aiken-project/src/lib.rs index 7df40e9f..e09b81cb 100644 --- a/crates/aiken-project/src/lib.rs +++ b/crates/aiken-project/src/lib.rs @@ -30,7 +30,7 @@ use crate::{ }; use aiken_lang::{ ast::{ - DataTypeKey, Definition, FunctionAccessKey, ModuleKind, Tracing, TypedDataType, + DataTypeKey, Definition, FunctionAccessKey, ModuleKind, TraceLevel, Tracing, TypedDataType, TypedFunction, Validator, }, builtins, @@ -810,6 +810,13 @@ where fn run_tests(&self, tests: Vec) -> Vec> { use rayon::prelude::*; + let generator = self.checked_modules.new_generator( + &self.functions, + &self.data_types, + &self.module_types, + Tracing::All(TraceLevel::Silent), + ); + tests .into_par_iter() .map(|test| match test { @@ -820,7 +827,7 @@ where }) .collect::>>() .into_iter() - .map(|test| test.reify(&self.module_types)) + .map(|test| test.reify(generator.data_types())) .collect() } diff --git a/crates/aiken-project/src/test_framework.rs b/crates/aiken-project/src/test_framework.rs index 9f28d428..2967e2a8 100644 --- a/crates/aiken-project/src/test_framework.rs +++ b/crates/aiken-project/src/test_framework.rs @@ -1,32 +1,31 @@ use crate::pretty; use aiken_lang::{ - ast::{BinOp, Span, TypedTest}, + ast::{BinOp, DataTypeKey, Span, TypedDataType, TypedTest}, expr::{TypedExpr, UntypedExpr}, gen_uplc::{ builder::{convert_data_to_type, convert_opaque_type}, CodeGenerator, }, - tipo::{Type, TypeInfo}, + tipo::Type, }; +use indexmap::IndexMap; use pallas::{ codec::utils::Int, ledger::primitives::alonzo::{BigInt, Constr, PlutusData}, }; use std::{ borrow::Borrow, - collections::HashMap, fmt::{self, Display}, path::PathBuf, rc::Rc, }; use uplc::{ - ast::{Constant, Data, NamedDeBruijn, Program, Term}, + ast::{Constant, Data, Name, NamedDeBruijn, Program, Term}, machine::{cost_model::ExBudget, eval_result::EvalResult}, + parser::interner::Interner, }; -/// ---------------------------------------------------------------------------- -/// -/// Test +/// ----- 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. @@ -42,7 +41,7 @@ use uplc::{ /// 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)] pub enum Test { UnitTest(UnitTest), @@ -75,8 +74,8 @@ impl Test { module: String, name: String, can_error: bool, - program: Program, - fuzzer: Fuzzer, + program: Program, + fuzzer: Fuzzer, ) -> Test { Test::PropertyTest(PropertyTest { input_path, @@ -131,7 +130,7 @@ impl Test { let via = parameter.via.clone(); - let type_info = convert_opaque_type(¶meter.tipo, generator.data_types()); + let type_info = parameter.tipo.clone(); // TODO: Possibly refactor 'generate_raw' to accept arguments and do this wrapping // itself. @@ -147,17 +146,9 @@ impl Test { return_annotation: None, }; - let program = generator - .clone() - .generate_raw(&body, &module_name) - .try_into() - .unwrap(); + let program = generator.clone().generate_raw(&body, &module_name); - let fuzzer: Program = generator - .clone() - .generate_raw(&via, &module_name) - .try_into() - .unwrap(); + let fuzzer = generator.clone().generate_raw(&via, &module_name); Self::property_test( input_path, @@ -167,6 +158,11 @@ impl Test { program, Fuzzer { program: fuzzer, + stripped_type_info: convert_opaque_type( + &type_info, + generator.data_types(), + true, + ), type_info, }, ) @@ -174,12 +170,8 @@ impl Test { } } -// ---------------------------------------------------------------------------- -// -// UnitTest -// -// ---------------------------------------------------------------------------- - +/// ----- UnitTest ----------------------------------------------------------------- +/// #[derive(Debug, Clone)] pub struct UnitTest { pub input_path: PathBuf, @@ -205,20 +197,16 @@ impl UnitTest { } } -// ---------------------------------------------------------------------------- -// -// PropertyTest -// -// ---------------------------------------------------------------------------- - +/// ----- PropertyTest ----------------------------------------------------------------- +/// #[derive(Debug, Clone)] pub struct PropertyTest { pub input_path: PathBuf, pub module: String, pub name: String, pub can_error: bool, - pub program: Program, - pub fuzzer: Fuzzer, + pub program: Program, + pub fuzzer: Fuzzer, } unsafe impl Send for PropertyTest {} @@ -226,7 +214,13 @@ unsafe impl Send for PropertyTest {} #[derive(Debug, Clone)] pub struct Fuzzer { pub program: Program, + pub type_info: Rc, + + /// A version of the Fuzzer's type that has gotten rid of + /// all erasable opaque type. This is needed in order to + /// generate Plutus data with the appropriate shape. + pub stripped_type_info: Rc, } impl PropertyTest { @@ -239,7 +233,7 @@ impl PropertyTest { let (counterexample, iterations) = match self.run_n_times(n, seed, None) { None => (None, n), - Some((remaining, counterexample)) => (Some(counterexample), n - remaining + 1), + Some((remaining, counterexample)) => (Some(counterexample.value), n - remaining + 1), }; TestResult::PropertyTestResult(PropertyTestResult { @@ -249,12 +243,12 @@ impl PropertyTest { }) } - fn run_n_times( - &self, + fn run_n_times<'a>( + &'a self, remaining: usize, seed: u32, - counterexample: Option<(usize, PlutusData)>, - ) -> Option<(usize, PlutusData)> { + counterexample: Option<(usize, Counterexample<'a>)>, + ) -> Option<(usize, Counterexample<'a>)> { // We short-circuit failures in case we have any. The counterexample is already simplified // at this point. if remaining > 0 && counterexample.is_none() { @@ -269,7 +263,7 @@ impl PropertyTest { } } - fn run_once(&self, seed: u32) -> (u32, Option) { + fn run_once(&self, seed: u32) -> (u32, Option>) { let (next_prng, value) = Prng::from_seed(seed) .sample(&self.fuzzer.program) .expect("running seeded Prng cannot fail."); @@ -291,7 +285,7 @@ impl PropertyTest { counterexample.simplify(); } - (next_seed, Some(counterexample.value)) + (next_seed, Some(counterexample)) } else { (next_seed, None) } @@ -301,19 +295,37 @@ impl PropertyTest { } pub fn eval(&self, value: &PlutusData) -> EvalResult { - let term = convert_data_to_type(Term::data(value.clone()), &self.fuzzer.type_info) - .try_into() - .expect("safe conversion from Name -> NamedDeBruijn"); - self.program.apply_term(&term).eval(ExBudget::max()) + let term: Term = + convert_data_to_type(Term::data(value.clone()), &self.fuzzer.stripped_type_info); + + let mut program = self.program.apply_term(&term); + + Interner::new().program(&mut program); + + Program::::try_from(program) + .unwrap() + .eval(ExBudget::max()) } } -// ---------------------------------------------------------------------------- -// -// Prng -// -// ---------------------------------------------------------------------------- - +/// ----- PRNG ----------------------------------------------------------------- +/// +/// A Pseudo-random generator (PRNG) used to produce random values for fuzzers. +/// Note that the randomness isn't actually managed by the Rust framework, it +/// entirely relies on properties of hashing algorithm on-chain (e.g. blake2b). +/// +/// The PRNG can have two forms: +/// +/// 1. Seeded: which occurs during the initial run of a property. Each time a +/// number is drawn from the PRNG, a new seed is created. We retain all the +/// choices drawn in a _choices_ vector. +/// +/// 2. Replayed: which is used to replay a Prng sequenced from a list of known +/// choices. This happens when shrinking an example. Instead of trying to +/// shrink the value directly, we shrink the PRNG sequence with the hope that +/// it will generate a smaller value. This implies that generators tend to +/// generate smaller values when drawing smaller numbers. +/// #[derive(Debug)] pub enum Prng { Seeded { @@ -385,9 +397,9 @@ impl Prng { } /// Generate a pseudo-random value from a fuzzer using the given PRNG. - pub fn sample(&self, fuzzer: &Program) -> Option<(Prng, PlutusData)> { - let result = fuzzer - .apply_data(self.uplc()) + pub fn sample(&self, fuzzer: &Program) -> Option<(Prng, PlutusData)> { + let result = Program::::try_from(fuzzer.apply_data(self.uplc())) + .unwrap() .eval(ExBudget::max()) .result() .expect("Fuzzer crashed?"); @@ -464,14 +476,12 @@ impl Prng { } } -// ---------------------------------------------------------------------------- -// -// Counterexample -// -// A counterexample is constructed on test failures. -// -// ---------------------------------------------------------------------------- - +/// ----- Counterexample ----------------------------------------------------------------- +/// +/// A counterexample is constructed from a test failure. It holds a value, and a sequence +/// of random choices that led to this value. It holds a reference to the underlying +/// property and fuzzer. In many cases, a counterexample can be simplified (a.k.a "shrinked") +/// into a smaller counterexample. #[derive(Debug)] pub struct Counterexample<'a> { pub value: PlutusData, @@ -690,7 +700,10 @@ pub enum TestResult { unsafe impl Send for TestResult {} impl TestResult { - pub fn reify(self, data_types: &HashMap) -> TestResult { + pub fn reify( + self, + data_types: &IndexMap, + ) -> TestResult { match self { TestResult::UnitTestResult(test) => TestResult::UnitTestResult(test), TestResult::PropertyTestResult(test) => { @@ -789,7 +802,10 @@ pub struct PropertyTestResult { unsafe impl Send for PropertyTestResult {} impl PropertyTestResult { - pub fn reify(self, data_types: &HashMap) -> PropertyTestResult { + pub fn reify( + self, + data_types: &IndexMap, + ) -> PropertyTestResult { PropertyTestResult { counterexample: match self.counterexample { None => None, @@ -882,16 +898,19 @@ mod test { use crate::module::{CheckedModule, CheckedModules}; use aiken_lang::{ ast::{Definition, ModuleKind, TraceLevel, Tracing}, - builtins, parser, + builtins, + format::Formatter, + parser, parser::extra::ModuleExtra, IdGenerator, }; use indoc::indoc; + use std::collections::HashMap; const TEST_KIND: ModuleKind = ModuleKind::Lib; impl Test { - pub fn from_source(src: &str) -> Self { + pub fn from_source(src: &str) -> (Self, IndexMap) { let id_gen = IdGenerator::new(); let module_name = ""; @@ -924,6 +943,22 @@ mod test { .last() .expect("No test found in declared src?"); + let functions = builtins::prelude_functions(&id_gen); + + let mut data_types = builtins::prelude_data_types(&id_gen); + + for def in ast.definitions() { + if let Definition::DataType(dt) = def { + data_types.insert( + DataTypeKey { + module_name: module_name.to_string(), + defined_type: dt.name.clone(), + }, + dt.clone(), + ); + } + } + let mut modules = CheckedModules::default(); modules.insert( module_name.to_string(), @@ -938,10 +973,6 @@ mod test { }, ); - let functions = builtins::prelude_functions(&id_gen); - - let data_types = builtins::prelude_data_types(&id_gen); - let mut generator = modules.new_generator( &functions, &data_types, @@ -949,16 +980,19 @@ mod test { Tracing::All(TraceLevel::Verbose), ); - Self::from_function_definition( - &mut generator, - test.to_owned(), - module_name.to_string(), - PathBuf::new(), + ( + Self::from_function_definition( + &mut generator, + test.to_owned(), + module_name.to_string(), + PathBuf::new(), + ), + data_types, ) } } - fn property(src: &str) -> PropertyTest { + fn property(src: &str) -> (PropertyTest, impl Fn(PlutusData) -> String) { let prelude = indoc! { r#" use aiken/builtin @@ -999,6 +1033,22 @@ mod test { } } + fn bool() -> Fuzzer { + int() |> map(fn(n) { n % 2 == 0 }) + } + + fn bytearray() -> Fuzzer { + int() + |> map( + fn(n) { + n + |> builtin.integer_to_bytearray(True, 32, _) + |> builtin.blake2b_256() + |> builtin.slice_bytearray(8, 4, _) + }, + ) + } + pub fn constant(a: a) -> Fuzzer { fn(s0) { Some((s0, a)) } } @@ -1020,39 +1070,58 @@ mod test { } } } + + pub fn map2(fuzz_a: Fuzzer, fuzz_b: Fuzzer, f: fn(a, b) -> c) -> Fuzzer { + fn(s0) { + when fuzz_a(s0) is { + Some((s1, a)) -> + when fuzz_b(s1) is { + Some((s2, b)) -> Some((s2, f(a, b))) + None -> None + } + None -> None + } + } + } "#}; let src = format!("{prelude}\n{src}"); match Test::from_source(&src) { - Test::PropertyTest(test) => test, - Test::UnitTest(..) => panic!("Expected to yield a PropertyTest but found a UnitTest"), + (Test::PropertyTest(test), data_types) => { + let type_info = test.fuzzer.type_info.clone(); + + let reify = move |counterexample| { + let mut data_type_refs = IndexMap::new(); + for (k, v) in &data_types { + data_type_refs.insert(k.clone(), v); + } + + let expr = UntypedExpr::reify(&data_type_refs, counterexample, &type_info) + .expect("Failed to reify value."); + Formatter::new().expr(&expr, false).to_pretty_string(70) + }; + + (test, reify) + } + (Test::UnitTest(..), _) => { + panic!("Expected to yield a PropertyTest but found a UnitTest") + } } } impl PropertyTest { fn expect_failure(&self, seed: u32) -> Counterexample { - let (next_prng, value) = Prng::from_seed(seed) - .sample(&self.fuzzer.program) - .expect("running seeded Prng cannot fail."); - - let result = self.eval(&value); - - if result.failed(self.can_error) { - return Counterexample { - value, - choices: next_prng.choices(), - property: self, - }; + match self.run_n_times(PropertyTest::MAX_TEST_RUN, seed, None) { + Some((_, counterexample)) => counterexample, + _ => panic!("expected property to fail but it didn't."), } - - unreachable!("Prng constructed from a seed necessarily yield a seed."); } } #[test] fn test_prop_basic() { - let prop = property(indoc! { r#" + let (prop, _) = property(indoc! { r#" test foo(n: Int via int()) { n >= 0 } @@ -1063,16 +1132,249 @@ mod test { #[test] fn test_prop_always_odd() { - let prop = property(indoc! { r#" + let (prop, reify) = property(indoc! { r#" test foo(n: Int via int()) { n % 2 == 0 } "#}); - let mut counterexample = prop.expect_failure(12); + let mut counterexample = prop.expect_failure(42); counterexample.simplify(); assert_eq!(counterexample.choices, vec![1]); + assert_eq!(reify(counterexample.value), "1"); + } + + #[test] + fn test_prop_combine() { + let (prop, reify) = property(indoc! { r#" + fn pair(fuzz_a: Fuzzer, fuzz_b: Fuzzer) -> Fuzzer<(a, b)> { + fuzz_a + |> and_then(fn(a) { + fuzz_b + |> map(fn(b) { + (a, b) + }) + }) + } + + + test foo(t: (Int, Int) via pair(int(), int())) { + t.1st + t.2nd <= 400 + } + "#}); + + let mut counterexample = prop.expect_failure(42); + + counterexample.simplify(); + + assert_eq!(counterexample.choices, vec![201, 200]); + assert_eq!(reify(counterexample.value), "(201, 200)"); + } + + #[test] + fn test_prop_enum_bool() { + let (prop, reify) = property(indoc! { r#" + test foo(predicate via bool()) { + predicate + } + "#}); + + let mut counterexample = prop.expect_failure(42); + + counterexample.simplify(); + + assert_eq!(counterexample.choices, vec![1]); + assert_eq!(reify(counterexample.value), "False"); + } + + #[test] + fn test_prop_enum_custom() { + let (prop, reify) = property(indoc! { r#" + type Temperature { + Hot + Cold + } + + fn temperature() -> Fuzzer { + bool() |> map(fn(is_cold) { + if is_cold { Cold } else { Hot } + }) + } + + test foo(t via temperature()) { + t == Hot + } + "#}); + + let mut counterexample = prop.expect_failure(42); + + counterexample.simplify(); + + assert_eq!(counterexample.choices, vec![0]); + assert_eq!(reify(counterexample.value), "Cold"); + } + + #[test] + fn test_prop_opaque() { + let (prop, reify) = property(indoc! { r#" + opaque type Temperature { + Hot + Cold + } + + fn temperature() -> Fuzzer { + bool() |> map(fn(is_cold) { + if is_cold { Cold } else { Hot } + }) + } + + test foo(t via temperature()) { + t == Hot + } + "#}); + + let mut counterexample = prop.expect_failure(42); + + counterexample.simplify(); + + assert_eq!(counterexample.choices, vec![0]); + assert_eq!(reify(counterexample.value), "Cold"); + } + + #[test] + fn test_prop_private_enum() { + let (prop, reify) = property(indoc! { r#" + type Vehicle { + Car { wheels: Int } + Bike { wheels: Int } + } + + fn vehicle() -> Fuzzer { + bool() |> map(fn(is_car) { + if is_car { Car(4) } else { Bike(2) } + }) + } + + test foo(v via vehicle()) { + when v is { + Car { wheels } -> wheels + Bike { wheels } -> wheels + } == 4 + } + "#}); + + let mut counterexample = prop.expect_failure(42); + + counterexample.simplify(); + + assert_eq!(counterexample.choices, vec![1]); + assert_eq!(reify(counterexample.value), "Bike { wheels: 2 }"); + } + + #[test] + fn test_prop_list() { + let (prop, reify) = property(indoc! { r#" + fn list(elem: Fuzzer) -> Fuzzer> { + bool() + |> and_then(fn(continue) { + if continue { + map2(elem, list(elem), fn(head, tail) { [head, ..tail] }) + } else { + constant([]) + } + }) + } + + fn length(es: List) -> Int { + when es is { + [] -> 0 + [_, ..tail] -> 1 + length(tail) + } + } + + test foo(es: List via list(int())) { + length(es) < 3 + } + "#}); + + let mut counterexample = prop.expect_failure(42); + + counterexample.simplify(); + + assert_eq!(counterexample.choices, vec![0, 0, 0, 0, 0, 0, 1]); + assert_eq!(reify(counterexample.value), "[0, 0, 0]"); + } + + #[test] + fn test_prop_opaque_dict() { + let (prop, reify) = property(indoc! { r#" + pub opaque type Dict { + inner: List<(ByteArray, a)>, + } + + fn dict(elem: Fuzzer) -> Fuzzer> { + bool() + |> and_then( + fn(continue) { + if continue { + let kv = map2(bytearray(), elem, fn(k, v) { (k, v) }) + map2(kv, dict(elem), fn(head, tail) { Dict([head, ..tail.inner]) }) + } else { + constant(Dict([])) + } + }, + ) + } + + test foo(d via dict(bool())) { + d == Dict([]) + } + "#}); + + let mut counterexample = prop.expect_failure(42); + + counterexample.simplify(); + + assert_eq!(counterexample.choices, vec![0, 0, 0, 1]); + assert_eq!(reify(counterexample.value), "Dict([(#\"2cd15ed0\", True)])"); + } + + #[test] + fn test_prop_opaque_nested_dict() { + let (prop, reify) = property(indoc! { r#" + pub opaque type Dict { + inner: List<(ByteArray, a)>, + } + + fn dict(elem: Fuzzer) -> Fuzzer> { + bool() + |> and_then( + fn(continue) { + if continue { + let kv = map2(bytearray(), elem, fn(k, v) { (k, v) }) + map2(kv, dict(elem), fn(head, tail) { Dict([head, ..tail.inner]) }) + } else { + constant(Dict([])) + } + }, + ) + } + + test foo(d via dict(dict(int()))) { + d == Dict([]) + } + "#}); + + let mut counterexample = prop.expect_failure(42); + + counterexample.simplify(); + + assert_eq!(counterexample.choices, vec![0, 0, 1, 1]); + assert_eq!( + reify(counterexample.value), + "Dict([(#\"2cd15ed0\", Dict([]))])" + ); } }