def schema_config(self):
     return Schema({
         Required('decrease_credit_after', default=1):
         Positive(int),
         Required('decrease_credit_steps', default=4):
         Positive(int),
         Required('minimum_credit', default=0.2):
         Any(All(float, Range(0, 1)), 0, 1)
     })
 def schema_config(self):
     """Define the configuration options for IntegralGrader"""
     # Construct the default AbstractGrader schema
     schema = super(IntegralGrader, self).schema_config
     default_input_positions = {
         'lower': 1,
         'upper': 2,
         'integrand': 3,
         'integration_variable': 4
     }
     # Append options
     return schema.extend({
         Required('answers'): {
             Required('lower'): str,
             Required('upper'): str,
             Required('integrand'): str,
             Required('integration_variable'): str
         },
         Required('input_positions', default=default_input_positions): {
             Required('lower', default=None):
             Any(None, Positive(int)),
             Required('upper', default=None):
             Any(None, Positive(int)),
             Required('integrand', default=None):
             Any(None, Positive(int)),
             Required('integration_variable', default=None):
             Any(None, Positive(int)),
         },
         Required('integrator_options', default={'full_output': 1}): {
             Required('full_output', default=1): 1,
             Extra: object
         },
         Required('complex_integrand', default=False):
         bool,
         # Most of the below are copied from FormulaGrader
         Required('user_functions', default={}): {
             Extra: Any(is_callable, [is_callable], FunctionSamplingSet)
         },
         Required('user_constants', default={}): {
             Extra: Number
         },
         Required('blacklist', default=[]): [str],
         Required('whitelist', default=[]): [Any(str, None)],
         Required('tolerance', default='0.01%'):
         Any(PercentageString, NonNegative(Number)),
         Required('case_sensitive', default=True):
         bool,
         Required('samples', default=1):
         Positive(int),  # default changed to 1
         Required('variables', default=[]): [str],
         Required('sample_from', default={}):
         dict,
         Required('failable_evals', default=0):
         NonNegative(int)
     })
Example #3
0
 def schema_config(self):
     """Define the configuration options for IntegralGrader"""
     # Construct the default AbstractGrader schema
     schema = super(IntegralGrader, self).schema_config
     default_input_positions = {
         'lower': 1,
         'upper': 2,
         'integrand': 3,
         'integration_variable': 4
     }
     # Append options
     return schema.extend({
         Required('answers'): {
             Required('lower'): str,
             Required('upper'): str,
             Required('integrand'): str,
             Required('integration_variable'): str
         },
         Required('input_positions', default=default_input_positions): {
             Required('lower', default=None):
             Any(None, Positive(int)),
             Required('upper', default=None):
             Any(None, Positive(int)),
             Required('integrand', default=None):
             Any(None, Positive(int)),
             Required('integration_variable', default=None):
             Any(None, Positive(int)),
         },
         Required('integrator_options', default={'full_output': 1}): {
             Required('full_output', default=1): 1,
             Extra: object
         },
         Required('complex_integrand', default=False):
         bool,
         # Most of the below are copied from FormulaGrader
         Required('user_functions', default={}):
         schema_user_functions,
         Required('user_constants', default={}):
         validate_user_constants(Number),
         # Blacklist/Whitelist have additional validation that can't happen here, because
         # their validation is correlated with each other
         Required('blacklist', default=[]): [str],
         Required('whitelist', default=[]):
         Any(All([None], Length(min=1, max=1)), [str]),
         Required('tolerance', default='0.01%'):
         Any(PercentageString, NonNegative(Number)),
         Required('samples', default=1):
         Positive(int),  # default changed to 1
         Required('variables', default=[]):
         All([str], all_unique),
         Required('sample_from', default={}):
         dict,
         Required('failable_evals', default=0):
         NonNegative(int)
     })
