Repository: microsoft/pybryt

Issue:
Add an annotation asserting that a value is a return value
Add an annotation that is similar to a value annotation but which specifically asserts that a value was returned by a function (that is, was the value of `arg` when the trace function was called for the `return` event).

Consider matrix exponentiation `A^p` when `p == 1`: the matrix `A` always shows up in the memory footprint as long as it's used inside the function body, but the return value of the function is not necessarily `A`. This may also help design references for problems that suffer from the issues described in #62.

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3ac107a..455860d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file, and this pr
 ## Unreleased
 
 * Added complexity unions for combining complexity classes per [#155](https://github.com/microsoft/pybryt/issues/155)
+* Added a return value annotation per [#144](https://github.com/microsoft/pybryt/issues/144)
 
 ## 0.4.0 - 2022-01-12
 
diff --git a/docs/annotations/type_annotations.rst b/docs/annotations/type_annotations.rst
index d7d39aa..508d15e 100644
--- a/docs/annotations/type_annotations.rst
+++ b/docs/annotations/type_annotations.rst
@@ -23,4 +23,4 @@ arrays:
     pybryt.ForbidType(np.ndarray, failure_message="Arrays are not allowed!")
 
 Checking for values is performed using Python's built-in ``isinstance`` function. Types passed to 
-the constructor must be picklable with the ``dill`` library.
+the constructor must be pickleable with the ``dill`` library.
diff --git a/docs/annotations/value_annotations.rst b/docs/annotations/value_annotations.rst
index 0535f81..e41cd12 100644
--- a/docs/annotations/value_annotations.rst
+++ b/docs/annotations/value_annotations.rst
@@ -61,6 +61,24 @@ To ensure that the annotation is only satisfied when the object is of the same t
         failure_message="Your model doesn't have the correct coefficients.")
 
 
+Return Value Annotations
+------------------------
+
+In addition to checking for the presence of a value, PyBryt also has an annotation that asserts that
+a value was returned by a student's function. The annotation does not specify anything about the
+function that returns it except that the function is part of the submission code (i.e. not part of
+an imported library); it merely checks that the value was seen at least once in a return event
+passed to the trace function.
+
+You can create a return value annotation with the
+:py:class:`ReturnValue<pybryt.annotations.value.ReturnValue>` constructor; it accepts all the same
+arguments as the :py:class:`Value<pybryt.annotations.value.Value>` constructor:
+
+.. code-block:: python
+
+    pybryt.ReturnValue(df, success_message="...", failure_message="...")
+
+
 Numerical Tolerances
 ---------------------
 
diff --git a/pybryt/annotations/complexity/annotation.py b/pybryt/annotations/complexity/annotation.py
index e816c27..18abc54 100644
--- a/pybryt/annotations/complexity/annotation.py
+++ b/pybryt/annotations/complexity/annotation.py
@@ -113,11 +113,11 @@ class TimeComplexity(ComplexityAnnotation):
             self.addl_complexities.insert(0, self.complexity)
 
         complexity_data = {}
-        for v, _ in footprint.values:
-            if not isinstance(v, TimeComplexityResult) or v.name != self.name:
+        for mfp_val in footprint:
+            if not isinstance(mfp_val.value, TimeComplexityResult) or mfp_val.value.name != self.name:
                 continue
 
-            complexity_data[v.n] = v.stop - v.start
+            complexity_data[mfp_val.value.n] = mfp_val.value.stop - mfp_val.value.start
 
         best_res = None
         complexities: List[cplx.complexity] = cplx.complexity_classes + self.addl_complexities
diff --git a/pybryt/annotations/type_.py b/pybryt/annotations/type_.py
index e145bd7..ffb9d15 100644
--- a/pybryt/annotations/type_.py
+++ b/pybryt/annotations/type_.py
@@ -56,8 +56,8 @@ class ForbidType(Annotation):
         Returns:
             :py:class:`AnnotationResult`: the results of this annotation against ``footprint``
         """
-        for val, _ in footprint.values:
-            if isinstance(val, self.type_):
+        for mfp_val in footprint:
+            if isinstance(mfp_val.value, self.type_):
                 return AnnotationResult(False, self)
         return AnnotationResult(True, self)
 
diff --git a/pybryt/annotations/value.py b/pybryt/annotations/value.py
index bb937d1..3ebd63b 100644
--- a/pybryt/annotations/value.py
+++ b/pybryt/annotations/value.py
@@ -1,6 +1,6 @@
 """Annotations for asserting the presence of a value"""
 
-__all__ = ["Value", "Attribute"]
+__all__ = ["Value", "Attribute", "ReturnValue"]
 
 import dill
 import numbers
@@ -16,7 +16,7 @@ from .annotation import Annotation, AnnotationResult
 from .invariants import invariant
 
 from ..debug import _debug_mode_enabled
-from ..execution import MemoryFootprint
+from ..execution import Event, MemoryFootprint, MemoryFootprintValue
 
 
 class Value(Annotation):
@@ -24,9 +24,9 @@ class Value(Annotation):
     Annotation class for asserting that a value should be observed.
 
     Indicates that a value passed to the constructor should be observed while tracing through the
-    students' code. Values can be of any type that is picklable by ``dill``. Values can specify a
+    students' code. Values can be of any type that is pickleable by ``dill``. Values can specify a
     list of :ref:`invariants<invariants>` that will allow objects to be considered "equal." For 
-    values that support arithemtic operators, absolute tolerances can be specified as well.
+    values that support arithmetic operators, absolute tolerances can be specified as well.
 
     Numeric tolerances are computed as with ``numpy.allcose``, where the value is considered "equal 
     enough" if it is within :math:`v \\pm (\\texttt{atol} + \\texttt{rtol} \\cdot |v|)`, where 
@@ -132,16 +132,47 @@ class Value(Annotation):
         Returns:
             :py:class:`AnnotationResult`: the results of this annotation against ``footprint``
         """
-        satisfied = [self._check_observed_value(v) for v, _ in footprint.values]
-        if not any(satisfied):
+        satisfier = self._get_satisfying_index(footprint)
+        return self._generate_annotation_result(footprint, satisfier)
+
+    def _get_satisfying_index(self, footprint: MemoryFootprint) -> Optional[int]:
+        """
+        Return the index of the first value in the memory footprint that satisfies this annotation.
+
+        Args:
+            footprint (:py:class:`pybryt.execution.memory_footprint.MemoryFootprint`): the
+                memory footprint to check against
+
+        Returns:
+            ``int | None``: the index, or ``None`` if no value satisfies this annotation.
+        """
+        satisfied = [self._check_observed_value(mfp_val.value) for mfp_val in footprint]
+        return satisfied.index(True) if any(satisfied) else None
+
+    def _generate_annotation_result(
+        self,
+        footprint: MemoryFootprint,
+        satisfying_index: Optional[int],
+    ) -> AnnotationResult:
+        """
+        Return an ``AnnotationResult`` based on the provided satisfying index.
+
+        Args:
+            footprint (``MemoryFootprint``): the footprint being checked
+            satisfying_index (``int | None``): the index of the value in the memory footprint that
+                satisfies the annotation, otherwise ``None``
+
+        Returns:
+            ``AnnotationResult``: the result
+        """
+        if satisfying_index is None:
             return AnnotationResult(False, self)
 
-        first_satisfier = satisfied.index(True)
         return AnnotationResult(
             True, 
             self, 
-            footprint.values[first_satisfier][0],
-            footprint.values[first_satisfier][1],
+            footprint.get_value(satisfying_index).value,
+            footprint.get_value(satisfying_index).timestamp,
         )
 
     def __eq__(self, other: Any) -> bool:
@@ -172,7 +203,7 @@ class Value(Annotation):
         Returns:
             ``bool``: whether this annotation is satisfied by the provided value
         """
-        return self.check(MemoryFootprint.from_values(other_value, 0)).satisfied
+        return self.check(MemoryFootprint.from_values(MemoryFootprintValue(other_value, 0, None))).satisfied
 
     def _check_observed_value(self, observed_value: Any) -> bool:
         """
@@ -372,18 +403,20 @@ class _AttrValue(Value):
         Returns:
             :py:class:`AnnotationResult`: the results of this annotation against ``footprint``
         """
-        observed_values = footprint.values
-        if self.enforce_type:  # filter out values of wrong type if enforce_type is True
-            observed_values = [t for t in observed_values if isinstance(t[0], type(self._object))]
-        vals = [t for t in observed_values if hasattr(t[0], self._attr)]
-        args = chain.from_iterable((getattr(obj, self._attr), ts) for obj, ts in vals)
-        attrs_fp = MemoryFootprint.from_values(*args)
-        res = super().check(attrs_fp)
-        try:
-            satisfier = vals[attrs_fp.values.index((res.value, res.timestamp))][0]
-        except ValueError:
-            satisfier = None
-        return AnnotationResult(None, self, value=satisfier, children=[res])
+        orig_mfp_vals, attr_mfp_vals = [], []
+        for mfp_val in footprint:
+            if not self.enforce_type or isinstance(mfp_val.value, type(self._object)):
+                mfp_lst = mfp_val.to_list()
+                if hasattr(mfp_val.value, self._attr):
+                    mfp_lst[0] = getattr(mfp_val.value, self._attr)
+                    attr_mfp_vals.append(MemoryFootprintValue(*mfp_lst))
+                    orig_mfp_vals.append(mfp_val)
+
+        attrs_fp = MemoryFootprint.from_values(*attr_mfp_vals)
+        satisfying_index = self._get_satisfying_index(attrs_fp)
+        child_res = self._generate_annotation_result(attrs_fp, satisfying_index)
+        satisfier = None if satisfying_index is None else orig_mfp_vals[satisfying_index].value
+        return AnnotationResult(None, self, value=satisfier, children=[child_res])
 
 
 class Attribute(Annotation):
@@ -502,4 +535,38 @@ class Attribute(Annotation):
         Returns:
             ``bool``: whether this annotation is satisfied by the provided value
         """
-        return self.check(MemoryFootprint.from_values(other_value, 0)).satisfied
+        return self.check(MemoryFootprint.from_values(MemoryFootprintValue(other_value, 0, None))).satisfied
+
+
+class ReturnValue(Value):
+    """
+    Annotation class for asserting that a value should be returned by a student-written function.
+
+    Indicates that a value passed to the constructor should be returned by a call to a student's
+    function. Values can be of any type that is pickleable by ``dill``. Values can specify a
+    list of :ref:`invariants<invariants>` that will allow objects to be considered "equal." For 
+    values that support arithmetic operators, absolute tolerances can be specified as well.
+
+    Numeric tolerances are computed as with ``numpy.allcose``, where the value is considered "equal 
+    enough" if it is within :math:`v \\pm (\\texttt{atol} + \\texttt{rtol} \\cdot |v|)`, where 
+    :math:`v` is the value of the annotation.
+
+    Args:
+        value (``object``): the value that should be observed
+        atol (``float`` or ``int``, optional): absolute tolerance for numeric values
+        rtol (``float`` or ``int``, optional): relative tolerance for numeric values
+        invariants (``list[invariant]``): invariants for 
+            this value
+        equivalence_fn (``callable[[object, object], bool]``): an optional function to check for
+            equivalence between two values, overriding the default provided by ``Value``
+        **kwargs: additional keyword arguments passed to the 
+            :py:class:`Annotation<pybryt.annotations.annotation.Annotation>` constructor
+    """
+
+    _VALID_EVENTS = {Event.RETURN, Event.LINE_AND_RETURN}
+
+    def check(self, footprint: MemoryFootprint) -> AnnotationResult:
+        satisfier = self._get_satisfying_index(footprint)
+        if satisfier is not None and footprint.get_value(satisfier).event not in type(self)._VALID_EVENTS:
+            satisfier = None
+        return self._generate_annotation_result(footprint, satisfier)
diff --git a/pybryt/complexity.py b/pybryt/complexity.py
index 228ae88..a6f0852 100644
--- a/pybryt/complexity.py
+++ b/pybryt/complexity.py
@@ -6,6 +6,8 @@ from collections.abc import Sized
 from itertools import chain
 from typing import List, Optional, Union
 
+from pybryt.execution.memory_footprint import MemoryFootprintValue
+
 from .annotations import complexities as cplx, TimeComplexity
 from .execution import (
     check_time_complexity, 
@@ -72,7 +74,7 @@ class TimeComplexityChecker:
         """
         annot = TimeComplexity(cplx.constant, name=self.name)
         footprint = MemoryFootprint.from_values(
-            *chain.from_iterable([(tcr, i) for i, tcr in enumerate(self.results)]))
+            *[MemoryFootprintValue(tcr, i, None) for i, tcr in enumerate(self.results)])
         result = annot.check(footprint)
         return result.value
 
@@ -116,9 +118,9 @@ class _check_time_complexity_wrapper:
         self.frame_tracer.end_trace()
 
         result = None
-        for val, _ in self.frame_tracer.get_footprint().values:
-            if isinstance(val, TimeComplexityResult):
-                result = val
+        for mfp_val in self.frame_tracer.get_footprint():
+            if isinstance(mfp_val.value, TimeComplexityResult):
+                result = mfp_val.value
 
         if result:
             self.checker.add_result(result)
diff --git a/pybryt/execution/__init__.py b/pybryt/execution/__init__.py
index 678a731..5ad1e3a 100644
--- a/pybryt/execution/__init__.py
+++ b/pybryt/execution/__init__.py
@@ -13,7 +13,7 @@ from typing import Any, List, Tuple, Optional
 from textwrap import dedent
 
 from .complexity import check_time_complexity, is_complexity_tracing_enabled, TimeComplexityResult
-from .memory_footprint import MemoryFootprint
+from .memory_footprint import Event, MemoryFootprint, MemoryFootprintValue
 from .tracing import (
     create_collector,
     FrameTracer,
@@ -78,7 +78,7 @@ def execute_notebook(
     last_cell = nbformat.v4.new_code_cell(dedent(f"""\
         from pybryt.execution import tracing_off
         tracing_off()
-        {footprint_varname}.filter_out_unpicklable_values()
+        {footprint_varname}.filter_out_unpickleable_values()
         import dill
         with open("{footprint_fp}", "wb+") as f:
             dill.dump({footprint_varname}, f)
diff --git a/pybryt/execution/memory_footprint.py b/pybryt/execution/memory_footprint.py
index 31a898a..a9c860a 100644
--- a/pybryt/execution/memory_footprint.py
+++ b/pybryt/execution/memory_footprint.py
@@ -2,9 +2,40 @@
 
 import nbformat
 
-from typing import Any, List, Optional, Set, Tuple, Union
+from dataclasses import astuple, dataclass
+from enum import Enum
+from typing import Any, Dict, List, Optional, Set, Tuple, Union
 
-from ..utils import filter_picklable_list, pickle_and_hash
+from ..utils import filter_pickleable_list, pickle_and_hash
+
+
+class Event(Enum):
+    """
+    An enum of event ``sys.settrace`` event types traced by PyBryt.
+    """
+
+    LINE = "line"
+    """indicates that a value was seen in a "line" event"""
+
+    RETURN = "return"
+    """indicates that a value was seen in a "return" event"""
+
+    LINE_AND_RETURN = "line_and_return"
+    """indicates that a value was seen in both a "line" and "return" event"""
+
+    @classmethod
+    def from_event_name(cls, event_name: str) -> Optional["Event"]:
+        """
+        Return the enum value corresponding to the event name, if present.
+
+        Args:
+            event_name (``str``): the event name provided by ``sys.settrace``
+
+        Returns:
+            :py:class:`Event`: the enum value corresponding to the event name if present,
+                otherwise ``None``
+        """
+        return cls(event_name) if event_name in {v.value for v in cls.__members__.values()} else None
 
 
 class Counter:
@@ -46,6 +77,28 @@ class Counter:
         self.val += val
 
 
+@dataclass
+class MemoryFootprintValue:
+    """
+    A data class for an observed value in the memory footprint.
+
+    The fields and their order should correspond to the structure of the tuples in
+    :py:attribute:`MemoryFootprint.values`.
+    """
+
+    value: Any
+    """the value"""
+
+    timestamp: int
+    """the timestamp at which this value was observed"""
+
+    event: Optional[Event]
+    """the trace function event that created this value, if applicable"""
+
+    def to_list(self):
+        return list(astuple(self))
+
+
 class MemoryFootprint:
     """
     A memory footprint for an executed notebook.
@@ -58,11 +111,11 @@ class MemoryFootprint:
     counter: Counter
     """the counter used to construct this footprint"""
 
-    _hashes: Set[str]
-    """set of hashed values present in the memory footprint"""
+    _value_indices_by_hash: Dict[str, int]
+    """indices of values keyed on their hashes"""
 
-    values: List[Tuple[Any, int]]
-    """the values and timestamps in the footprint"""
+    values: List[Tuple[Any, int, Optional[Event]]]
+    """the values, timestamps, and event types in the footprint"""
 
     calls: List[Tuple[str, str]]
     """the list of function calls tracked by the trace function"""
@@ -75,44 +128,29 @@ class MemoryFootprint:
 
     def __init__(self, counter: Optional[Counter] = None):
         self.counter = counter if counter is not None else Counter()
-        self._hashes = set()
+        self._value_indices_by_hash = {}
         self.values = []
         self.calls = []
         self.imports = set()
         self.executed_notebook = None
 
     @classmethod
-    def from_values(cls, *values_and_timestamps: Union[Any, int]) -> 'MemoryFootprint':
+    def from_values(cls, *values: MemoryFootprintValue) -> 'MemoryFootprint':
         """
         Generate a memory footprint from values and their timestamps.
-
-        Values and timestamps should be provided in sequence, with a value followed by its timestmap.
-        For example:
-
-        .. code-block:: python
-
-            MemoryFootprint.from_values(val1, ts1, val2, ts2, val3, ts3, ...)
-
         Args:
-            values_and_timestamps (``object | int``): the values and timestamps
+            values_and_timestamps (:py:class:`MemoryFootprintValue`): the values
 
         Returns:
             :py:class:`pybryt.execution.memory_footprint.MemoryFootprint`: the memory footprint
 
         Raises:
-            ``ValueError``: if an odd number of arguments is provided
-            ``TypeError``: if one of the timestamps is not an ``int``
+            ``TypeError``: if any of the values is of the wrong type
         """
-        if len(values_and_timestamps) % 2 != 0:
-            raise ValueError("Uneven number of arguments provided")
-
-        values = [(values_and_timestamps[2 * i], values_and_timestamps[2 * i + 1]) \
-            for i in range(len(values_and_timestamps) // 2)]
+        if not all(isinstance(v, MemoryFootprintValue) for v in values):
+            raise TypeError("Arguments to from_values must be of type MemoryFootprintValue")
 
-        not_ints = [not isinstance(ts, int) for _, ts in values]
-        if any(not_ints):
-            bad_idx = not_ints.index(True)
-            raise TypeError(f"Provided timestamp is not an integer: {values[bad_idx][1]}")
+        values = [astuple(v) for v in values]
 
         footprint = cls()
         footprint.values.extend(values)
@@ -125,7 +163,7 @@ class MemoryFootprint:
         Generate a memory footprint by combining memory footprints in-sequence.
 
         Duplicate values in different footprints are removed, keeping the first instance seen. The
-        timestmaps of each footprint are offset by the number of steps in all preceeding footprints.
+        timestmaps of each footprint are offset by the number of steps in all preceding footprints.
 
         Args:
             *footprints (:py:class:`pybryt.execution.memory_footprint.MemoryFootprint`): the 
@@ -139,11 +177,16 @@ class MemoryFootprint:
         timestamp_offset = 0  # offset for timestamps in the new memory footprint
         for fp in footprints:
             map(lambda c: new_fp.add_call(*c), fp.calls)
-            for obj, ts in fp.values:
-                h = pickle_and_hash(obj)
+            for fp_val in fp:
+                h = pickle_and_hash(fp_val.value)
                 if h not in seen:
-                    ts += timestamp_offset
-                    new_fp.add_value(obj, ts)
+                    fp_val.timestamp += timestamp_offset
+                    new_fp.add_value(
+                        fp_val.value,
+                        fp_val.timestamp,
+                        fp_val.event,
+                        allow_duplicates=True,
+                    )
                     seen.add(h)
 
             timestamp_offset += fp.num_steps
@@ -166,7 +209,13 @@ class MemoryFootprint:
         """
         self.counter.offset(val)
 
-    def add_value(self, val: Any, timestamp: Optional[int] = None, allow_duplicates: bool = False) -> None:
+    def add_value(
+        self,
+        val: Any,
+        timestamp: Optional[int] = None,
+        event: Optional[Event] = None,
+        allow_duplicates: bool = False,
+    ) -> None:
         """
         Add a value to the memory footprint.
 
@@ -177,21 +226,33 @@ class MemoryFootprint:
         Args:
             value (``object``): the value to add
             timestamp (``int``, optional): the timestamp
+            event (:py:class:`Event`, optional): the event that produced the value
             allow_duplicates(``bool``): whether duplicate values should be allowed in the footprint
         """
         if not allow_duplicates:
             h = pickle_and_hash(val)
-            if h in self._hashes:
+            if h in self._value_indices_by_hash:
+                tup = self.values[self._value_indices_by_hash[h]]
+                tup_event = tup[2]
+
+                # update the event type if necessary
+                if tup_event is None and event is not None:
+                    tup_event = event
+                elif tup_event is not None and event is not None and tup_event != event:
+                    tup_event = Event.LINE_AND_RETURN
+                
+                self.values[self._value_indices_by_hash[h]] = (tup[0], tup[1], tup_event)
+
                 return
 
-            self._hashes.add(h)
+            self._value_indices_by_hash[h] = len(self.values)
 
         if timestamp is None:
             timestamp = self.counter.get_value()
 
-        self.values.append((val, timestamp))
+        self.values.append((val, timestamp, event))
 
-    def get_value(self, index: int) -> Any:
+    def get_value(self, index: int) -> MemoryFootprintValue: # TODO: change name??
         """
         Get the value at the specified index.
 
@@ -199,21 +260,9 @@ class MemoryFootprint:
             index (``int``): the index
 
         Returns:
-            ``object``: the value
+            :py:class:`MemoryFootprintValue`: the value
         """
-        return self.values[index][0]
-
-    def get_timestamp(self, index: int) -> int:
-        """
-        Get the timestamp of the value at the specified index.
-
-        Args:
-            index (``int``): the index
-
-        Returns:
-            ``int``: the timestamp
-        """
-        return self.values[index][1]
+        return MemoryFootprintValue(*self.values[index])
 
     def add_call(self, filename: str, fn_name: str) -> None:
         """
@@ -243,11 +292,16 @@ class MemoryFootprint:
         """
         self.executed_notebook = nb
 
-    def filter_out_unpicklable_values(self) -> None:
+    def filter_out_unpickleable_values(self) -> None:
+        """
+        Filter any unpickleable objects out of the list of value-timestamp tuples in-place.
+        """
+        filter_pickleable_list(self.values)
+
+    def clear(self) -> None:
         """
-        Filter any unpicklable objects out of the list of value-timestamp tuples in-place.
         """
-        filter_picklable_list(self.values)
+        self.values.clear()
 
     @property
     def num_steps(self) -> int:
@@ -257,13 +311,47 @@ class MemoryFootprint:
         timestamps = [t[1] for t in self.values]
         return max(timestamps) if len(timestamps) else -1
 
+    def __len__(self):
+        return len(self.values)
+
+    def __iter__(self):
+        return MemoryFootprintIterator(self)
+
     def __eq__(self, other: Any):
         """
         Return whether another object is equal to this one.
 
-        An object is equal to a memory footprint if it is also a memory fotprint and has the same
+        An object is equal to a memory footprint if it is also a memory footprint and has the same
         values, calls, imports, and executed notebook.
         """
-        return isinstance(other, type(self)) and self.values == other.values \
-            and self.calls == other.calls and self.imports == other.imports \
-            and self.executed_notebook == other.executed_notebook
+        return isinstance(other, type(self)) and self.calls == other.calls \
+            and self.imports == other.imports \
+            and self.executed_notebook == other.executed_notebook \
+            and pickle_and_hash(self.values) == pickle_and_hash(other.values)
+
+
+class MemoryFootprintIterator:
+    """
+    An iterator class for memory footprints.
+
+    Args:
+        footprint (:py:class:`MemoryFootprint`): the memory footprint to iterate over
+        start (``int``, optional): the index to start at
+    """
+
+    footprint: MemoryFootprint
+    """the memory footprint being iterated over"""
+
+    _index: int
+    """the next index in the memory footprint to yield"""
+
+    def __init__(self, footprint: MemoryFootprint, start: int = 0):
+        self.footprint = footprint
+        self._index = start
+
+    def __next__(self) -> MemoryFootprintValue:
+        if self._index < len(self.footprint):
+            value = self.footprint.get_value(self._index)
+            self._index += 1
+            return value
+        raise StopIteration()
diff --git a/pybryt/execution/tracing.py b/pybryt/execution/tracing.py
index 56d1cb4..792bec4 100644
--- a/pybryt/execution/tracing.py
+++ b/pybryt/execution/tracing.py
@@ -9,10 +9,10 @@ from types import FrameType, FunctionType, ModuleType
 from typing import Any, Dict, List, Optional, Tuple, Callable
 
 from .complexity import is_complexity_tracing_enabled
-from .memory_footprint import MemoryFootprint
+from .memory_footprint import Event, MemoryFootprint
 from .utils import is_ipython_frame
 
-from ..utils import make_secret, pickle_and_hash, UnpicklableError
+from ..utils import make_secret, pickle_and_hash, UnpickleableError
 
 
 ACTIVE_FOOTPRINT = None
@@ -42,10 +42,10 @@ def create_collector(
         and the trace function
     """
     global ACTIVE_FOOTPRINT
-    vars_not_found = {}
+    vars_not_found: Dict[str, List[Tuple[str, str, int]]] = {}
     footprint = MemoryFootprint()
 
-    def track_value(val, seen_at=None):
+    def track_value(val: Any, event_name: str, seen_at: Optional[int] = None):
         """
         Tracks a value in ``footprint``. Checks that the value has not already been tracked by 
         pickling it and hashing the pickled object and comparing it to ``hashes``. If pickling is
@@ -53,6 +53,7 @@ def create_collector(
 
         Args:
             val (``object``): the object to be tracked
+            event_name (``str``): the event name provided by ``sys.settrace``
             seen_at (``int``, optional): an overriding step counter value
         """
         try:
@@ -66,7 +67,8 @@ def create_collector(
                 footprint.add_imports(val.__name__.split(".")[0])
                 return
 
-            footprint.add_value(copy(val), seen_at)
+            event = Event.from_event_name(event_name)
+            footprint.add_value(copy(val), seen_at, event)
 
         # if something fails, don't track
         except:
@@ -118,43 +120,43 @@ def create_collector(
 
                         try:
                             val = eval(t, frame.f_globals, frame.f_locals)
-                            track_value(val)
+                            track_value(val, event)
                         except:
                             pass
 
                     else:
                         if t in frame.f_locals:
                             val = frame.f_locals[t]
-                            track_value(val)
+                            track_value(val, event)
                                 
                         elif t in frame.f_globals:
                             val = frame.f_globals[t]
-                            track_value(val)
+                            track_value(val, event)
                 
                 # for tracking the results of an assignment statement
                 m = re.match(r"^\s*(\w+)(\[[^\]]\]|(\.\w+)+)*\s=.*", line)
                 if m:
                     if name not in vars_not_found:
                         vars_not_found[name] = []
-                    vars_not_found[name].append((m.group(1), footprint.counter.get_value()))
+                    vars_not_found[name].append((m.group(1), event, footprint.counter.get_value()))
 
             if event == "return":
-                track_value(arg)
+                track_value(arg, event)
 
         elif (is_ipython_frame(frame) or frame.f_back.f_code.co_filename in addl_filenames) and \
                 event == "return":
-            track_value(arg)
+            track_value(arg, event)
 
         if event == "return" and name in vars_not_found:
             varnames = vars_not_found.pop(name)
-            for t, step in varnames:
+            for t, event_name, step in varnames:
                 if t in frame.f_locals:
                     val = frame.f_locals[t]
-                    track_value(val, step)
+                    track_value(val, event_name, step)
 
                 elif t in frame.f_globals:
                     val = frame.f_globals[t]
-                    track_value(val, step)
+                    track_value(val, event_name, step)
 
         return collect_intermidiate_results
 
diff --git a/pybryt/plagiarism.py b/pybryt/plagiarism.py
index 72b503e..73574e4 100644
--- a/pybryt/plagiarism.py
+++ b/pybryt/plagiarism.py
@@ -16,6 +16,7 @@ def create_references(student_impls: List["StudentImplementation"], frac=0.25, s
     if seed is not None:
         random.seed(seed)
 
+    # TODO: this doesn't work with the new MemoryFootprint API
     values = [[t[0] for t in stu.values if t[0] is not None] for stu in student_impls]
 
     k = int(min([len(v) for v in values]) * frac)
diff --git a/pybryt/utils.py b/pybryt/utils.py
index 854352e..9fc6655 100644
--- a/pybryt/utils.py
+++ b/pybryt/utils.py
@@ -16,7 +16,7 @@ from IPython import get_ipython
 from IPython.display import publish_display_data
 
 
-class UnpicklableError(Exception):
+class UnpickleableError(Exception):
     """
     An exception to raise when :py:func:`pybryt.utils.pickle_and_hash` fails.
     """
@@ -33,17 +33,17 @@ def pickle_and_hash(obj: Any) -> str:
         ``str``: the hex digest of the SHA-512 hash of the pickled object
 
     Raises:
-        :py:class:`pybryt.utils.UnpicklableError`: if the object cannot be pickled
+        :py:class:`pybryt.utils.UnpickleableError`: if the object cannot be pickled
     """
     try:
         s = dill.dumps(obj)
     except:
-        raise UnpicklableError()
+        raise UnpickleableError()
 
     return hashlib.sha512(s).hexdigest()
 
 
-def filter_picklable_list(lst: List[Any]) -> None:
+def filter_pickleable_list(lst: List[Any]) -> None:
     """
     Removes all elements from a list that cannot be pickled with ``dill``.
 


Test patch:
diff --git a/tests/annotations/test_annotation.py b/tests/annotations/test_annotation.py
index 6653d56..ef12125 100644
--- a/tests/annotations/test_annotation.py
+++ b/tests/annotations/test_annotation.py
@@ -27,8 +27,8 @@ def test_name_group_limit():
         "group": None,
     })
 
-    v1 = pybryt.Value(footprint.get_value(0), group="bar")
-    v2 = pybryt.Value(footprint.get_value(1), group="bar")
+    v1 = pybryt.Value(footprint.get_value(0).value, group="bar")
+    v2 = pybryt.Value(footprint.get_value(1).value, group="bar")
     assert_object_attrs(v1, {"group": "bar"})
     assert_object_attrs(v2, {"group": "bar"})
 
@@ -72,8 +72,8 @@ def test_messages():
     footprint = generate_memory_footprint()
     pybryt.Annotation.reset_tracked_annotations()
 
-    val1 = footprint.get_value(0)
-    val2 = footprint.get_value(1)
+    val1 = footprint.get_value(0).value
+    val2 = footprint.get_value(1).value
 
     v1 = pybryt.Value(val1, success_message="m1", failure_message="m2")
     v2 = pybryt.Value(val2)
diff --git a/tests/annotations/test_collection.py b/tests/annotations/test_collection.py
index f86de8d..a5b70f3 100644
--- a/tests/annotations/test_collection.py
+++ b/tests/annotations/test_collection.py
@@ -13,7 +13,7 @@ def test_collection():
     footprint = generate_memory_footprint()
     pybryt.Annotation.reset_tracked_annotations()
 
-    v1, v2 = pybryt.Value(footprint.get_value(0)), pybryt.Value(footprint.get_value(2))
+    v1, v2 = pybryt.Value(footprint.get_value(0).value), pybryt.Value(footprint.get_value(2).value)
     a = pybryt.Collection(v1, v2, success_message="foo")
 
     res = a.check(footprint)
@@ -42,7 +42,7 @@ def test_collection():
     res = a.check(footprint)
     assert res.satisfied
 
-    v3 = pybryt.Value(footprint.get_value(1))
+    v3 = pybryt.Value(footprint.get_value(1).value)
     a.add(v3)
     res = a.check(footprint)
     assert not res.satisfied
diff --git a/tests/annotations/test_complexity.py b/tests/annotations/test_complexity.py
index 8b75080..0a99344 100644
--- a/tests/annotations/test_complexity.py
+++ b/tests/annotations/test_complexity.py
@@ -8,6 +8,8 @@ import pytest
 import pybryt
 import pybryt.complexities as cplx
 
+from pybryt.execution import MemoryFootprintValue
+
 from .utils import assert_object_attrs
 
 
@@ -16,7 +18,7 @@ def generate_complexity_footprint(name, t_transform, max_exp=8):
     for i, e in enumerate(range(1, max_exp + 1)):
         n = 10 ** e
         t = t_transform(n)
-        values += [pybryt.TimeComplexityResult(name, n, 0, t), i]
+        values.append(MemoryFootprintValue(pybryt.TimeComplexityResult(name, n, 0, t), i, None))
     return pybryt.MemoryFootprint.from_values(*values)
 
 
diff --git a/tests/annotations/test_relation.py b/tests/annotations/test_relation.py
index 0a1583f..4c188c8 100644
--- a/tests/annotations/test_relation.py
+++ b/tests/annotations/test_relation.py
@@ -13,8 +13,8 @@ def test_before_annotation():
     footprint = generate_memory_footprint()
     pybryt.Annotation.reset_tracked_annotations()
 
-    val1 = footprint.get_value(0)
-    val2, ts2 = footprint.get_value(1), footprint.get_timestamp(1)
+    val1 = footprint.get_value(0).value
+    val2, ts2 = footprint.get_value(1).value, footprint.get_value(1).timestamp
 
     v1 = pybryt.Value(val1)
     v2 = pybryt.Value(val2)
@@ -102,8 +102,8 @@ def test_and_annotation():
     """
     footprint = generate_memory_footprint()
 
-    val1 = footprint.get_value(0)
-    val2, ts2 = footprint.get_value(1), footprint.get_timestamp(1)
+    val1 = footprint.get_value(0).value
+    val2, ts2 = footprint.get_value(1).value, footprint.get_value(1).timestamp
 
     v1 = pybryt.Value(val1)
     v2 = pybryt.Value(val2)
@@ -168,8 +168,8 @@ def test_or_annotation():
     """
     footprint = generate_memory_footprint()
 
-    val1, ts1 = footprint.get_value(0), footprint.get_timestamp(0)
-    val2, ts2 = footprint.get_value(1), footprint.get_timestamp(1)
+    val1, ts1 = footprint.get_value(0).value, footprint.get_value(0).timestamp
+    val2, ts2 = footprint.get_value(1).value, footprint.get_value(1).timestamp
 
     v1 = pybryt.Value(val1)
     v2 = pybryt.Value(val2)
@@ -234,8 +234,8 @@ def test_xor_annotation():
     """
     footprint = generate_memory_footprint()
 
-    val1, ts1 = footprint.get_value(0), footprint.get_timestamp(0)
-    val2, ts2 = footprint.get_value(1), footprint.get_timestamp(1)
+    val1, ts1 = footprint.get_value(0).value, footprint.get_value(0).timestamp
+    val2, ts2 = footprint.get_value(1).value, footprint.get_value(1).timestamp
 
     v1 = pybryt.Value(val1)
     v2 = pybryt.Value(val2)
@@ -300,7 +300,7 @@ def test_not_annotation():
     """
     footprint = generate_memory_footprint()
 
-    val1 = footprint.get_value(0)
+    val1 = footprint.get_value(0).value
 
     v1 = pybryt.Value(val1)
     v = ~v1
diff --git a/tests/annotations/test_value.py b/tests/annotations/test_value.py
index c961fab..0e2618d 100644
--- a/tests/annotations/test_value.py
+++ b/tests/annotations/test_value.py
@@ -6,7 +6,7 @@ from itertools import chain
 from unittest import mock
 
 import pybryt
-from pybryt.execution.memory_footprint import MemoryFootprint
+from pybryt.execution.memory_footprint import Event, MemoryFootprint, MemoryFootprintValue
 
 from .utils import assert_object_attrs, generate_memory_footprint
 
@@ -18,13 +18,13 @@ def test_value_annotation():
     pybryt.Annotation.reset_tracked_annotations()
 
     seen = {}
-    for val, ts in footprint.values:
-        v = pybryt.Value(val)
+    for mfp_val in footprint:
+        v = pybryt.Value(mfp_val.value)
         res = v.check(footprint)
 
         assert repr(res) == "AnnotationResult(satisfied=True, annotation=pybryt.Value)"
 
-        h = pybryt.utils.pickle_and_hash(val)
+        h = pybryt.utils.pickle_and_hash(mfp_val.value)
 
         # check attributes of BeforeAnnotation and AnnotationResult
         assert_object_attrs(v, {"children__len": 0})
@@ -33,12 +33,12 @@ def test_value_annotation():
             "satisfied": True,
             "_satisfied": True,
             "annotation": v,
-            "timestamp": seen[h] if h in seen else ts,
-            "value": val,
+            "timestamp": seen[h] if h in seen else mfp_val.timestamp,
+            "value": mfp_val.value,
         })
 
         if h not in seen:
-            seen[h] = ts
+            seen[h] = mfp_val.timestamp
 
     v = pybryt.Value(-1)  # does not occur in footprint
     res = v.check(footprint)
@@ -78,7 +78,7 @@ def test_value_annotation():
             v = pybryt.Value(-1)
 
     # test with invariants
-    s = footprint.get_value(-1)
+    s = footprint.get_value(-1).value
     v = pybryt.Value(s.upper(), invariants=[pybryt.invariants.string_capitalization])
     res = v.check(footprint)
     assert res.satisfied
@@ -88,7 +88,7 @@ def test_value_annotation():
         mocked_check.return_value = mock.MagicMock()
         mocked_check.return_value.satisfied = True
         assert v.check_against(s.lower())
-        mocked_check.assert_called_with(MemoryFootprint.from_values(s.lower(), 0))
+        mocked_check.assert_called_with(MemoryFootprint.from_values(MemoryFootprintValue(s.lower(), 0, None)))
 
     # check custom equivalence function
     mocked_eq = mock.MagicMock()
@@ -132,7 +132,7 @@ def test_attribute_annotation():
     """
     footprint = generate_memory_footprint()
     pybryt.Annotation.reset_tracked_annotations()
-    val, ts = footprint.get_value(0), footprint.get_timestamp(0)
+    val, ts = footprint.get_value(0).value, footprint.get_value(0).timestamp
 
     v = pybryt.Attribute(val, "T")
     res = v.check(footprint)
@@ -183,13 +183,13 @@ def test_attribute_annotation():
         mocked_check.return_value = mock.MagicMock()
         mocked_check.return_value.satisfied = False
         assert not v.check_against(val)
-        mocked_check.assert_called_with(MemoryFootprint.from_values(val, 0))
+        mocked_check.assert_called_with(MemoryFootprint.from_values(MemoryFootprintValue(val, 0, None)))
 
     # check enforce type
     class Foo:
         T = val.T
 
-    footprint2 = pybryt.MemoryFootprint.from_values(Foo(), 1)
+    footprint2 = pybryt.MemoryFootprint.from_values(MemoryFootprintValue(Foo(), 1, None))
     res = v.check(footprint2)
     assert res.satisfied
 
@@ -197,7 +197,7 @@ def test_attribute_annotation():
     res = v.check(footprint2)
     assert not res.satisfied
 
-    footprint3 = MemoryFootprint.from_values(*chain.from_iterable(footprint.values + footprint2.values))
+    footprint3 = MemoryFootprint.from_values(*chain(footprint, footprint2))
     res = v.check(footprint3)
     assert res.satisfied
 
@@ -209,4 +209,55 @@ def test_attribute_annotation():
         pybryt.Attribute(val, 1)
 
     with pytest.raises(AttributeError):
-        pybryt.Attribute(val, "foo")
\ No newline at end of file
+        pybryt.Attribute(val, "foo")
+
+
+def test_return_value():
+    """
+    Tests for the ``ReturnValue`` annotation.
+    """
+    rv = pybryt.ReturnValue(1)
+    footprint = MemoryFootprint.from_values(
+        MemoryFootprintValue(1, 0, Event.RETURN),
+        MemoryFootprintValue(2, 1, Event.LINE_AND_RETURN),
+    )
+
+    res = rv.check(footprint)
+    assert_object_attrs(res, {
+        "children": [],
+        "satisfied": True,
+        "_satisfied": True,
+        "annotation": rv,
+        "timestamp": 0,
+        "value": 1,
+    })
+
+    footprint = MemoryFootprint.from_values(
+        MemoryFootprintValue(1, 0, Event.LINE_AND_RETURN),
+        MemoryFootprintValue(2, 1, Event.RETURN),
+    )
+
+    res = rv.check(footprint)
+    assert_object_attrs(res, {
+        "children": [],
+        "satisfied": True,
+        "_satisfied": True,
+        "annotation": rv,
+        "timestamp": 0,
+        "value": 1,
+    })
+
+    footprint = MemoryFootprint.from_values(
+        MemoryFootprintValue(1, 0, Event.LINE),
+        MemoryFootprintValue(2, 1, Event.RETURN),
+    )
+
+    res = rv.check(footprint)
+    assert_object_attrs(res, {
+        "children": [],
+        "satisfied": False,
+        "_satisfied": False,
+        "annotation": rv,
+        "timestamp": -1,
+        "value": None,
+    })
diff --git a/tests/annotations/utils.py b/tests/annotations/utils.py
index 86bfae8..b4fb135 100644
--- a/tests/annotations/utils.py
+++ b/tests/annotations/utils.py
@@ -5,7 +5,7 @@ import numpy as np
 from functools import lru_cache
 from itertools import chain
 
-from pybryt.execution import MemoryFootprint
+from pybryt.execution import MemoryFootprint, MemoryFootprintValue
 
 
 def assert_object_attrs(obj, attrs):
@@ -44,4 +44,4 @@ def generate_memory_footprint() -> MemoryFootprint:
         4.0,
         "some CasE insenSITIve StrINg!",
     ]
-    return MemoryFootprint.from_values(*chain.from_iterable((o, i) for i, o in enumerate(objs)))
+    return MemoryFootprint.from_values(*[MemoryFootprintValue(o, i, None) for i, o in enumerate(objs)])
diff --git a/tests/execution/test_complexity.py b/tests/execution/test_complexity.py
index f96a5ae..fbdc116 100644
--- a/tests/execution/test_complexity.py
+++ b/tests/execution/test_complexity.py
@@ -23,10 +23,10 @@ def test_time_complexity():
         with pybryt.check_time_complexity("foo", n):
             do_work(n, lambda v: v)
 
-    assert len(footprint.values) == 8
-    assert all(isinstance(t[0], pybryt.TimeComplexityResult) for t in footprint.values)
+    assert len(footprint) == 8
+    assert all(isinstance(mfp_val.value, pybryt.TimeComplexityResult) for mfp_val in footprint)
 
-    footprint.values.clear()
+    footprint.clear()
 
     for e in range(1, 9):
         n = float(10 ** e)
@@ -37,9 +37,9 @@ def test_time_complexity():
         with pybryt.check_time_complexity("bar", n):
             do_work(n, len)
 
-    assert len(footprint.values) == 16
-    assert all(isinstance(t[0], pybryt.TimeComplexityResult) for t in footprint.values)
-    assert [t[0].name for t in footprint.values] == ["foo", "bar"] * 8
+    assert len(footprint) == 16
+    assert all(isinstance(mfp_val.value, pybryt.TimeComplexityResult) for mfp_val in footprint)
+    assert [mfp_val.value.name for mfp_val in footprint] == ["foo", "bar"] * 8
 
     # test error
     n = lambda v: v
@@ -51,4 +51,4 @@ def test_time_complexity():
     frame = generate_mocked_frame("<ipython-abc-123>", "bar", 1)
     with pybryt.check_time_complexity("foo", 10):
         cir(frame, "return", 10)
-        assert len(footprint.values) == 16
+        assert len(footprint) == 16
diff --git a/tests/execution/test_memory_footprint.py b/tests/execution/test_memory_footprint.py
index b68099a..bc526e8 100644
--- a/tests/execution/test_memory_footprint.py
+++ b/tests/execution/test_memory_footprint.py
@@ -7,17 +7,24 @@ from copy import deepcopy
 from itertools import chain
 from unittest import mock
 
-from pybryt.execution.memory_footprint import Counter, MemoryFootprint
-
-from ..annotations.utils import generate_memory_footprint
+from pybryt.execution.memory_footprint import (
+    Counter, Event, MemoryFootprint, MemoryFootprintIterator, MemoryFootprintValue)
 
 
 def generate_values():
-    return [(o, i + 1) for i, o in enumerate([10, "", True, None, 10039, 1e-4])]
+    """
+    Generate a list of memory footprint values for use in constructing memory footprints.
+
+    Returns:
+        ``list[MemoryFootprintValue]``: the values
+    """
+    return [MemoryFootprintValue(o, i + 1, None) for i, o in \
+        enumerate([10, "", True, None, 10039, 1e-4])]
 
 
 def test_counter():
     """
+    Test the ``pybryt.execution.memory_footprint.Counter`` class.
     """
     counter = Counter()
     assert counter.get_value() == 0
@@ -29,9 +36,12 @@ def test_counter():
 
 
 def test_memory_footprint_constructor():
+    """
+    Test the constructor for ``pybryt.execution.memory_footprint.MemoryFootprint``.
+    """
     footprint = MemoryFootprint()
     assert footprint.counter is not None
-    assert len(footprint.values) == 0
+    assert len(footprint) == 0
     assert len(footprint.calls) == 0
     assert len(footprint.imports) == 0
 
@@ -39,40 +49,46 @@ def test_memory_footprint_constructor():
     counter.offset(50)
     footprint = MemoryFootprint(counter)
     assert footprint.counter is counter
-    assert len(footprint.values) == 0
+    assert len(footprint) == 0
     assert len(footprint.calls) == 0
     assert len(footprint.imports) == 0
 
 
 def test_from_values():
+    """
+    Test the ``pybryt.execution.memory_footprint.MemoryFootprint.from_values`` method.
+    """
     vals = generate_values()
-    footprint = MemoryFootprint.from_values(*chain.from_iterable(vals))
-    assert footprint.values == vals
+    footprint = MemoryFootprint.from_values(*vals)
+    assert list(footprint) == vals
     assert footprint.num_steps == len(vals)
     assert footprint.counter.get_value() == len(vals)
 
     # check errors
-    with pytest.raises(ValueError):
-        MemoryFootprint.from_values(1, 1, 1)
-
     with pytest.raises(TypeError):
-        MemoryFootprint.from_values([1], 1, True, 2.0)
+        MemoryFootprint.from_values(MemoryFootprintValue(1, 1, None), 2)
 
 
 def test_combine():
+    """
+    Test the ``pybryt.execution.memory_footprint.MemoryFootprint.combine`` method.
+    """
     vals = generate_values()
     v1, v2 = vals[:3], vals[3:]
     v2.extend(v1)
 
-    f1, f2 = MemoryFootprint.from_values(*chain.from_iterable(v1)), MemoryFootprint.from_values(*chain.from_iterable(v2))
+    f1, f2 = MemoryFootprint.from_values(*v1), MemoryFootprint.from_values(*v2)
     footprint = MemoryFootprint.combine(f1, f2)
 
-    sorted_fp_vals = sorted(footprint.values, key=lambda t: t[1])
-    assert list(map(lambda t: t[0], sorted_fp_vals)) == list(map(lambda t: t[0], vals))
+    sorted_fp_vals = sorted(list(footprint), key=lambda mfp_val: mfp_val.timestamp)
+    assert list(map(lambda mfp_val: mfp_val.value, sorted_fp_vals)) == list(map(lambda mfp_val: mfp_val.value, vals))
     assert footprint.counter.get_value() == len(v1) + len(v2)
 
 
 def test_counter_manipulation():
+    """
+    Test counter manipulations for the ``MemoryFootprint`` class.
+    """
     footprint = MemoryFootprint()
     footprint.increment_counter()
     assert footprint.counter.get_value() == 1
@@ -81,31 +97,54 @@ def test_counter_manipulation():
 
 
 def test_values():
+    """
+    Test value updates and accesses for the ``MemoryFootprint`` class.
+    """
     footprint = MemoryFootprint()
     footprint.add_value(1)
-    assert len(footprint.values) == 1
-    assert footprint.get_value(0) == 1
-    assert footprint.get_timestamp(0) == 0
+    assert len(footprint) == 1
+    assert footprint.get_value(0).value == 1
+    assert footprint.get_value(0).timestamp == 0
 
     footprint.increment_counter()
     footprint.add_value(2)
-    assert len(footprint.values) == 2
-    assert footprint.get_value(1) == 2
-    assert footprint.get_timestamp(1) == 1
+    assert len(footprint) == 2
+    assert footprint.get_value(1).value == 2
+    assert footprint.get_value(1).timestamp == 1
+    assert footprint.get_value(1).event is None
 
     footprint.add_value(3, 10)
-    assert len(footprint.values) == 3
-    assert footprint.get_value(2) == 3
-    assert footprint.get_timestamp(2) == 10
+    assert len(footprint) == 3
+    assert footprint.get_value(2).value == 3
+    assert footprint.get_value(2).timestamp == 10
+
+    footprint.increment_counter()
+    footprint.add_value(2, event=Event.LINE)
+    assert len(footprint) == 3
+    assert footprint.get_value(1).event == Event.LINE
+
+    footprint.add_value(2)
+    assert len(footprint) == 3
+    assert footprint.get_value(1).event == Event.LINE
+
+    footprint.add_value(2, event=Event.RETURN)
+    assert len(footprint) == 3
+    assert footprint.get_value(1).event == Event.LINE_AND_RETURN
 
 
 def test_calls():
+    """
+    Test call tracking for the ``MemoryFootprint`` class.
+    """
     footprint = MemoryFootprint()
     footprint.add_call("foo", "bar")
     assert footprint.calls == [("foo", "bar")]
 
 
 def test_imports():
+    """
+    Test import tracking for the ``MemoryFootprint`` class.
+    """
     footprint = MemoryFootprint()
     imports = {"a", "b", "c", "d"}
     footprint.add_imports(*imports)
@@ -117,6 +156,9 @@ def test_imports():
 
 
 def test_set_executed_notebook():
+    """
+    Test executed notebook tracking for the ``MemoryFootprint`` class.
+    """
     footprint = MemoryFootprint()
     assert footprint.executed_notebook == None
 
@@ -125,19 +167,43 @@ def test_set_executed_notebook():
     assert footprint.executed_notebook is nb
 
 
-def test_filter_out_unpicklable_values():
+def test_filter_out_unpickleable_values():
+    """
+    Test pickleable value filtering tracking for the ``MemoryFootprint`` class.
+    """
     vals = generate_values()
-    footprint = MemoryFootprint.from_values(*chain.from_iterable(vals))
+    footprint = MemoryFootprint.from_values(*vals)
 
-    with mock.patch("pybryt.execution.memory_footprint.filter_picklable_list") as mocked_filter:
-        footprint.filter_out_unpicklable_values()
+    with mock.patch("pybryt.execution.memory_footprint.filter_pickleable_list") as mocked_filter:
+        footprint.filter_out_unpickleable_values()
         mocked_filter.assert_called_once_with(footprint.values)
 
 
 def test_eq():
+    """
+    Test ``MemoryFootprint`` equals comparisons.
+    """
     vals = generate_values()
-    f1, f2 = MemoryFootprint.from_values(*chain.from_iterable(vals)), MemoryFootprint()
+    f1, f2 = MemoryFootprint.from_values(*vals), MemoryFootprint()
     f3 = deepcopy(f1)
 
     assert f1 != f2
     assert f1 == f3
+
+
+def test_misc_dunder_methods():
+    """
+    Tests for misc. dunder methods of ``MemoryFootprint``.
+    """
+    vals = generate_values()
+    fp = MemoryFootprint.from_values(*vals)
+
+    assert len(fp) == len(vals)
+
+    fpi = iter(fp)
+    assert isinstance(fpi, MemoryFootprintIterator)
+    for e, a in zip(vals, fp):
+        assert isinstance(a, MemoryFootprintValue)
+        assert e == a
+
+
diff --git a/tests/execution/test_notebook_execution.py b/tests/execution/test_notebook_execution.py
index 85a2ca0..e64b941 100644
--- a/tests/execution/test_notebook_execution.py
+++ b/tests/execution/test_notebook_execution.py
@@ -37,6 +37,6 @@ def test_notebook_execution():
             mocked_tempfile.return_value = (None, observed_ntf.name)
 
             footprint = pybryt.execution.execute_notebook(nb, "")
-            assert len(footprint.values) > 0
+            assert len(footprint) > 0
             assert all(i in footprint.imports for i in ["pandas", "numpy", "matplotlib"])
             assert len(footprint.calls) > 0
diff --git a/tests/execution/test_tracing.py b/tests/execution/test_tracing.py
index 15bcdea..9af6d51 100644
--- a/tests/execution/test_tracing.py
+++ b/tests/execution/test_tracing.py
@@ -5,8 +5,9 @@ import sys
 
 from unittest import mock
 
-from pybryt import *
+from pybryt import MemoryFootprint, no_tracing
 from pybryt.execution import create_collector, FrameTracer, tracing_off, tracing_on
+from pybryt.execution.memory_footprint import Event
 
 from .utils import generate_mocked_frame
 
@@ -26,20 +27,21 @@ def test_trace_function():
 
     arr = np.random.uniform(-100, 100, size=(100, 100))
     cir(frame, "return", arr)
-    assert len(footprint.values) == 1
-    assert np.allclose(footprint.values[0][0], arr)
-    assert footprint.values[0][1] == 1
+    assert len(footprint) == 1
+    assert np.allclose(footprint.get_value(0).value, arr)
+    assert footprint.get_value(0).timestamp == 1
+    assert footprint.get_value(0).event == Event.RETURN
 
     # test value in skip_types
     cir(frame, "return", type(1))
-    assert len(footprint.values) == 1
+    assert len(footprint) == 1
 
     # test pickling error
     with mock.patch("dill.dumps") as mocked_dumps:
         mocked_dumps.side_effect = Exception()
         cir(frame, "return", 1)
     
-    assert len(footprint.values) == 1
+    assert len(footprint) == 1
 
     # test call event
     assert len(footprint.calls) == 0
@@ -57,55 +59,56 @@ def test_trace_function():
         # check eval call for attributes
         mocked_linecache.return_value = "data.T"
         cir(frame, "line", None)
-        assert len(footprint.values) == 2
-        assert np.allclose(footprint.values[1][0], arr.T)
-        assert footprint.values[1][1] == 5
+        assert len(footprint) == 2
+        assert np.allclose(footprint.get_value(1).value, arr.T)
+        assert footprint.get_value(1).timestamp == 5
+        assert footprint.get_value(1).event == Event.LINE
 
         # check failed eval call for attributes
         mocked_linecache.return_value = "data.doesnt_exist"
         cir(frame, "line", None)
-        assert len(footprint.values) == 2
+        assert len(footprint) == 2
 
         # check looking in frame locals + globals
         frame.f_globals["more_data"] = np.random.uniform(-100, 100, size=(100, 100))
         frame.f_locals["more_data"] = np.random.uniform(-100, 100, size=(100, 100))
         mocked_linecache.return_value = "more_data"
         cir(frame, "line", None)
-        assert len(footprint.values) == 3
-        assert np.allclose(footprint.values[2][0], frame.f_locals["more_data"])
-        assert footprint.values[2][1] == 7
+        assert len(footprint) == 3
+        assert np.allclose(footprint.get_value(2).value, frame.f_locals["more_data"])
+        assert footprint.get_value(2).timestamp == 7
 
         # check that we track assignment statements on function return
         mocked_linecache.return_value = "even_more_data = more_data ** 2"
         cir(frame, "line", None)
-        assert len(footprint.values) == 3
+        assert len(footprint) == 3
 
         mocked_linecache.return_value = "even_more_data_2 = more_data ** 3"
         cir(frame, "line", None)
-        assert len(footprint.values) == 3
+        assert len(footprint) == 3
 
         # check that floats aren't added with the eval call
         mocked_linecache.return_value = "event_more_data_3 = [2.1, 1000, 100.3]"
         cir(frame, "line", None)
-        assert len(footprint.values) == 3
+        assert len(footprint) == 3
 
         frame.f_locals["even_more_data"] = frame.f_locals["more_data"] ** 2
         frame.f_globals["even_more_data_2"] = frame.f_locals["more_data"] ** 3
         mocked_linecache.return_value = ""
         cir(frame, "return", None)
-        assert len(footprint.values) == 6
-        assert footprint.values[3][0] is None
-        assert footprint.values[3][1] == 11
-        assert np.allclose(footprint.values[4][0], frame.f_locals["more_data"] ** 2)
-        assert footprint.values[4][1] == 8
-        assert np.allclose(footprint.values[5][0], frame.f_locals["more_data"] ** 3)
-        assert footprint.values[5][1] == 9
+        assert len(footprint) == 6
+        assert footprint.get_value(3).value is None
+        assert footprint.get_value(3).timestamp == 11
+        assert np.allclose(footprint.get_value(4).value, frame.f_locals["more_data"] ** 2)
+        assert footprint.get_value(4).timestamp == 8
+        assert np.allclose(footprint.get_value(5).value, frame.f_locals["more_data"] ** 3)
+        assert footprint.get_value(5).timestamp == 9
 
         # check that skip_types respected
         frame.f_locals["none_type"] = type(None)
         mocked_linecache.return_vaue = "none_type"
         cir(frame, "line", None)
-        assert len(footprint.values) == 6
+        assert len(footprint) == 6
 
         # check that addl_filenames respected
         frame = generate_mocked_frame(tracked_filepath, "bar", 100, f_back=frame)
@@ -114,19 +117,19 @@ def test_trace_function():
         cir(frame, "line", None)
         frame.f_locals["arr"] = -1 * arr
         cir(frame, "return", None) # run a return since arr shows up in vars_not_found
-        assert len(footprint.values) == 7
-        assert np.allclose(footprint.values[6][0], -1 * arr)
-        assert footprint.values[6][1] == 14
+        assert len(footprint) == 7
+        assert np.allclose(footprint.get_value(6).value, -1 * arr)
+        assert footprint.get_value(6).timestamp == 14
     
     # check that IPython child frame return values are tracked
     frame = generate_mocked_frame("/path/to/file.py", "bar", 100, f_back=frame)
     cir(frame, "line", None)
-    assert len(footprint.values) == 7
+    assert len(footprint) == 7
 
     cir(frame, "return", np.exp(arr))
-    assert len(footprint.values) == 8
-    assert np.allclose(footprint.values[7][0], np.exp(arr))
-    assert footprint.values[7][1] == 14
+    assert len(footprint) == 8
+    assert np.allclose(footprint.get_value(7).value, np.exp(arr))
+    assert footprint.get_value(7).timestamp == 14
 
     assert len(footprint.calls) == 1
     frame = generate_mocked_frame("/path/to/foo.py", "bar", 100)
@@ -141,9 +144,9 @@ def test_trace_function():
 
     arr = np.random.uniform(-100, 100, size=(100, 100))
     cir(frame, "return", arr)
-    assert len(footprint.values) == 1
-    assert np.allclose(footprint.values[0][0], arr)
-    assert footprint.values[0][1] == 1
+    assert len(footprint) == 1
+    assert np.allclose(footprint.get_value(0).value, arr)
+    assert footprint.get_value(0).timestamp == 1
 
     # check that imported modules are correctly tracked
     with mock.patch.object(footprint, "add_imports") as mocked_add_imports:
diff --git a/tests/test_complexity.py b/tests/test_complexity.py
index 6e0dd69..3b11a6e 100644
--- a/tests/test_complexity.py
+++ b/tests/test_complexity.py
@@ -4,7 +4,7 @@ from unittest import mock
 
 from pybryt import complexities as cplx
 from pybryt.complexity import ANNOTATION_NAME, _check_time_complexity_wrapper, TimeComplexityChecker
-from pybryt.execution import TimeComplexityResult
+from pybryt.execution import MemoryFootprintValue, TimeComplexityResult
 
 
 def test_time_complexity_checker():
@@ -40,8 +40,8 @@ def test_wrapper():
             mock.patch("pybryt.complexity.FrameTracer") as mocked_tracer, \
             mock.patch("pybryt.complexity.inspect") as mocked_inspect, \
             mock.patch("pybryt.complexity.check_time_complexity") as mocked_cm:
-        mocked_tracer.return_value.get_footprint.return_value.values = \
-            [(res, 0)]
+        mocked_tracer.return_value.get_footprint.return_value.__iter__ = \
+            mock.Mock(return_value=iter([MemoryFootprintValue(res, 0, None)]))
         wrapper = _check_time_complexity_wrapper(checker, n)
         with wrapper:
             mocked_tracer.assert_called_once_with(mocked_inspect.currentframe.return_value.f_back)
diff --git a/tests/test_reference.py b/tests/test_reference.py
index d8745cc..558339a 100644
--- a/tests/test_reference.py
+++ b/tests/test_reference.py
@@ -13,7 +13,7 @@ from copy import deepcopy
 from textwrap import dedent
 
 from pybryt import generate_report, MemoryFootprint, ReferenceImplementation, Value
-from pybryt.execution import execute_notebook
+from pybryt.execution import execute_notebook, MemoryFootprintValue
 
 
 def generate_footprint(nb) -> MemoryFootprint:
@@ -245,21 +245,22 @@ def test_run_and_results():
 
     # check message filtering (#145)
     ref = ReferenceImplementation("foo", [
+        Value(0, name="1", success_message="sm1"),
         Value(1, name="1", success_message="sm1"),
-        Value(2, name="1", success_message="sm1"),
+        Value(2, name="2", failure_message="fm2"),
         Value(3, name="2", failure_message="fm2"),
-        Value(4, name="2", failure_message="fm2"),
+        Value(4, name="3", success_message="sm3", failure_message="fm3"),
         Value(5, name="3", success_message="sm3", failure_message="fm3"),
-        Value(6, name="3", success_message="sm3", failure_message="fm3"),
     ])
 
-    res = ref.run(MemoryFootprint.from_values(1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6))
+    vals = [MemoryFootprintValue(i, i, None) for i in range(6)]
+    res = ref.run(MemoryFootprint.from_values(*vals))
     assert res.messages == ["sm1", "sm3"]
 
-    res = ref.run(MemoryFootprint.from_values(1, 1, 3, 3, 4, 4))
+    res = ref.run(MemoryFootprint.from_values(vals[0], vals[2], vals[3]))
     assert res.messages == ["fm3"]
 
-    res = ref.run(MemoryFootprint.from_values(1, 1, 2, 2, 5, 5))
+    res = ref.run(MemoryFootprint.from_values(vals[0], vals[1], vals[4]))
     assert res.messages == ["sm1", "fm2", "fm3"]
 
 
@@ -345,7 +346,7 @@ def test_generate_report():
     """).strip()
 
     # check empty messages
-    ref = ReferenceImplementation("foo", [Value(footprint.get_value(0))])
+    ref = ReferenceImplementation("foo", [Value(footprint.get_value(0).value)])
     res = ref.run(footprint)
     report = generate_report(res)
     assert report == dedent("""\
diff --git a/tests/test_student.py b/tests/test_student.py
index bf904e0..9efdbc7 100644
--- a/tests/test_student.py
+++ b/tests/test_student.py
@@ -64,7 +64,7 @@ def test_constructor():
     nb, stu = generate_impl()
     assert stu.nb is nb
     assert isinstance(stu.footprint, MemoryFootprint)
-    assert len(stu.footprint.values) == 993
+    assert len(stu.footprint) == 993
 
     with mock.patch("pybryt.student.execute_notebook") as mocked_exec:
         mocked_exec.return_value = MemoryFootprint()
@@ -75,7 +75,7 @@ def test_constructor():
 
             stu = StudentImplementation(ntf.name)
             assert stu.footprint.num_steps == -1
-            assert stu.footprint.values == []
+            assert list(stu.footprint) == []
             assert stu.footprint.calls == []
             assert stu.nb == nb
 
@@ -95,12 +95,12 @@ def test_load_and_dump():
         stu.dump(ntf.name)
         stu2 = StudentImplementation.load(ntf.name)
 
-        assert len(stu.footprint.values) == len(stu2.footprint.values)
+        assert len(stu.footprint) == len(stu2.footprint)
         assert stu.footprint.num_steps == stu2.footprint.num_steps
 
     enc_stu = stu.dumps()
     stu2 = StudentImplementation.loads(enc_stu)
-    assert len(stu.footprint.values) == len(stu2.footprint.values)
+    assert len(stu.footprint) == len(stu2.footprint)
     assert stu.footprint.num_steps == stu2.footprint.num_steps
 
 
@@ -246,9 +246,9 @@ def test_combine():
     stu2.footprint.add_value([1, 2, 3, 4], stu2.footprint.num_steps + 1)
 
     comb = StudentImplementation.combine([stu, stu2])
-    assert len(comb.footprint.values) == len(stu.footprint.values) + 1
+    assert len(comb.footprint) == len(stu.footprint) + 1
     assert comb.footprint.num_steps == stu.footprint.num_steps + stu2.footprint.num_steps
-    assert comb.footprint.get_timestamp(-1) == stu.footprint.num_steps + stu2.footprint.num_steps
+    assert comb.footprint.get_value(-1).timestamp == stu.footprint.num_steps + stu2.footprint.num_steps
 
 
 def test_generate_student_impls():
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 6dc7aa0..4725625 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -12,16 +12,16 @@ from pybryt.utils import *
 from .test_reference import generate_reference_notebook
 
 
-def test_filter_picklable_list():
+def test_filter_pickleable_list():
     """
     """
     l = [1, 2, 3]
-    filter_picklable_list(l)
+    filter_pickleable_list(l)
     assert len(l) == 3
 
     with mock.patch("dill.dumps") as mocked_dill:
         mocked_dill.side_effect = Exception()
-        filter_picklable_list(l)
+        filter_pickleable_list(l)
         assert len(l) == 0