Complexity Annotations#

Complexity annotations define an expectation of the complexity of a block of student code. Currently, PyBryt supports one kind of complexity annotation: time complexity. All complexity annotations are subclasses of the abstract ComplexityAnnotation class, which defines some helpful defaults for working with these annotations.

Creating Complexity Annotations#

Complexity annotations can be created just like value annotations: by calling their constructor. The constructor takes one positional argument which corresponds to a class asserting the expected complexity of the code. These complexity classes can be found in the pybryt.complexities module. All complexity classes are subclasses of the abstract base class pybryt.complexities.complexity.

The constructor also takes one required keyword argument, name. This should be set to a string that corresponds to named time complexity context in the student’s code (more on this below). The name should be unique across all annotations.

For example, to check the time complexity of a block named "sort" with \(\mathcal{O}(n \log n)\):

import pybryt
import pybryt.complexities as cplx

pybryt.TimeComplexity(cplx.linearithmic, name="sort")

A full list of the available complexity classes can be found here.

Custom Complexity Classes#

If a complexity not provided in PyBryt’s defaults is needed, this can be created by subclassing pybryt.complexities.complexity. This abstract base class requires the transform_n static method to be implemented, which should transform the array of input lengths, passed as an numpy.ndarray so that the least-squares solver can be used to determine if the data matches this complexity. Optionally, you can also override the transform_t static method, which transforms the array of timings. The default implementation of this function is the identity function.

import numpy as np
import pybryt.complexities as cplx

class loglog(cplx.complexity):

    @staticmethod
    def transform_n(n: np.ndarray) -> np.ndarray:
        return np.vstack((np.ones(len(n)), np.log2(np.log2(n)))).T

To use these custom complexity classes, they can either be passed as the first argument to the TimeComplexity constructor, if they are the desired complexity, or passed in a list to the addl_complexities argument if they should be considered but are not correct.

# my_cplx is a package with custom complexities
pybryt.TimeComplexity(my_cplx.quartic, name="foo", addl_complexities=[my_cplx.factorial])

Tracking Code Complexity#

To track the time complexity of students’ code, PyBryt provides the check_time_complexity context manager. This context manager takes two arguments, the name of the block (which should match the name of a time complexity annotation in the reference implementation) and length of the input (or, for simplicity, the input itself if it has a __len__ method).

The context manager uses PyBryt’s tracing function to determine the runtime of the student’s code by counting the number of steps taken while the code executes (the number of times the trace function is called). It then adds an object that tracks this information to the student’s memory footprint when the block exits.

An important note: any code placed inside this context manager will not have its objects in-memory traced. PyBryt’s trace function will only be used to track the number of steps taken during execution, but no values will be added to the student’s memory footprint. This means that any code needed to satisfy value annotations, relational annotations, etc. must be placed outside this context manager. This is also the case during reference construction: any annotations created by code inside this context manager will not be automatically tracked and added to a reference implementation. If this behavior is desired, it must be accomplished manually.

For example, to satisfy the time complexity annotation above, the code block below checks the time complexity of a student-implemented sort function on a series of inputs of increasing sizes.

import numpy as np
for exp in np.arange(8):
    n = np.random.uniform(size=10**exp)
    with pybryt.check_time_complexity("sort", n):
        sort(n)

Evaluating Code Complexity#

When evaluating whether a time complexity annotation has been satisfied, PyBryt uses the method of the big_O Python package. The annotation is evaluated by collecting all of the time complexity result objects found in the student’s memory footprint that match the name of the annotation.

The data from these results is collected and sent through each complexity class, which uses np.linalg.lstsq to fit the input length data to the time data (incorporating the transformations needed for each complexity) and returns the residuals of this fit. The complexity with the smallest residuals (incorporating a slight preference for simpler complexities) is determined to be the best match, and is used to determine whether or not the annotation was satisfied.