Example #4
0
 def schema_config(self):
     """Define the configuration options for FormulaGrader"""
     # Construct the default ItemGrader schema
     schema = super(FormulaGrader, self).schema_config
     # Append options
     forbidden_default = "Invalid Input: This particular answer is forbidden"
     return schema.extend({
         Required('user_functions', default={}): schema_user_functions,
         Required('user_constants', default={}): validate_user_constants(
             Number, MathArray),
         # Blacklist/Whitelist have additional validation that can't happen here, because
         # their validation is correlated with each other
         Required('blacklist', default=[]): [str],
         Required('whitelist', default=[]): Any(
             All([None], Length(min=1, max=1)),
             [str]
         ),
         Required('forbidden_strings', default=[]): [str],
         Required('forbidden_message', default=forbidden_default): str,
         Required('required_functions', default=[]): [str],
         Required('tolerance', default='0.01%'): Any(PercentageString, NonNegative(Number)),
         Required('metric_suffixes', default=False): bool,
         Required('samples', default=5): Positive(int),
         Required('variables', default=[]): All([str], all_unique),
         Required('numbered_vars', default=[]): All([str], all_unique),
         Required('sample_from', default={}): dict,
         Required('failable_evals', default=0): NonNegative(int),
         Required('max_array_dim', default=0): NonNegative(int)
     })
Example #5
0
 def schema_config(self):
     """Define the configuration options for ListGrader"""
     # Construct the default AbstractGrader schema
     schema = super(ListGrader, self).schema_config
     # Append options
     return schema.extend({
         Required('ordered', default=False): bool,
         Required('subgraders'): Any(AbstractGrader, [AbstractGrader]),
         Required('grouping', default=[]): [Positive(int)],
         Required('answers', default=[]): Any(list, (list,))  # Allow for a tuple of lists
     })
 def schema_config(self):
     """Define the configuration options for IntegralGrader"""
     # Construct the default AbstractGrader schema
     schema = super(IntegralGrader, self).schema_config
     # Apply the default math schema
     schema = schema.extend(self.math_config_options)
     # Append IntegralGrader-specific options
     default_input_positions = {
         'lower': 1,
         'upper': 2,
         'integrand': 3,
         'integration_variable': 4
     }
     return schema.extend({
         Required('answers'): {
             Required('lower'): text_string,
             Required('upper'): text_string,
             Required('integrand'): text_string,
             Required('integration_variable'): text_string
         },
         Required('input_positions', default=default_input_positions): {
             Required('lower', default=None):
             Any(None, Positive(int)),
             Required('upper', default=None):
             Any(None, Positive(int)),
             Required('integrand', default=None):
             Any(None, Positive(int)),
             Required('integration_variable', default=None):
             Any(None, Positive(int)),
         },
         Required('integrator_options', default={'full_output': 1}): {
             Required('full_output', default=1): 1,
             Extra: object
         },
         Required('complex_integrand', default=False): bool,
         Required('samples', default=1):
         Positive(int),  # default changed to 1
     })
Example #7
0
class AbstractSquareMatrices(VariableSamplingSet):  # pylint: disable=abstract-method
    """
    Abstract class representing a collection of square matrices

    Config:
    =======
        dimension: Positive integer that specifies the dimension of the matrix (default 2)
    """

    # This is an abstract base class
    __metaclass__ = abc.ABCMeta

    # Store the dimension of the square matrix
    schema_config = Schema({Required('dimension', default=2): Positive(int)})
 def schema_config(self):
     """Define the configuration options for SumGrader"""
     # Construct the default AbstractGrader schema
     schema = super(SumGrader, self).schema_config
     # Apply the default math schema
     schema = schema.extend(self.math_config_options)
     # Append SumGrader-specific options
     default_input_positions = {
         'lower': 1,
         'upper': 2,
         'summand': 3,
         'summation_variable': 4
     }
     return schema.extend({
         Required('answers'): {
             Required('lower'): text_string,
             Required('upper'): text_string,
             Required('summand'): text_string,
             Required('summation_variable'): text_string
         },
         Required('input_positions', default=default_input_positions): {
             Required('lower', default=None):
             Any(None, Positive(int)),
             Required('upper', default=None):
             Any(None, Positive(int)),
             Required('summand', default=None):
             Any(None, Positive(int)),
             Required('summation_variable', default=None):
             Any(None, Positive(int)),
         },
         Required('infty_val', default=1e3):
         Positive(Number),
         Required('infty_val_fact', default=80):
         Positive(Number),
         Required('even_odd', default=0):
         Any(0, 1, 2),
         Required('samples', default=2):
         Positive(int),  # default changed to 2
         Required('tolerance', default=1e-12):
         Any(PercentageString,
             NonNegative(Number)),  # default changed to 1e-12
     })
 def schema_config(self):
     """Define the configuration options for FormulaGrader"""
     # Construct the default ItemGrader schema
     schema = super(FormulaGrader, self).schema_config
     # Append options
     forbidden_default = "Invalid Input: This particular answer is forbidden"
     return schema.extend({
         Required('user_functions', default={}):
             {Extra: Any(is_callable, [is_callable], FunctionSamplingSet)},
         Required('user_constants', default={}): {Extra: Number},
         Required('blacklist', default=[]): [str],
         Required('whitelist', default=[]): [Any(str, None)],
         Required('forbidden_strings', default=[]): [str],
         Required('forbidden_message', default=forbidden_default): str,
         Required('required_functions', default=[]): [str],
         Required('tolerance', default='0.01%'): Any(PercentageString, NonNegative(Number)),
         Required('case_sensitive', default=True): bool,
         Required('metric_suffixes', default=False): bool,
         Required('samples', default=5): Positive(int),
         Required('variables', default=[]): [str],
         Required('sample_from', default={}): dict,
         Required('failable_evals', default=0): NonNegative(int)
     })
Example #10
0
class RandomFunction(FunctionSamplingSet):  # pylint: disable=too-few-public-methods
    """
    Generates a random well-behaved function on demand.

    Currently implemented as a sum of trigonometric functions with random amplitude,
    frequency and phase. You can control the center and amplitude of the resulting
    oscillations by specifying center and amplitude. The complex flag allows you to
    generate complex random functions.

    Config:
        input_dim (int): Number of input arguments. 1 is a unary function (default 1)
        output_dim (int): Number of output dimensions. 1 = scalar, more than 1 is a vector
            (default 1)
        num_terms (int): Number of random sinusoid terms to add together (default 3)
        center (float): Center around which oscillations occur (default 0)
        amplitude (float): Maximum amplitude of the function (default 10)
        complex (bool): Generate a complex random function (default False)

    Usage
    =====
    Generate a random continous function
    >>> funcs = RandomFunction()

    By default, the generated functions are R-->R. You can specify the
    input and output dimensions:
    >>> funcs = RandomFunction(input_dim=3, output_dim=2)

    To control the range of the function, specify a center and amplitude. The bounds of
    the function will be center - amplitude < func(x) < center + amplitude.
    The following will give oscillations between 0 and 1.
    >>> funcs = RandomFunction(center=0.5, amplitude=0.5)
    """

    schema_config = Schema({
        Required('input_dim', default=1): Positive(int),
        Required('output_dim', default=1): Positive(int),
        Required('num_terms', default=3): Positive(int),
        Required('center', default=0): Number,
        Required('amplitude', default=10): Positive(Number),
        Required('complex', default=False): bool
    })

    def gen_sample(self):
        """
        Returns a randomly chosen 'nice' function.

        The output is a vector with output_dim dimensions:
        Y^i = sum_{jk} A^i_{jk} sin(B^i_{jk} X_k + C^i_{jk})

        i ranges from 1 to output_dim
        j ranges from 1 to num_terms
        k ranges from 1 to input_dim
        """
        # Generate arrays of random values for A, B and C
        output_dim = self.config['output_dim']
        input_dim = self.config['input_dim']
        num_terms = self.config['num_terms']
        # Amplitudes A range from 0.5 to 1
        A = np.random.rand(output_dim, num_terms, input_dim) / 2 + 0.5
        # If we're complex, multiply the amplitude by a complex phase
        if self.config['complex']:
            complexphases = np.random.rand(output_dim, num_terms,
                                           input_dim) * np.pi * 2j
            A = A * np.exp(complexphases)
        # Angular frequencies B range from -pi to pi
        B = 2 * np.pi * (np.random.rand(output_dim, num_terms, input_dim) -
                         0.5)
        # Phases C range from 0 to 2*pi
        C = 2 * np.pi * np.random.rand(output_dim, num_terms, input_dim)

        def random_function(*args):
            """Function that generates the random values"""
            # Check that the dimensions are correct
            if len(args) != input_dim:
                msg = "Expected {} arguments, but received {}".format(
                    input_dim, len(args))
                raise ConfigError(msg)

            # Turn the inputs into an array
            xvec = np.array(args)
            # Repeat it into the shape of A, B and C
            xarray = np.tile(xvec, (output_dim, num_terms, 1))
            # Compute the output matrix
            output = A * np.sin(B * xarray + C)
            # Sum over the j and k terms
            # We have an old version of numpy going here, so we can't use
            # fullsum = np.sum(output, axis=(1, 2))
            fullsum = np.sum(np.sum(output, axis=2), axis=1)

            # Scale and translate to fit within center and amplitude
            fullsum = fullsum * self.config["amplitude"] / self.config[
                "num_terms"]
            fullsum += self.config["center"]

            # Return the result
            return MathArray(fullsum) if output_dim > 1 else fullsum[0]

        # Tag the function with the number of required arguments
        random_function.nin = input_dim

        return random_function
class MathMixin(object):
    """This is a mixin class that provides generic math handling capabilities"""
    # Set up a bunch of defaults
    default_variables = DEFAULT_VARIABLES.copy()
    default_functions = DEFAULT_FUNCTIONS.copy()
    default_suffixes = DEFAULT_SUFFIXES.copy()

    # Set up some debugging templates
    debug_appendix_eval_header_template = (
        "\n"
        "==============================================================\n"
        "{grader} Debug Info\n"
        "==============================================================\n"
        "Functions available during evaluation and allowed in answer:\n"
        "{functions_allowed}\n"
        "Functions available during evaluation and disallowed in answer:\n"
        "{functions_disallowed}\n")
    debug_appendix_comparison_template = (
        "\n"
        "==========================================\n"
        "Comparison Data for All {samples_total} Samples\n"
        "==========================================\n"
        "Comparer Function: {comparer}\n"
        "Comparison Results:\n"
        "{comparer_results}\n"
        "")

    # Set up the comparison utilities
    Utils = namedtuple('Utils', ['tolerance', 'within_tolerance'])

    def get_comparer_utils(self):
        """Get the utils for comparer function."""
        def _within_tolerance(x, y):
            return within_tolerance(x, y, self.config['tolerance'])

        return self.Utils(tolerance=self.config['tolerance'],
                          within_tolerance=_within_tolerance)

    # Set up a bunch of configuration options
    math_config_options = {
        Required('user_functions', default={}):
        schema_user_functions,
        Required('user_constants', default={}):
        validate_user_constants(Number, MathArray),
        # Blacklist/Whitelist have additional validation that can't happen here, because
        # their validation is correlated with each other
        Required('blacklist', default=[]): [text_string],
        Required('whitelist', default=[]):
        Any(All([None], Length(min=1, max=1)), [text_string]),
        Required('tolerance', default='0.01%'):
        Any(PercentageString, NonNegative(Number)),
        Required('samples', default=5):
        Positive(int),
        Required('variables', default=[]):
        All([text_string], all_unique),
        Required('numbered_vars', default=[]):
        All([text_string], all_unique),
        Required('sample_from', default={}):
        dict,
        Required('failable_evals', default=0):
        NonNegative(int),
        Required('forbidden_strings', default=[]): [text_string],
        Required('forbidden_message',
                 default="Invalid Input: This particular answer is forbidden"):
        text_string,
        Required('metric_suffixes', default=False):
        bool,
        Required('required_functions', default=[]): [text_string],
        Required('instructor_vars', default=[]): [text_string],
    }

    def validate_math_config(self):
        """Performs generic math configuration validation"""
        validate_blacklist_whitelist_config(self.default_functions,
                                            self.config['blacklist'],
                                            self.config['whitelist'])

        warn_if_override(self.config, 'variables', self.default_variables)
        warn_if_override(self.config, 'numbered_vars', self.default_variables)
        warn_if_override(self.config, 'user_constants', self.default_variables)
        warn_if_override(self.config, 'user_functions', self.default_functions)

        validate_no_collisions(self.config,
                               keys=['variables', 'user_constants'])

        self.permitted_functions = get_permitted_functions(
            self.default_functions, self.config['whitelist'],
            self.config['blacklist'], self.config['user_functions'])

        # Set up the various lists we use
        self.functions, self.random_funcs = construct_functions(
            self.default_functions, self.config["user_functions"])
        self.constants = construct_constants(self.default_variables,
                                             self.config["user_constants"])
        self.suffixes = construct_suffixes(self.default_suffixes,
                                           self.config["metric_suffixes"])

        # Construct the schema for sample_from
        # First, accept all VariableSamplingSets
        # Then, accept any list that RealInterval can interpret
        # Finally, single numbers or tuples of numbers will be handled by DiscreteSet
        schema_sample_from = Schema({
            Required(varname, default=RealInterval()):
            Any(VariableSamplingSet, All(list, Coerce(RealInterval)),
                Coerce(DiscreteSet))
            for varname in (self.config['variables'] +
                            self.config['numbered_vars'])
        })
        self.config['sample_from'] = schema_sample_from(
            self.config['sample_from'])
        # Note that voluptuous ensures that there are no orphaned entries in sample_from

    def check_math_response(self, answer, student_input, **kwargs):
        """Check the student response against a given answer"""
        result, used_funcs = self.raw_check(answer, student_input, **kwargs)

        if result['ok'] is True or result['ok'] == 'partial':
            self.post_eval_validation(student_input, used_funcs)
        return result

    def post_eval_validation(self, expr, used_funcs):
        """Runs several post-evaluation validator functions"""
        validate_forbidden_strings_not_used(expr,
                                            self.config['forbidden_strings'],
                                            self.config['forbidden_message'])

        validate_required_functions_used(used_funcs,
                                         self.config['required_functions'])

        validate_only_permitted_functions_used(used_funcs,
                                               self.permitted_functions)

    @staticmethod
    def get_used_vars(expressions):
        """
        Get the variables used in expressions

        Arguments:
            expressions: an iterable collection of expressions

        Returns:
            vars_used ({str}): set of variables used
        """
        is_empty = lambda x: x is None or x.strip() == ''
        expressions = [expr for expr in expressions if not is_empty(expr)]
        # Pre-parse all expressions (these all get cached)
        parsed_expressions = [parse(expr) for expr in expressions]
        # Create a list of all variables used in the expressions
        vars_used = set().union(
            *[p.variables_used for p in parsed_expressions])
        return vars_used

    def gen_var_and_func_samples(self, *args):
        """
        Generate a list of variable/function sampling dictionaries from the supplied arguments.
        Arguments may be strings, lists of strings, or dictionaries with string values.
        Does not flag any bad variables.
        """
        # Make a list of all expressions to check for variables
        expressions = []
        for entry in args:
            if isinstance(entry, six.text_type):
                expressions.append(entry)
            elif isinstance(entry, list):
                expressions += entry
            elif isinstance(entry, dict):
                expressions += [v for k, v in entry.items()]

        # Generate the variable list
        variables, sample_from_dict = self.generate_variable_list(expressions)

        # Generate the samples
        var_samples = gen_symbols_samples(variables, self.config['samples'],
                                          sample_from_dict, self.functions,
                                          self.suffixes, self.constants)

        func_samples = gen_symbols_samples(list(self.random_funcs.keys()),
                                           self.config['samples'],
                                           self.random_funcs, self.functions,
                                           self.suffixes, {})

        return var_samples, func_samples

    def generate_variable_list(self, expressions):
        """
        Generates the list of variables required to perform a comparison and the
        corresponding sampling dictionary, taking into account any numbered variables.
        Bad variables are not flagged here.

        Returns variable_list, sample_from_dict
        """
        vars_used = self.get_used_vars(expressions)

        # Seed the variables list with all allowed variables
        variable_list = list(self.config['variables'])
        # Make a copy of the sample_from dictionary, so we can add numbered variables to it
        sample_from_dict = self.config['sample_from'].copy()

        # Find all unassigned variables
        bad_vars = set(var for var in vars_used if var not in variable_list)

        # Check to see if any unassigned variables are numbered_vars
        regexp = numbered_vars_regexp(self.config['numbered_vars'])
        for var in bad_vars:
            match = regexp.match(var)  # Returns None if no match
            if match:
                # This variable is a numbered_variable
                # Go and add it to variable_list with the appropriate sampler
                (full_string, head) = match.groups()
                variable_list.append(full_string)
                sample_from_dict[full_string] = sample_from_dict[head]

        return variable_list, sample_from_dict

    def log_comparison_info(self, comparer, comparer_results):
        """Add sample comparison information to debug log"""
        msg = self.debug_appendix_comparison_template.format(
            samples_total=self.config['samples'],
            comparer=re.sub(r"0x[0-9a-fA-F]+", "0x...",
                            six.text_type(comparer)),
            comparer_results=pprint.pformat(comparer_results))
        msg = msg.replace("<", "&lt;").replace(">", "&gt;")
        self.log(msg)

    @staticmethod
    def consolidate_results(results, answer, failable_evals):
        """
        Consolidate comparer result(s) into just one result.

        Arguments:
            results: a list of results dicts
            answer (dict): correctness data for the expected answer, or None for all correct
            failable_evals: int
        """
        # Handle an empty answer
        if answer is None:
            answer = {'ok': True, 'grade_decimal': 1, 'msg': ''}

        # answer can contain extra keys, so prune them
        pruned_answer = {
            key: answer[key]
            for key in ['ok', 'grade_decimal', 'msg']
        }

        # Check each result for correctness
        num_failures = 0
        for result in results:
            if result['ok'] != True:
                num_failures += 1
                if len(results) == 1 or num_failures > failable_evals:
                    return result

        # This response appears to agree with the expected answer
        return pruned_answer

    def compare_evaluations(self, compare_params_evals, student_evals,
                            comparer, utils):
        """
        Compare the student evaluations to the expected results.
        """
        results = []
        if isinstance(comparer, CorrelatedComparer):
            result = comparer(compare_params_evals, student_evals, utils)
            results.append(ItemGrader.standardize_cfn_return(result))
        else:
            for compare_params_eval, student_eval in zip(
                    compare_params_evals, student_evals):
                result = comparer(compare_params_eval, student_eval, utils)
                results.append(ItemGrader.standardize_cfn_return(result))

        # TODO: Take out this if statement - should always work.
        # However, presently doesn't, because subgraders don't have access to the master debuglog.
        if self.config['debug']:
            self.log_comparison_info(comparer, results)

        return results

    def log_eval_info(self, index, varlist, funclist, **kwargs):
        """Add sample information to debug log"""

        if index == 0:
            header = self.debug_appendix_eval_header_template.format(
                grader=self.__class__.__name__,
                # The regexp replaces memory locations, e.g., 0x10eb1e848 -> 0x...
                functions_allowed=pprint.pformat({
                    f: funclist[f]
                    for f in funclist if f in self.permitted_functions
                }),
                functions_disallowed=pprint.pformat({
                    f: funclist[f]
                    for f in funclist if f not in self.permitted_functions
                }),
            )
            header = re.sub(r"0x[0-9a-fA-F]+", "0x...", header)
            header = header.replace('RandomFunction.gen_sample.<locals>.', '')
            header = header.replace("<", "&lt;").replace(">", "&gt;")
            self.log(header)
        msg = self.debug_appendix_eval_template.format(
            sample_num=index + 1,  # to account for 0 index
            samples_total=self.config['samples'],
            variables=pprint.pformat(varlist),
            **kwargs)
        msg = msg.replace("<", "&lt;").replace(">", "&gt;")
        self.log(msg)
class SpecifyDomain(ObjectWithSchema):
    """
    An author-facing, configurable decorator that validates the inputs
    of the decorated function.

    Performs shape validation and number-of-arguments validation.

    Configuration
    =============
    - input_shapes (list): A list of shapes for the function inputs, where:
        1: means input is scalar
        k, positive integer: means input is a k-component vector
        [k1, k2, ...], list of positive integers: means input is an array of shape (k1, k2, ...)
        (k1, k2, ...), tuple of positive integers: means input is an array of shape (k1, k2, ...)
        'square' indicates a square matrix of any dimension
    - display_name (?str): Function name to be used in error messages
      defaults to None, meaning that the function's __name__ attribute is used.
    - min_length (None or int): If None, inputs must match the exact shape specified by
        input_shapes. If a positive integer, input_shapes must have a single entry,
        and the function will accept any number of entries with that shape (minimum min_length).
        (default None)

    Basic Usage:
    ============

    Validate that both inputs to a cross product function are 3-component MathArrays:
    >>> import numpy as np          # just to get the cross product
    >>> @SpecifyDomain(input_shapes=[3, 3])
    ... def cross(u, v):
    ...     return np.cross(u, v)

    If inputs are valid, the function is calculated:
    >>> a = MathArray([2, -1, 3])
    >>> b = MathArray([-1, 4, 1])
    >>> np.array_equal(
    ...     cross(a, b),
    ...     np.cross(a, b)
    ... )
    True

    If inputs are bad, student-facing ArgumentShapeErrors are thrown:
    >>> a = MathArray([2, -1, 3])
    >>> b = MathArray([-1, 4])
    >>> try:
    ...     cross(a, b)
    ... except ArgumentShapeError as error:
    ...     print(error)
    There was an error evaluating function cross(...)
    1st input is ok: received a vector of length 3 as expected
    2nd input has an error: received a vector of length 2, expected a vector of length 3
    >>> try:
    ...     cross(a)
    ... except ArgumentError as error:
    ...     print(error)
    Wrong number of arguments passed to cross(...): Expected 2 inputs, but received 1.

    To specify that an input should be a an array of specific size, use a list or tuple
    for that shape value. Below, [3, 2] specifies a 3 by 2 matrix (the tuple
    (3, 2) would work also). Use 'square' to indicate square matrix of any
    dimension:
    >>> @SpecifyDomain(input_shapes=[1, [3, 2], 2, 'square'])
    ... def f(w, x, y, z):
    ...     pass # implement complicated stuff here
    >>> square_mat = MathArray([[1, 2], [3, 4]])
    >>> try:
    ...     f(1, 2, 3, square_mat)
    ... except ArgumentShapeError as error:
    ...     print(error)
    There was an error evaluating function f(...)
    1st input is ok: received a scalar as expected
    2nd input has an error: received a scalar, expected a matrix of shape (rows: 3, cols: 2)
    3rd input has an error: received a scalar, expected a vector of length 2
    4th input is ok: received a square matrix as expected

    To specify that a function should be allowed to take any number of inputs of a certain
    shape, input_shapes should have a single entry, and set min_length to the minimum
    number of arguments (at least 1).
    >>> @SpecifyDomain(input_shapes=[1], min_length=2)  # Any number of scalars
    ... def my_min(*args):
    ...     return min(*args)
    >>> my_min(1, 2, 3)
    1
    >>> my_min(2, 3, 4, 5, 6, 7, 8)
    2
    >>> try:
    ...     my_min(MathArray([[1, 1], [0, 1]]), 2, 3, 4)
    ... except ArgumentShapeError as error:
    ...     print(error)
    There was an error evaluating function my_min(...)
    1st input has an error: received a matrix of shape (rows: 2, cols: 2), expected a scalar
    2nd input is ok: received a scalar as expected
    3rd input is ok: received a scalar as expected
    4th input is ok: received a scalar as expected
    """

    schema_config = Schema({
        Required('input_shapes'):
        [Schema(Any(is_shape_specification(), 'square'))],
        Required('display_name', default=None):
        Nullable(text_string),
        Required('min_length', default=None):
        Nullable(Positive(int))
    })

    def __init__(self, config=None, **kwargs):
        super(SpecifyDomain, self).__init__(config, **kwargs)

        shapes = self.config['input_shapes']

        # Check that min_length is compatible with the provided shapes
        if self.config['min_length'] is not None and len(shapes) != 1:
            raise ConfigError(
                "SpecifyDomain was called with a specified min_length, which "
                "requires input_shapes to specify only a single shape. "
                "However, {} shapes were provided.".format(len(shapes)))

        self.decorate = self.make_decorator(
            *shapes,
            display_name=self.config['display_name'],
            min_length=self.config['min_length'])

    def __call__(self, func):
        return self.decorate(func)

    @staticmethod
    def make_decorator(*shapes, **kwargs):
        """
        Constructs the decorator that validates inputs.

        This method is NOT author-facing; its inputs undergo no validation.

        Used internally in mitxgraders library.
        """
        display_name = kwargs.get('display_name', None)
        min_length = kwargs.get('min_length', None)
        if min_length is not None:
            any_schema = Schema(has_shape(shapes[0]))
        else:
            len_schema = [Schema(has_shape(shape)) for shape in shapes]

        # can't use @wraps, func might be a numpy ufunc
        def decorator(func):
            func_name = display_name if display_name else func.__name__

            @wraps(func)
            def _func(*args):
                # Set up the schemas and shapes for validation.
                # Also check the number of arguments provided is correct.
                # Use the same response as in validate_function_call in expressions.py
                msg = ''
                if min_length is not None:
                    schemas = [any_schema] * len(args)
                    use_shapes = [shapes[0]] * len(args)
                    if len(args) < min_length:
                        msg = (
                            "Wrong number of arguments passed to {func_name}(...): "
                            "Expected at least {expected} inputs, but received {received}."
                            .format(func_name=func_name,
                                    expected=min_length,
                                    received=len(args)))
                else:
                    schemas = len_schema
                    use_shapes = shapes
                    if len(shapes) != len(args):
                        msg = (
                            "Wrong number of arguments passed to {func_name}(...): "
                            "Expected {expected} inputs, but received {received}."
                            .format(func_name=func_name,
                                    expected=len(shapes),
                                    received=len(args)))
                if msg:
                    raise ArgumentError(msg)

                errors = []
                for schema, arg in zip(schemas, args):
                    try:
                        schema(arg)
                        errors.append(None)
                    except Invalid as error:
                        errors.append(error)

                if all([error is None for error in errors]):
                    return func(*args)

                lines = [
                    'There was an error evaluating function {0}(...)'.format(
                        func_name)
                ]
                for index, (shape, error) in enumerate(zip(use_shapes,
                                                           errors)):
                    ordinal = low_ordinal(index + 1)
                    if error:
                        lines.append(
                            '{0} input has an error: '.format(ordinal) +
                            error.error_message)
                    else:
                        expected = get_shape_description(shape)
                        lines.append(
                            '{0} input is ok: received a {1} as expected'.
                            format(ordinal, expected))

                message = "\n".join(lines)
                raise ArgumentShapeError(message)

            _func.validated = True

            return _func

        return decorator