Repository: 8451/labrea (language: python)

Issue:
Syntax fixes and example clarification in README



Discussion / hints:

Fix patch:
diff --git a/.bumpversion.cfg b/.bumpversion.cfg
index 3ff4ff7..f22168a 100644
--- a/.bumpversion.cfg
+++ b/.bumpversion.cfg
@@ -1,5 +1,5 @@
 [bumpversion]
-current_version = 1.4.0
+current_version = 2.0.0
 commit = false
 tag = false
 serialize =
diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml
index 5f30ae1..38126fb 100644
--- a/.github/workflows/build-and-deploy.yml
+++ b/.github/workflows/build-and-deploy.yml
@@ -20,6 +20,11 @@ on:
       - main
       - develop
   workflow_dispatch:
+    inputs:
+      pre-release-version:
+        description: 'Version to set wheel to for pre-release'
+        required: false
+        default: ''
 
 
 jobs:
@@ -29,6 +34,8 @@ jobs:
   build:
     uses: ./.github/workflows/build.yml
     needs: test
+    with:
+      pre-release-version: ${{ inputs.pre-release-version || '' }}
 
   publish:
     uses: ./.github/workflows/publish.yml
@@ -36,5 +43,5 @@ jobs:
       id-token: write
     with:
       # release if this is a published release, test-release otherwise
-      environment: ${{ github.event.release && 'release' || 'test-release' }}
+      environment: ${{ (github.event.release || inputs.pre-release-version) && 'release' || 'test-release' }}
     needs: build
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 74e5ae8..fa7f214 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -3,13 +3,27 @@
 
 name: Build
 
-on: [workflow_call, workflow_dispatch]
+on:
+  workflow_dispatch:
+  workflow_call:
+    inputs:
+      pre-release-version:
+        description: 'Version to set wheel to for pre-release'
+        required: false
+        default: ''
+        type: string
 
 jobs:
   build:
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v3
+      - name: Set Version
+        if: ${{ inputs.pre-release-version }}
+        run: |
+          echo "Setting version to ${{ inputs.pre-release-version }}"
+          pip install bump2version
+          bump2version --new-version ${{ inputs.pre-release-version }} patch
       - name: Build Package
         run: |
           python -m pip install --upgrade pip
diff --git a/README.md b/README.md
index cb0a62b..dce8990 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
 # Labrea
 
-![](https://img.shields.io/badge/version-1.4.0-blue.svg)
+![](https://img.shields.io/badge/version-2.0.0-blue.svg)
 [![lifecycle](https://img.shields.io/badge/lifecycle-stable-green.svg)](https://www.tidyverse.org/lifecycle/#stable)
 [![PyPI Downloads](https://img.shields.io/pypi/dm/labrea.svg?label=PyPI%20downloads)](https://pypi.org/project/labrea/)
 [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit)
@@ -25,8 +25,8 @@ pip install git+https://github.com/8451/labrea@develop
 ## Usage
 See our usage guide [here](docs/source/usage.md).
 
-Labrea exposes a `dataset` decorator that allows you to define datasets and their dependencies in a declarative manner. 
-Dependencies can either be other datasets or `Option`s, which are values that can be passed in at runtime via a 
+Labrea exposes a `dataset` decorator that allows you to define datasets and their dependencies in a declarative manner.
+Dependencies can either be other datasets or `Option`s, which are values that can be passed in at runtime via a
 dictionary.
 
 ```python
@@ -46,7 +46,7 @@ def transactions(path: str = Option('PATHS.SALES')) -> pd.DataFrame:
 
 @dataset
 def sales_by_region(
-        stores_: pd.DataFrame = stores, 
+        stores_: pd.DataFrame = stores,
         transactions_: pd.DataFrame = transactions
 ) -> pd.DataFrame:
     """Merge stores to transactions, sum sales by region"""
diff --git a/docs/source/changelog.md b/docs/source/changelog.md
index f3a7d90..d52ba6c 100644
--- a/docs/source/changelog.md
+++ b/docs/source/changelog.md
@@ -1,4 +1,40 @@
 # Changelog
 
+## Unreleased (2.0.0)
+
+- Changes to dataset
+  + To overload a dataset, you now *must* provide a `dispatch=` argument to the decorator
+    - Previously, a default dispatch of `Option('LABREA.IMPLEMENTATIONS.pkg.module.dataset_name')` was used
+    - Similarly, a dispatch argument is required for the `@interface` decorator
+  + `@my_dataset.overload` now takes `alias=` as either an alias or a list of aliases
+  + `where=` argument is now renamed `defaults=` fo clarity
+  + `@dataset` longer accepts an `alias=` argument
+  + `callbacks=` has been renamed to `effects=`
+    - Now takes functions of a single argument, or `Evaluatable` objects that return such functions
+    - Can also subclass the `labrea.computation.Effect` class to define custom effects
+- Changes to pipelines
+  + `LabreaPipeline.step` has been removed in favor of the `pipeline_step` decorator
+    - Defining pipeline steps looks the same (like a dataset with empty first arg)
+  + Composing pipeline steps is now done with the `+` operator rather than the `>>` operator
+    - This is because `>>` is now used as an alias for the new `.apply` method on all `Evaluatable` objects
+  + Pipelines are now *evaluatable*, meaning they act like a dataset the returns a function of one variable
+    - This means a pipeline can be provided as an argument to a dataset definition
+  + Pipelines can now be invoked in one of two ways
+    - `pipeline(options)(value)`
+    - `pipeline.transform(value, options)`
+      + Here options can be omitted and an empty dictionary will be used
+  + Pipelines now return the plain value, rather than the special `LabreaPipelineData` object
+- Changes to Caching 
+  + Datasets are cached, but overloads do not have their own cache
+    - This means that if you overload a dataset, it will be cached in the parent dataset's cache rather than in a separate cache
+  + Caching can be globally disabled by using `with labrea.cache.disabled():`
+  + The `get`, `set`, and `exists` hooks in the `Cache` ABC have changed signatures
+    - This only impacts those who were writing custom cache implementations 
+- `Evaluatable` is now an `ABC` rather than a `Protocol`
+  + This only impacts those who are extending the labrea framework with custom types
+- Implementing multiple interfaces at once is now done using the `@implements` decorator
+  + `@implements(Interface1, Interface2, alias='ALIAS')` 
+  
+
 ## Version 1.4.0
 - Release as open source
\ No newline at end of file
diff --git a/docs/source/usage.md b/docs/source/usage.md
index 7fdef72..233271a 100644
--- a/docs/source/usage.md
+++ b/docs/source/usage.md
@@ -235,11 +235,11 @@ from labrea import Option
 
 config = {
     'A': 'a',
-    'B': 'b'
+    'V': 'b'
 }
 
 Option('X', 1)(config)          ## 1
-Option('Y', '{A}/{B}')(config)  ## 'a/b'
+Option('Y', '{A}/{V}')(config)  ## 'a/b'
 ```
 
 ### Templates
@@ -254,7 +254,7 @@ from labrea import dataset, Option, Template
 
 @dataset
 def b_dataset(
-        b: str = Option('B')
+        b: str = Option('V')
 ) -> str:
     return b
 
@@ -263,7 +263,7 @@ template = Template(
     b=b_dataset
 )
 
-template({'A': 'Hello', 'B': 'World!'})  ## 'Hello World!'
+template({'A': 'Hello', 'V': 'World!'})  ## 'Hello World!'
 ```
 
 ### Switches
@@ -341,20 +341,20 @@ the first one that can evaluate.
 ```python
 from labrea import Coalesce, Option
 
-x = Coalesce(Option('A'), Option('B'), Option('C'))
+x = Coalesce(Option('A'), Option('V'), Option('C'))
 
 x({'A': 1}) == 1
-x({'B': 2}) == 2
+x({'V': 2}) == 2
 x({'C': 3}) == 3
-x({'A': 1, 'B': 2}) == 1
-x({'B': 2, 'C': 3}) == 2
+x({'A': 1, 'V': 2}) == 1
+x({'V': 2, 'C': 3}) == 2
 x({})  ## EvaluationError
 
 
-y = Coalesce(Option('A'), Option('B'), None)
+y = Coalesce(Option('A'), Option('V'), None)
 y({'A': 1}) == 1
-y({'B': 2}) == 2
-y({'A': 1, 'B': 2}) == 1
+y({'V': 2}) == 2
+y({'A': 1, 'V': 2}) == 1
 y({}) is None
 ```
 
diff --git a/labrea/__init__.py b/labrea/__init__.py
index 19ffa1d..d883b6f 100644
--- a/labrea/__init__.py
+++ b/labrea/__init__.py
@@ -1,34 +1,59 @@
-from . import _version as version
-from .coalesce import Coalesce
-from .collections import DatasetDict, DatasetList
-from .datasetclasses import Field, datasetclass
-from .datasets import Dataset, Overload, abstractdataset, dataset
-from .interfaces import Interface, interface
-from .options import Option
-from .pipelines import LabreaPipeline
-from .switch import Switch
+from . import _version, exceptions, types
+from .cache import cached
+from .coalesce import Coalesce, coalesce
+from .collections import (
+    DatasetDict,
+    DatasetList,
+    DatasetSet,
+    DatasetTuple,
+    evaluatable_dict,
+    evaluatable_list,
+    evaluatable_set,
+    evaluatable_tuple,
+)
+from .computation import Computation
+from .conditional import Switch, case, switch
+from .dataset import abstractdataset, dataset
+from .datasetclass import datasetclass
+from .interface import implements, interface
+from .iterable import Map
+from .option import Option, WithOptions
+from .overload import Overloaded
+from .pipeline import pipeline_step
 from .template import Template
 from .types import Value
 
-
-__version__ = version.__version__
+__version__ = _version.__version__
 
 
 __all__ = [
-    "dataset",
-    "abstractdataset",
-    "Dataset",
-    "Overload",
-    "interface",
-    "Interface",
-    "Template",
+    "Coalesce",
+    "Computation",
+    "DatasetDict",
+    "DatasetList",
+    "DatasetSet",
+    "DatasetTuple",
+    "Map",
     "Option",
+    "WithOptions",
+    "Overloaded",
     "Switch",
-    "DatasetList",
-    "DatasetDict",
-    "datasetclass",
-    "Field",
+    "Template",
     "Value",
-    "Coalesce",
-    "LabreaPipeline",
+    "abstractdataset",
+    "cached",
+    "case",
+    "coalesce",
+    "dataset",
+    "datasetclass",
+    "evaluatable_dict",
+    "evaluatable_list",
+    "evaluatable_set",
+    "evaluatable_tuple",
+    "exceptions",
+    "implements",
+    "interface",
+    "pipeline_step",
+    "switch",
+    "types",
 ]
diff --git a/labrea/_aliases.py b/labrea/_aliases.py
deleted file mode 100644
index 97c1bf1..0000000
--- a/labrea/_aliases.py
+++ /dev/null
@@ -1,21 +0,0 @@
-from typing import Any
-
-from .types import Alias, MultiAlias
-
-
-def default_alias(obj: Any) -> Alias:
-    try:
-        name = obj.__name__
-    except AttributeError:
-        raise TypeError(
-            f"A default alias for {obj} could not be created, please provide "
-            f"one manually."
-        )
-
-    module = getattr(obj, "__module__", None)
-
-    return name if module in ("__main__", None) else f"{module}.{name}"
-
-
-def default_aliases(obj: Any) -> MultiAlias:
-    return [default_alias(obj)]
diff --git a/labrea/_import.py b/labrea/_import.py
deleted file mode 100644
index 9d6b374..0000000
--- a/labrea/_import.py
+++ /dev/null
@@ -1,40 +0,0 @@
-from functools import reduce
-from importlib import import_module
-from types import ModuleType
-from typing import Any, List, Tuple, TypeVar
-
-A = TypeVar("A")
-
-
-def _import_module(path: List[str]) -> Tuple[ModuleType, List[str]]:
-    if len(path) == 0:
-        raise ImportError
-
-    try:
-        return import_module(".".join(path)), []
-    except ModuleNotFoundError:
-        path, attrs = path[:-1], path[-1:]
-        module, rest = _import_module(path)
-        return module, rest + attrs
-
-
-def _getattr(namespace: Any, name: str):
-    try:
-        return getattr(namespace, name)
-    except AttributeError as err:
-        raise ImportError(f'"{namespace}" does not define attribute "{name}"') from err
-
-
-def import_string(dotted_path: str) -> Any:
-    """
-    Import a dotted module path and return the attribute/class designated by
-    the last name in the path. Raise ImportError if the import failed.
-    """
-    module, attrs = _import_module(dotted_path.split("."))
-
-    return reduce(_getattr, attrs, module)
-
-
-def require(*args: str):
-    for arg in args:
-        _ = import_string(arg)
diff --git a/labrea/_missing.py b/labrea/_missing.py
new file mode 100644
index 0000000..9513e21
--- /dev/null
+++ b/labrea/_missing.py
@@ -0,0 +1,17 @@
+from enum import Enum
+from typing import TypeVar, Union
+
+T = TypeVar("T")
+
+
+class Missing(Enum):
+    token = 0
+
+    def __repr__(self) -> str:
+        return "MISSING"
+
+
+MISSING = Missing.token
+
+
+MaybeMissing = Union[Missing, T]
diff --git a/labrea/_version.py b/labrea/_version.py
index 3e8d9f9..8c0d5d5 100644
--- a/labrea/_version.py
+++ b/labrea/_version.py
@@ -1,1 +1,1 @@
-__version__ = "1.4.0"
+__version__ = "2.0.0"
diff --git a/labrea/application.py b/labrea/application.py
new file mode 100644
index 0000000..fe2c8c8
--- /dev/null
+++ b/labrea/application.py
@@ -0,0 +1,197 @@
+import sys
+
+if sys.version_info < (3, 10):
+    from typing_extensions import Concatenate, ParamSpec
+else:
+    from typing import Concatenate, ParamSpec
+
+import inspect
+from typing import Callable, Dict, Generic, Optional, Set, TypeVar, Union, overload
+
+from .arguments import Arguments, arguments
+from .types import Evaluatable, MaybeEvaluatable, Options
+
+P = ParamSpec("P")
+A = TypeVar("A", covariant=True)
+X = TypeVar("X")
+
+
+class FunctionApplication(Generic[P, A], Evaluatable[A]):
+    """A class representing the application of a function to a set of arguments."""
+
+    func: Callable[P, A]
+    arguments: Evaluatable[Arguments[P]]
+    _repr: str
+
+    def __init__(
+        self,
+        __func: Callable[P, A],
+        /,
+        *args: "MaybeEvaluatable[P.args]",
+        **kwargs: "MaybeEvaluatable[P.kwargs]",
+    ):
+        self.func = __func
+        self.arguments = arguments(*args, **kwargs)
+
+        self._repr = (
+            f"FunctionApplication("
+            f"{self.func.__name__}, "
+            f"{', '.join(map(repr, args))}"
+            f"{', '.join(f'{key}={value!r}' for key, value in kwargs.items())}"
+            f")"
+        )
+
+    def evaluate(self, options: Options) -> A:
+        args = self.arguments.evaluate(options)
+        return self.func(*args.args, **args.kwargs)
+
+    def validate(self, options: Options) -> None:
+        self.arguments.validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        return self.arguments.keys(options)
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        return self.arguments.explain(options)
+
+    def __repr__(self) -> str:
+        return self._repr
+
+    @overload
+    @classmethod
+    def lift(
+        cls,
+        __func: Callable[P, A],
+        /,
+        **kwargs: "MaybeEvaluatable[P.kwargs]",
+    ) -> "FunctionApplication[P, A]":
+        ...  # pragma: no cover
+
+    @overload
+    @classmethod
+    def lift(
+        cls,
+        __func: None = None,
+        /,
+        **kwargs: "MaybeEvaluatable[P.kwargs]",
+    ) -> Callable[[Callable[P, A]], "FunctionApplication[P, A]"]:
+        ...  # pragma: no cover
+
+    @classmethod
+    def lift(
+        cls,
+        __func: Optional[Callable[P, A]] = None,
+        /,
+        **kwargs: "MaybeEvaluatable[P.kwargs]",
+    ) -> Union[
+        "FunctionApplication[P, A]",
+        Callable[[Callable[P, A]], "FunctionApplication[P, A]"],
+    ]:
+        if __func is None:
+            return lambda f: cls.lift(f, **kwargs)
+
+        signature = inspect.signature(__func)
+        eval_kwargs: Dict[str, Evaluatable["P.kwargs"]] = {}
+
+        for param in signature.parameters.values():
+            default = kwargs.get(param.name, param.default)
+            if default is param.empty:
+                raise TypeError(
+                    f"Cannot lift function {__func} with non-defaulted parameters"
+                )
+            eval_kwargs[param.name] = Evaluatable.ensure(default)
+
+        return FunctionApplication(__func, **eval_kwargs)
+
+
+class PartialApplication(Generic[X, P, A], Evaluatable[Callable[[X], A]]):
+    func: Callable[Concatenate[X, P], A]
+    arguments: Evaluatable[Arguments[P]]
+    _repr: str
+
+    def __init__(
+        self,
+        __func: Callable[Concatenate[X, P], A],
+        /,
+        *args: Evaluatable["P.args"],
+        **kwargs: Evaluatable["P.kwargs"],
+    ):
+        self.func = __func
+        self.arguments = arguments(*args, **kwargs)
+
+        self._repr = (
+            f"PartialApplication("
+            f"{self.func.__name__}, "
+            f"{', '.join(map(repr, args))}"
+            f"{', '.join(f'{key}={value!r}' for key, value in kwargs.items())}"
+            f")"
+        )
+
+    def evaluate(self, options: Options) -> Callable[[X], A]:
+        args = self.arguments.evaluate(options)
+        return lambda x: self.func(x, *args.args, **args.kwargs)
+
+    def validate(self, options: Options) -> None:
+        self.arguments.validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        return self.arguments.keys(options)
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        return self.arguments.explain(options)
+
+    def __repr__(self) -> str:
+        return self._repr
+
+    @overload
+    @classmethod
+    def lift(
+        cls,
+        __func: Callable[Concatenate[X, P], A],
+        /,
+        **kwargs: "MaybeEvaluatable[P.kwargs]",
+    ) -> "PartialApplication[X, P, A]":
+        ...  # pragma: no cover
+
+    @overload
+    @classmethod
+    def lift(
+        cls,
+        __func: None = None,
+        /,
+        **kwargs: "MaybeEvaluatable[P.kwargs]",
+    ) -> Callable[[Callable[Concatenate[X, P], A]], "PartialApplication[X, P, A]"]:
+        ...  # pragma: no cover
+
+    @classmethod
+    def lift(
+        cls,
+        __func: Optional[Callable[Concatenate[X, P], A]] = None,
+        /,
+        **kwargs: "MaybeEvaluatable[P.kwargs]",
+    ) -> Union[
+        "PartialApplication[X, P, A]",
+        Callable[[Callable[Concatenate[X, P], A]], "PartialApplication[X, P, A]"],
+    ]:
+        if __func is None:
+            return lambda f: cls.lift(f, **kwargs)
+
+        signature = inspect.signature(__func)
+        eval_kwargs: Dict[str, Evaluatable["P.kwargs"]] = {}
+
+        for i, param in enumerate(signature.parameters.values()):
+            default = kwargs.get(param.name, param.default)
+            if i == 0 and default is not param.empty:
+                raise TypeError(
+                    f"Cannot create a PartialApplication with a default value "
+                    f"for the first parameter of {__func}"
+                )
+            elif i > 0 and default is param.empty:
+                raise TypeError(
+                    f"Cannot lift function {__func} with non-defaulted parameters "
+                    f"after the first parameter"
+                )
+            elif default is not param.empty:
+                eval_kwargs[param.name] = Evaluatable.ensure(default)
+
+        return PartialApplication(__func, **eval_kwargs)
diff --git a/labrea/arguments.py b/labrea/arguments.py
new file mode 100644
index 0000000..d6c7eef
--- /dev/null
+++ b/labrea/arguments.py
@@ -0,0 +1,130 @@
+import sys
+
+if sys.version_info < (3, 10):
+    from typing_extensions import ParamSpec
+else:
+    from typing import ParamSpec
+
+from typing import Dict, Generic, Optional, Set, Tuple
+
+from .types import Evaluatable, MaybeEvaluatable, Options
+
+P = ParamSpec("P")
+
+
+class Arguments(Generic[P]):
+    """A class representing the arguments of a function or method."""
+
+    args: P.args
+    kwargs: P.kwargs
+
+    def __init__(self, *args: P.args, **kwargs: P.kwargs):
+        self.args = args
+        self.kwargs = kwargs
+
+    def __repr__(self) -> str:
+        args_repr = ", ".join(map(repr, self.args))
+        kwargs_repr = ", ".join(
+            f"{key}={value!r}" for key, value in self.kwargs.items()
+        )
+        return f"Arguments({', '.join(filter(bool, [args_repr, kwargs_repr]))})"
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, Arguments):
+            return NotImplemented
+        return self.args == other.args and self.kwargs == other.kwargs
+
+
+class EvaluatableArgs(Generic[P], Evaluatable["P.args"]):
+    """A class representing a set of arguments that can be evaluated."""
+
+    args: Tuple[Evaluatable, ...]
+
+    def __init__(self, *args: Evaluatable["P.args"]):
+        self.args = args
+
+    def evaluate(self, options: Options) -> "P.args":
+        return tuple(arg.evaluate(options) for arg in self.args)  # type: ignore
+
+    def validate(self, options: Options) -> None:
+        for arg in self.args:
+            arg.validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        return set().union(*(arg.keys(options) for arg in self.args))
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        return set().union(*(arg.explain(options) for arg in self.args))
+
+    def __repr__(self) -> str:
+        return f"EvaluatableArgs({', '.join(map(repr, self.args))})"
+
+
+class EvaluatableKwargs(Generic[P], Evaluatable["P.kwargs"]):
+    """A class representing a set of keyword arguments that can be evaluated."""
+
+    kwargs: Dict[str, Evaluatable]
+
+    def __init__(self, **kwargs: Evaluatable["P.kwargs"]):
+        self.kwargs = kwargs
+
+    def evaluate(self, options: Options) -> "P.kwargs":
+        return {key: value.evaluate(options) for key, value in self.kwargs.items()}  # type: ignore
+
+    def validate(self, options: Options) -> None:
+        for value in self.kwargs.values():
+            value.validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        return set().union(*(value.keys(options) for value in self.kwargs.values()))
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        return set().union(*(value.explain(options) for value in self.kwargs.values()))
+
+    def __repr__(self) -> str:
+        return (
+            f"EvaluatableKwargs("
+            f"{', '.join(f'{key}={value!r}' for key, value in self.kwargs.items())}"
+            f")"
+        )
+
+
+class EvaluatableArguments(Evaluatable[Arguments[P]]):
+    """A class representing a set of arguments that can be evaluated."""
+
+    args: EvaluatableArgs[P]
+    kwargs: EvaluatableKwargs[P]
+
+    def __init__(self, *args: Evaluatable["P.args"], **kwargs: Evaluatable["P.kwargs"]):
+        self.args = EvaluatableArgs(*args)
+        self.kwargs = EvaluatableKwargs(**kwargs)
+
+    def evaluate(self, options: Options) -> Arguments[P]:
+        return Arguments(*self.args.evaluate(options), **self.kwargs.evaluate(options))
+
+    def validate(self, options: Options) -> None:
+        self.args.validate(options)
+        self.kwargs.validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        return self.args.keys(options).union(self.kwargs.keys(options))
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        return self.args.explain(options) | self.kwargs.explain(options)
+
+    def __repr__(self) -> str:
+        return (
+            f"EvaluatableParameters("
+            f"{', '.join(map(repr, self.args.args))}, "
+            f"{', '.join(f'{key}={value!r}' for key, value in self.kwargs.kwargs.items())}"
+            f")"
+        )
+
+
+def arguments(
+    *args: MaybeEvaluatable["P.args"], **kwargs: MaybeEvaluatable["P.kwargs"]
+) -> Evaluatable[Arguments[P]]:
+    return EvaluatableArguments(
+        *(Evaluatable.ensure(arg) for arg in args),
+        **{key: Evaluatable.ensure(value) for key, value in kwargs.items()},
+    )
diff --git a/labrea/cache.py b/labrea/cache.py
index 90c7a96..5fa9a99 100644
--- a/labrea/cache.py
+++ b/labrea/cache.py
@@ -1,185 +1,318 @@
-from abc import ABCMeta, abstractmethod
-from typing import Generic, TypeVar
+from abc import ABC, abstractmethod
+from typing import Any, Callable, Dict, Generic, Optional, Set, TypeVar, Union, overload
 
-from confectioner.templating import get_dotted_key
-
-from .types import Evaluatable, JSONDict, JSONType
+from . import runtime
+from .option import Option
+from .types import Evaluatable, Options
 
 A = TypeVar("A")
 
 
-class CacheInvalidationError(Exception):
-    """Raised when a cache is invalidated"""
+class CacheFailure(Exception):
+    """An exception raised when a cache operation fails."""
 
-    pass
+    evaluatable: Evaluatable
+    options: Options
+    cache: "Cache"
 
 
-def json_hash(json: JSONType):
-    if isinstance(json, dict):
-        keys = tuple(sorted(json.keys()))
-        vals = [json[key] for key in keys]
-        return hash((hash(keys), json_hash(vals)))
-    if isinstance(json, list):
-        return hash(tuple(json_hash(val) for val in json))
-    else:
-        return hash(json)
+class CacheGetFailure(CacheFailure):
+    """An exception raised when a cache get operation fails."""
 
+    def __init__(
+        self, evaluatable: Evaluatable, options: Options, cache: "Cache"
+    ) -> None:
+        self.evaluatable = evaluatable
+        self.options = options
+        self.cache = cache
 
-class Cache(Generic[A], metaclass=ABCMeta):
-    """A cache for Datasets
+        super().__init__(f"Failed to retrieve {evaluatable} from cache {cache}")
 
-    A cache is used to store the results of evaluating a Dataset. Every time a
-    Dataset is evaluated, the cache is checked to see if the result has already
-    been computed. If it has, the cached result is returned. If not, the
-    Dataset is evaluated and the result is stored in the cache.
 
-    Because the same Dataset can be evaluated with different options, the cache
-    is keyed by the options dictionary. The keys returned by the Cache's
-    keys() method are used to generate the key for the cache lookup, so the
-    keys() method should return all keys that affect the evaluation of the
-    Dataset.
+class CacheSetFailure(CacheFailure):
+    """An exception raised when a cache set operation fails."""
 
-    The Cache class is abstract and must be subclassed to implement the
-    exists(), get(), and set() methods.
-    """
+    evaluatable: Evaluatable
+    options: Options
+    cache: "Cache"
+    value: Any
 
-    evaluatable: Evaluatable[A]
+    def __init__(
+        self, evaluatable: Evaluatable, options: Options, cache: "Cache", value: Any
+    ) -> None:
+        self.evaluatable = evaluatable
+        self.options = options
+        self.cache = cache
+        self.value = value
 
-    def __init__(self, _evaluatable: Evaluatable[A]):
-        """Create a new Cache
+        super().__init__(f"Failed to add value {value} from {evaluatable} to {cache}")
 
-        Parameters
-        ----------
-        _evaluatable : Evaluatable[A]
-            The Evaluatable that this Cache is caching.
-        """
-        self.evaluatable = _evaluatable
 
-    @abstractmethod
-    def exists(self, keys: JSONDict) -> bool:
-        """Check if there is a cached value for the given keys
+class CacheExistsFailure(CacheFailure):
+    """An exception raised when a cache exists operation fails."""
 
-        Parameters
-        ----------
-        keys : JSONDict
-            The keys to check for in the cache.
+    evaluatable: Evaluatable
+    options: Options
+    cache: "Cache"
 
-        Returns
-        -------
-        bool
-            True if there is a cached value for the given keys,
-            False otherwise.
+    def __init__(
+        self, evaluatable: Evaluatable, options: Options, cache: "Cache"
+    ) -> None:
+        self.evaluatable = evaluatable
+        self.options = options
+        self.cache = cache
 
-        Raises
-        ------
-        CacheInvalidationError
-            If the cached value is no longer valid.
-        """
-        ...  # pragma: nocover
+        super().__init__(f"Failed to check if {evaluatable} exists in cache {cache}")
+
+
+class Cache(Generic[A], ABC):
+    """A class representing a cache of values that can be evaluated."""
 
     @abstractmethod
-    def get(self, keys: JSONDict) -> A:
-        """Get the cached value for the given keys
+    def get(self, evaluatable: Evaluatable[A], options: Options) -> A:
+        """Get a key from the cache.
 
-        Parameters
-        ----------
-        keys : JSONDict
-            The keys to lookup in the cache
+        Arguments
+        ---------
+        evaluatable: Evaluatable
+            The evaluatable to get from the cache.
+        options : Options
+            The options dictionary to evaluate against.
 
         Returns
         -------
         A
-            The cached value for the given keys.
+            The value from the cache.
 
         Raises
         ------
-        KeyError
-            If there is no cached value for the given keys.
-        CacheInvalidationError
-            If the cached value is no longer valid.
+        CacheGetFailure
+            If the key cannot be retrieved from the cache.
         """
-        ...  # pragma: nocover
+        raise NotImplementedError  # pragma: nocover
 
     @abstractmethod
-    def set(self, keys: JSONDict, value: A):
-        """Set the cached value for the given keys
-
-        Parameters
-        ----------
-        keys : JSONDict
-            The keys to set in the cache
+    def set(self, evaluatable: Evaluatable[A], options: Options, value: A) -> None:
+        """Set a key in the cache.
+
+        Arguments
+        ---------
+        evaluatable: Evaluatable
+            The evaluatable to set in the cache.
+        options : Options
+            The options dictionary to evaluate against.
         value : A
-            The value to set in the cache
+            The value to set in the cache.
 
         Raises
         ------
-        CacheInvalidationError
-            If the cached value is no longer valid.
+        CacheSetFailure
+            If the key cannot be set in the cache.
         """
-        ...  # pragma: nocover
+        raise NotImplementedError  # pragma: nocover
 
-    def keys(self, options: JSONDict) -> JSONDict:
-        """Identify the keys that affect the evaluation of the Dataset
+    def exists(self, evaluatable: Evaluatable[A], options: Options) -> bool:
+        """Check if a key exists in the cache.
 
-        Parameters
-        ----------
-        options : JSONDict
-            The options dictionary to use to evaluate the Dataset
+        Arguments
+        ---------
+        evaluatable: Evaluatable
+            The evaluatable to check in the cache.
+        options : Options
+            The options dictionary to evaluate against.
 
         Returns
         -------
-        JSONDict
-            The keys that affect the evaluation of the Dataset, and their
-            values.
+        bool
+            Whether the value exists in the cache.
+
+        Raises
+        ------
+        CacheExistsFailure
+            If the existence of the key cannot be checked in the cache.
         """
-        return {
-            key: get_dotted_key(key, dict(options))
-            for key in self.evaluatable.keys(options)
+        try:
+            self.get(evaluatable, options)
+            return True
+        except CacheGetFailure:
+            return False
+
+
+class NoCache(Cache[Any]):
+    """A class representing a cache that does not store any values."""
+
+    def get(self, evaluatable: Evaluatable, options: Options) -> Any:
+        raise CacheGetFailure(evaluatable, options, self)
+
+    def set(self, evaluatable: Evaluatable, options: Options, value: Any) -> None:
+        pass
+
+
+class MemoryCache(Cache[A]):
+    """A class representing a cache that stores values in memory."""
+
+    _cache: Dict[bytes, A]
+
+    def __init__(self) -> None:
+        self._cache = {}
+
+    def get(self, evaluatable: Evaluatable, options: Options) -> A:
+        try:
+            return self._cache[evaluatable.fingerprint(options)]
+        except KeyError as e:
+            raise CacheGetFailure(evaluatable, options, self) from e
+
+    def set(self, evaluatable: Evaluatable, options: Options, value: A) -> None:
+        self._cache[evaluatable.fingerprint(options)] = value
+
+    def exists(self, evaluatable: Evaluatable, options: Options) -> bool:
+        return evaluatable.fingerprint(options) in self._cache
+
+
+class CacheSetRequest(runtime.Request[A]):
+    evaluatable: Evaluatable
+    options: Options
+    value: A
+    cache: Cache[A]
+
+    def __init__(
+        self, evaluatable: Evaluatable[A], options: Options, value: A, cache: Cache[A]
+    ):
+        self.evaluatable = evaluatable
+        self.options = options
+        self.value = value
+        self.cache = cache
+
+
+class CacheGetRequest(runtime.Request[A]):
+    evaluatable: Evaluatable[A]
+    options: Options
+    cache: Cache[A]
+
+    def __init__(self, evaluatable: Evaluatable, options: Options, cache: Cache[A]):
+        self.evaluatable = evaluatable
+        self.options = options
+        self.cache = cache
+
+
+class CacheExistsRequest(runtime.Request[bool]):
+    evaluatable: Evaluatable
+    options: Options
+    cache: Cache
+
+    def __init__(self, evaluatable: Evaluatable, options: Options, cache: Cache):
+        self.evaluatable = evaluatable
+        self.options = options
+        self.cache = cache
+
+
+def _cache_disabled(
+    request: Union[CacheSetRequest, CacheGetRequest, CacheExistsRequest]
+) -> bool:
+    return Option("LABREA.CACHE.DISABLED", False)(request.options)
+
+
+@CacheSetRequest.handle
+def set_cache_handler(request: CacheSetRequest[A]) -> A:
+    if _cache_disabled(request):
+        return _disabled_set_cache_handler(request)
+
+    request.cache.set(request.evaluatable, request.options, request.value)
+    try:
+        return request.cache.get(request.evaluatable, request.options)
+    except CacheGetFailure:
+        return request.value
+
+
+@CacheGetRequest.handle
+def get_cache_handler(request: CacheGetRequest[A]) -> A:
+    if _cache_disabled(request):
+        return _disabled_get_cache_handler(request)
+
+    return request.cache.get(request.evaluatable, request.options)
+
+
+@CacheExistsRequest.handle
+def exists_cache_handler(request: CacheExistsRequest) -> bool:
+    if _cache_disabled(request):
+        return _disabled_exists_cache_handler(request)
+
+    return request.cache.exists(request.evaluatable, request.options)
+
+
+def _disabled_set_cache_handler(request: CacheSetRequest[A]) -> A:
+    return request.value
+
+
+def _disabled_get_cache_handler(request: CacheGetRequest[A]) -> A:
+    raise CacheGetFailure(request.evaluatable, request.options, request.cache)
+
+
+def _disabled_exists_cache_handler(request: CacheExistsRequest) -> bool:
+    return False
+
+
+def disabled() -> runtime.Runtime:
+    return runtime.handle(
+        {
+            CacheSetRequest: _disabled_set_cache_handler,
+            CacheGetRequest: _disabled_get_cache_handler,
+            CacheExistsRequest: _disabled_exists_cache_handler,
         }
+    )
 
-    def __getitem__(self, options: JSONDict):
-        keys = self.keys(options)
-        return self.get(keys)
 
-    def __setitem__(self, options: JSONDict, value: A):
-        keys = self.keys(options)
-        self.set(keys, value)
+class Cached(Evaluatable[A]):
+    """A class representing an Evaluatable that may be cached."""
 
-    def __contains__(self, options: JSONDict):
-        keys = self.keys(options)
-        return self.exists(keys)
+    evaluatable: Evaluatable[A]
+    cache: Cache[A]
 
+    def __init__(self, evaluatable: Evaluatable[A], cache: Cache[A]):
+        self.evaluatable = evaluatable
+        self.cache = cache
 
-class NoCache(Cache[A]):
-    """A Cache that does not cache anything"""
+    def evaluate(self, options: Options) -> A:
+        if CacheExistsRequest(self.evaluatable, options, self.cache).run():
+            try:
+                return CacheGetRequest(self.evaluatable, options, self.cache).run()
+            except CacheGetFailure:
+                pass
 
-    def exists(self, keys: JSONDict) -> bool:
-        return False
+        value = self.evaluatable.evaluate(options)
 
-    def get(self, keys: JSONDict) -> A:
-        raise KeyError
+        return CacheSetRequest(self.evaluatable, options, value, self.cache).run()
 
-    def set(self, keys: JSONDict, value: A):
-        pass
+    def validate(self, options: Options) -> None:
+        if not CacheExistsRequest(self.evaluatable, options, self.cache).run():
+            self.evaluatable.validate(options)
 
+    def keys(self, options: Options) -> Set[str]:
+        return self.evaluatable.keys(options)
 
-class MemoryCache(Cache[A]):
-    """A Cache that stores values in memory
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        return self.evaluatable.explain(options)
 
-    A simple in-memory cache that stores values in a dictionary.
-    """
+    def __repr__(self) -> str:
+        return f"CachedEvaluation({self.evaluatable!r}, {self.cache!r})"
 
-    _cache: dict
 
-    def __init__(self, *args, **kwargs):
-        self._cache = {}
-        super().__init__(*args, **kwargs)
+@overload
+def cached(__x: Evaluatable[A], cache: Optional[Cache[A]] = None) -> Evaluatable[A]:
+    ...  # pragma: nocover
 
-    def exists(self, keys: JSONDict) -> bool:
-        return json_hash(keys) in self._cache
 
-    def get(self, keys: JSONDict) -> A:
-        return self._cache[json_hash(keys)]
+@overload
+def cached(__x: Cache[A]) -> Callable[[Evaluatable[A]], Evaluatable[A]]:
+    ...  # pragma: nocover
 
-    def set(self, keys: JSONDict, value: A):
-        self._cache[json_hash(keys)] = value
+
+def cached(
+    __x: Union[Cache[A], Evaluatable[A]],
+    cache: Optional[Cache[A]] = None,
+) -> Union[Callable[[Evaluatable[A]], Evaluatable[A]], Evaluatable[A]]:
+    if isinstance(__x, Cache):
+        return lambda evaluatable: cached(evaluatable, __x)
+    else:
+        cache = cache or MemoryCache()
+        return Cached(__x, cache)
diff --git a/labrea/callbacks.py b/labrea/callbacks.py
deleted file mode 100644
index 411dbb4..0000000
--- a/labrea/callbacks.py
+++ /dev/null
@@ -1,55 +0,0 @@
-import typing
-from collections import OrderedDict
-from typing import Generic, Optional, TypeVar
-
-from .types import Callback, Evaluatable, JSONDict
-
-A = TypeVar("A", covariant=True)
-
-
-PreCallback = Callback[Evaluatable[A], JSONDict]
-PostCallback = Callback[A, A]
-
-
-class EarlyExit(Generic[A], Exception):
-    """An exception to signal that the evaluation should stop early."""
-
-    value: A
-
-    def __init__(self, value: A, *args):
-        self.value = value
-        super().__init__(*args)
-
-
-class Callbacks(Generic[A]):
-    """A collection of callbacks to be applied to an Evaluatable.
-
-    Callbacks are functions that take an Evaluatable and an options dictionary
-    and return a value. They are used to extend the behavior of an Evaluatable
-    by running a computation before or after the .evaluate() method is called.
-
-    Parameters
-    ----------
-    pre : typing.OrderedDict[PreCallback]
-        A list of callbacks to run before the .evaluate() method is called.
-        Takes an Evaluatable and an options dictionary and optionally returns
-        a new Evaluatable and options dictionary.
-    post : typing.OrderedDict[PostCallback]
-        A list of callbacks to run after the .evaluate() method is called.
-        Takes the result of the .evaluate() method and an options dictionary
-        and optionally returns a new result.
-    """
-
-    pre: typing.OrderedDict[str, PreCallback[A]]
-    post: typing.OrderedDict[str, PostCallback[A]]
-
-    def __init__(
-        self,
-        pre: Optional[typing.OrderedDict[str, PreCallback[A]]] = None,
-        post: Optional[typing.OrderedDict[str, PostCallback[A]]] = None,
-    ):
-        self.pre = pre or OrderedDict()
-        self.post = post or OrderedDict()
-
-    def __repr__(self):
-        return f"{self.__class__.__name__}(pre={self.pre}, post={self.post})"
diff --git a/labrea/coalesce.py b/labrea/coalesce.py
index 0f571f1..a07f4bd 100644
--- a/labrea/coalesce.py
+++ b/labrea/coalesce.py
@@ -1,8 +1,9 @@
-from typing import List, Set, TypeVar, Union
+from typing import Any, List, Optional, Set, TypeVar
 
-from .types import Evaluatable, EvaluationError, JSONDict, ValidationError, Value
+from .exceptions import EvaluationError
+from .types import Evaluatable, MaybeEvaluatable, Options
 
-A = TypeVar("A")
+A = TypeVar("A", covariant=True)
 
 
 class Coalesce(Evaluatable[A]):
@@ -15,69 +16,53 @@ class Coalesce(Evaluatable[A]):
 
     members: List[Evaluatable[A]]
 
-    def __init__(self, __first, *__rest: Union[A, Evaluatable[A]]):
+    def __init__(self, __first: MaybeEvaluatable[A], *__rest: MaybeEvaluatable[A]):
         """Create a new Coalesce Evaluatable
 
         Parameters
         ----------
-        __first : Union[A, Evaluatable[A]]
+        __first : MaybeEvaluatable[A]
             The first Evaluatable to try to evaluate.
-        *__rest : Union[A, Evaluatable[A]]
+        *__rest : MaybeEvaluatable[A]
             The rest of the Evaluatables to try to evaluate.
         """
-        self.members = [
-            arg if isinstance(arg, Evaluatable) else Value(arg)
-            for arg in (__first, *__rest)
-        ]
+        self.members = [Evaluatable.ensure(arg) for arg in (__first, *__rest)]
 
-    def evaluate(self, options: JSONDict) -> A:
-        """Evaluate the first Evaluatable that can be evaluated
+    def evaluate(self, options: Options) -> A:
+        """Evaluate the first Evaluatable that can be evaluated"""
+        return self._delegate("evaluate", options)
 
-        See Also
-        --------
-        labrea.types.Evaluatable.evaluate
-        """
-        for member in self.members:
-            try:
-                member.validate(options)
-                return member.evaluate(options)
-            except (ValidationError, EvaluationError):
-                continue
+    def validate(self, options: Options) -> None:
+        """Determines if any of the Evaluatables can be validated."""
+        return self._delegate("validate", options)
 
-        raise EvaluationError("No members of Coalesce returned a result")
+    def keys(self, options: Options) -> Set[str]:
+        """Return the keys for the first Evaluatable that can be evaluated."""
+        return self._delegate("keys", options)
 
-    def validate(self, options: JSONDict) -> None:
-        """Attempt to validate each Evaluatable in order
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        """Return the explanation for the first Evaluatable that can be evaluated."""
+        try:
+            return self._delegate("explain", options)
+        except EvaluationError:
+            return self.members[-1].explain(options)
 
-        If none can be validated, raises a ValidationError.
+    def _delegate(self, method: str, options: Optional[Options] = None) -> Any:
+        """Delegate a method to the first Evaluatable that can be evaluated."""
+        options = options or {}
+        err: Optional[EvaluationError] = None
 
-        See Also
-        --------
-        labrea.types.Validatable.validate
-        """
         for member in self.members:
             try:
                 member.validate(options)
-                return
-            except ValidationError:
-                continue
+                return getattr(member, method)(options)
+            except EvaluationError as e:
+                err = e
 
-        self.members[0].validate(options)
+        raise err  # type: ignore
 
-    def keys(self, options: JSONDict) -> Set[str]:
-        """Return the keys for the first Evaluatable that can be validated
+    def __repr__(self) -> str:
+        return f"Coalesce({', '.join(map(repr, self.members))})"
 
-        If none can be validated, returns an empty set.
-
-        See Also
-        --------
-        labrea.types.Validatable.keys
-        """
-        for member in self.members:
-            try:
-                member.validate(options)
-                return member.keys(options)
-            except ValidationError:
-                continue
 
-        return set()
+coalesce = Coalesce
diff --git a/labrea/collections.py b/labrea/collections.py
index 85151b6..662d77e 100644
--- a/labrea/collections.py
+++ b/labrea/collections.py
@@ -1,114 +1,31 @@
-from typing import Dict, List, Set, TypeVar
+from typing import Dict, Hashable, List, Set, Tuple, TypeVar, Union
 
-from .types import Evaluatable, JSONDict
+from .iterable import Iter
+from .types import Evaluatable, MaybeEvaluatable, Value
 
 A = TypeVar("A")
-K = TypeVar("K")
+K = TypeVar("K", bound=Hashable)
+V = TypeVar("V")
 
 
-class DatasetList(Evaluatable[List[A]]):
-    """A list of Evaluatables that can be evaluated as a list of values
+def evaluatable_list(*evaluatables: MaybeEvaluatable[A]) -> Evaluatable[List[A]]:
+    return Iter(*evaluatables).apply(list)
 
-    Turns a list of Evaluatables into a single Evaluatable that evaluates to a
-    list of values.
-    """
 
-    contents: List[Evaluatable[A]]
+def evaluatable_tuple(*evaluatables: MaybeEvaluatable[A]) -> Evaluatable[Tuple[A, ...]]:
+    return Iter(*evaluatables).apply(tuple)
 
-    def __init__(self, *contents: Evaluatable[A]):
-        """Create a new DatasetList Evaluatable
 
-        Parameters
-        ----------
-        *contents : Evaluatable[A]
-            The Evaluatables to include in the list.
-        """
-        self.contents = list(contents)
+def evaluatable_set(*evaluatables: MaybeEvaluatable[K]) -> Evaluatable[Set[K]]:
+    return Iter(*evaluatables).apply(set)
 
-    def evaluate(self, options: JSONDict) -> List[A]:
-        """Evaluate the Evaluatables in the list
 
-        Return a list containing the results of evaluating each Evaluatable in
-        the list.
+def evaluatable_dict(contents: Dict[K, MaybeEvaluatable[V]]) -> Evaluatable[Dict[K, V]]:
+    pairs = (Iter[Union[K, V]](Value(key), val) for key, val in contents.items())
+    return Iter(*pairs).apply(dict)  # type: ignore
 
-        See Also
-        --------
-        labrea.types.Evaluatable.evaluate
-        """
-        return [dep.evaluate(options) for dep in self.contents]
 
-    def validate(self, options: JSONDict):
-        """Validate each Evaluatable in the list.
-
-        See Also
-        --------
-        labrea.types.Validatable.validate
-        """
-        for val in self.contents:
-            val.validate(options)
-
-    def keys(self, options: JSONDict) -> Set[str]:
-        """Return the keys that each Evaluatable in the list depends on.
-
-        See Also
-        --------
-        labrea.types.Validatable.keys
-        """
-        return {key for val in self.contents for key in val.keys(options)}
-
-    def __repr__(self):
-        return f'DatasetList({", ".join(map(repr, self.contents))})'
-
-
-class DatasetDict(Evaluatable[Dict[K, A]]):
-    """A dict of Evaluatables that can be evaluated as a dict of values
-
-    Turns a dict of Evaluatables into a single Evaluatable that evaluates to a
-    dict of values.
-    """
-
-    contents: Dict[K, Evaluatable[A]]
-
-    def __init__(self, contents: Dict[K, Evaluatable[A]]):
-        """Create a new DatasetDict Evaluatable
-
-        Parameters
-        ----------
-        contents : Dict[K, Evaluatable[A]]
-            The Evaluatables to include in the dict.
-        """
-        self.contents = contents.copy()
-
-    def evaluate(self, options: JSONDict) -> Dict[K, A]:
-        """Evaluate the Evaluatables in the dict
-
-        Return a dict containing the results of evaluating each Evaluatable in
-        the dict.
-
-        See Also
-        --------
-        labrea.types.Evaluatable.evaluate
-        """
-        return {key: val.evaluate(options) for key, val in self.contents.items()}
-
-    def validate(self, options: JSONDict):
-        """Validate each Evaluatable in the dict values.
-
-        See Also
-        --------
-        labrea.types.Validatable.validate
-        """
-        for val in self.contents.values():
-            val.validate(options)
-
-    def keys(self, options: JSONDict) -> Set[str]:
-        """Return the keys that each Evaluatable in the dict values depends on.
-
-        See Also
-        --------
-        labrea.types.Validatable.keys
-        """
-        return {key for val in self.contents.values() for key in val.keys(options)}
-
-    def __repr__(self):
-        return f"DatasetDict({repr(self.contents)})"
+DatasetList = evaluatable_list
+DatasetTuple = evaluatable_tuple
+DatasetSet = evaluatable_set
+DatasetDict = evaluatable_dict
diff --git a/labrea/computation.py b/labrea/computation.py
new file mode 100644
index 0000000..e71ed8e
--- /dev/null
+++ b/labrea/computation.py
@@ -0,0 +1,106 @@
+from abc import ABC, abstractmethod
+from typing import Callable, Generic, List, Optional, Set, TypeVar
+
+from .types import (
+    Evaluatable,
+    Explainable,
+    MaybeEvaluatable,
+    Options,
+    Transformation,
+    Validatable,
+)
+
+A = TypeVar("A")
+B = TypeVar("B")
+
+
+class Effect(Transformation[A, B], Validatable, Explainable, ABC):
+    @abstractmethod
+    def transform(self, value: A, options: Optional[Options] = None) -> B:
+        """Apply the effect to a value.
+
+        Arguments
+        ----------
+        value : A
+            The value to apply the effect to.
+        options : Options
+            The options dictionary to evaluate against.
+
+        Returns
+        -------
+        A
+            The value after the effect has been applied.
+
+        Raises
+        ------
+        EvaluationError
+            If the effect cannot be applied to the value.
+        """
+        raise NotImplementedError  # pragma: nocover
+
+
+class ChainedEffect(Effect[A, A]):
+    effects: List[Effect[A, A]]
+
+    def __init__(self, *effects: Effect[A, A]):
+        self.effects = list(effects)
+
+    def transform(self, value: A, options: Optional[Options] = None) -> A:
+        for effect in self.effects:
+            value = effect.transform(value, options)
+        return value
+
+    def validate(self, options: Options) -> None:
+        for effect in self.effects:
+            effect.validate(options)
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        return set().union(*(effect.explain(options) for effect in self.effects))
+
+    def __repr__(self) -> str:
+        return f"ChainedEffect({', '.join(map(repr, self.effects))})"
+
+
+class CallbackEffect(Effect[A, B]):
+    callback: Evaluatable[Callable[[A], B]]
+
+    def __init__(self, callback: MaybeEvaluatable[Callable[[A], B]]):
+        self.callback = Evaluatable.ensure(callback)
+
+    def transform(self, value: A, options: Optional[Options] = None) -> B:
+        return self.callback(options)(value)
+
+    def validate(self, options: Options) -> None:
+        self.callback.validate(options)
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        return self.callback.explain(options)
+
+    def __repr__(self) -> str:
+        return f"CallbackEffect({self.callback!r})"
+
+
+class Computation(Generic[A, B], Evaluatable[B]):
+    evaluatable: Evaluatable[A]
+    effect: Effect[A, B]
+
+    def __init__(self, evaluatable: Evaluatable[A], effect: Effect[A, B]):
+        self.evaluatable = evaluatable
+        self.effect = effect
+
+    def evaluate(self, options: Options) -> B:
+        value = self.evaluatable.evaluate(options)
+        return self.effect.transform(value, options)
+
+    def validate(self, options: Options) -> None:
+        self.evaluatable.validate(options)
+        self.effect.validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        return self.evaluatable.keys(options)
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        return self.evaluatable.explain(options) | self.effect.explain(options)
+
+    def __repr__(self) -> str:
+        return f"Computation({self.evaluatable!r}, {self.effect!r})"
diff --git a/labrea/conditional.py b/labrea/conditional.py
new file mode 100644
index 0000000..dd0ecca
--- /dev/null
+++ b/labrea/conditional.py
@@ -0,0 +1,217 @@
+import functools
+from typing import (
+    Callable,
+    Generic,
+    Hashable,
+    Mapping,
+    Optional,
+    Sequence,
+    Set,
+    Tuple,
+    TypeVar,
+    Union,
+)
+
+from ._missing import MISSING, MaybeMissing
+from .exceptions import EvaluationError, InsufficientInformationError
+from .option import Option
+from .types import Evaluatable, MaybeEvaluatable, Options
+
+A = TypeVar("A")
+B = TypeVar("B")
+K = TypeVar("K", bound=Hashable)
+V = TypeVar("V")
+
+
+class SwitchError(EvaluationError):
+    """An error raised when a switch statement encounters an invalid key."""
+
+    def __init__(
+        self,
+        dispatch: Evaluatable[K],
+        value: K,
+        lookup: Mapping[K, MaybeEvaluatable[V]],
+    ):
+        super().__init__(
+            f"Evaluated to {value}, "
+            f"but must be one of {', '.join(map(str, lookup.keys()))}.",
+            dispatch,
+        )
+
+
+class _DependsOn(Generic[A, B], Evaluatable[B]):
+    evaluatable: Evaluatable[B]
+    depends: Evaluatable[A]
+
+    def __init__(
+        self, evaluatable: MaybeEvaluatable[B], depends: MaybeEvaluatable[A]
+    ) -> None:
+        self.evaluatable = Evaluatable.ensure(evaluatable)
+        self.depends = Evaluatable.ensure(depends)
+
+    def evaluate(self, options: Options) -> B:
+        return self.evaluatable.evaluate(options)
+
+    def validate(self, options: Options) -> None:
+        self.evaluatable.validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        return self.evaluatable.keys(options) | self.depends.keys(options)
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        return self.evaluatable.explain(options) | self.depends.explain(options)
+
+    def __repr__(self) -> str:
+        return f"_DependsOn({self.evaluatable!r}, {self.depends!r})"  # pragma: no cover
+
+
+class Switch(Generic[K, V], Evaluatable[V]):
+    dispatch: Evaluatable[K]
+    lookup: Mapping[K, Evaluatable[V]]
+    default: MaybeMissing[Evaluatable[V]]
+
+    def __init__(
+        self,
+        dispatch: Union[str, Evaluatable[K]],
+        lookup: Mapping[K, MaybeEvaluatable[V]],
+        default: MaybeMissing[MaybeEvaluatable[V]] = MISSING,
+    ) -> None:
+        self.dispatch = (
+            dispatch if isinstance(dispatch, Evaluatable) else Option(dispatch)
+        )
+        self.lookup = {key: Evaluatable.ensure(value) for key, value in lookup.items()}
+        self.default = (
+            Evaluatable.ensure(default) if default is not MISSING else default
+        )
+
+    def _dispatch(self, options: Options) -> K:
+        return self.dispatch.evaluate(options)
+
+    def _lookup(self, options: Options) -> Evaluatable[V]:
+        try:
+            key = self._dispatch(options)
+        except EvaluationError as e:
+            if self.default is MISSING:
+                raise e
+            return self.default
+
+        if key not in self.lookup:
+            if self.default is MISSING:
+                raise SwitchError(self.dispatch, key, self.lookup)  # type: ignore  [arg-type]
+            return self.default
+
+        return _DependsOn(self.lookup[key], self.dispatch)  # type: ignore  [arg-type]
+
+    def evaluate(self, options: Options) -> V:
+        return self._lookup(options).evaluate(options)
+
+    def validate(self, options: Options) -> None:
+        self._lookup(options).validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        return self._lookup(options).keys(options)
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        options = options or {}
+        try:
+            chosen = self._lookup(options)
+        except EvaluationError as e:
+            raise InsufficientInformationError(
+                "Could not determine the switch branch", self
+            ) from e
+
+        return chosen.explain(options)
+
+    def __repr__(self) -> str:
+        if self.default is MISSING:
+            return f"switch({self.dispatch!r}, {self.lookup!r})"
+
+        return f"switch({self.dispatch!r}, {self.lookup!r}, {self.default!r})"
+
+
+switch = Switch
+
+
+class CaseWhenError(EvaluationError):
+    """An error raised when a case when statement encounters an invalid key."""
+
+    def __init__(self, dispatch: Evaluatable[A], value: A):
+        super().__init__(
+            f"Evaluated to {value}, which does not match any of the cases, "
+            f"and no default was provided.",
+            dispatch,
+        )
+
+
+class CaseWhen(Generic[A, B], Evaluatable[B]):
+    """A class representing a case when statement."""
+
+    dispatch: Evaluatable[A]
+    cases: Sequence[Tuple[Evaluatable[Callable[[A], bool]], Evaluatable[B]]]
+    default: MaybeMissing[Evaluatable[B]]
+
+    def __init__(
+        self,
+        dispatch: MaybeEvaluatable[A],
+        cases: Sequence[
+            Tuple[MaybeEvaluatable[Callable[[A], bool]], MaybeEvaluatable[B]]
+        ],
+        default: MaybeMissing[MaybeEvaluatable[B]] = MISSING,
+    ) -> None:
+        self.dispatch = Evaluatable.ensure(dispatch)
+        self.cases = [
+            (Evaluatable.ensure(condition), Evaluatable.ensure(result))
+            for condition, result in cases
+        ]
+        self.default = (
+            Evaluatable.ensure(default) if default is not MISSING else default
+        )
+
+    def _evaluate(self, value: A, options: Options) -> Evaluatable[B]:
+        for condition, result in self.cases:
+            if condition.evaluate(options)(value):
+                return result
+
+        if self.default is not MISSING:
+            return self.default
+
+        raise CaseWhenError(self.dispatch, value)
+
+    def _bound(self, options: Options) -> Evaluatable[B]:
+        return self.dispatch.bind(functools.partial(self._evaluate, options=options))
+
+    def evaluate(self, options: Options) -> B:
+        return self._bound(options).evaluate(options)
+
+    def validate(self, options: Options) -> None:
+        self._bound(options).validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        return self._bound(options).keys(options)
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        return self._bound(options or {}).explain(options)
+
+    def when(
+        self,
+        condition: MaybeEvaluatable[Callable[[A], bool]],
+        result: MaybeEvaluatable[B],
+    ) -> "CaseWhen[A, B]":
+        return CaseWhen(self.dispatch, [*self.cases, (condition, result)], self.default)
+
+    def otherwise(self, default: MaybeEvaluatable[B]) -> "CaseWhen[A, B]":
+        return CaseWhen(self.dispatch, self.cases, default)
+
+    def __repr__(self) -> str:
+        _base = f"case({self.dispatch!r})"
+        _cases = ", ".join(
+            f"when({condition!r}, {result!r})" for condition, result in self.cases
+        )
+        _default = (
+            f".otherwise({self.default!r})" if self.default is not MISSING else ""
+        )
+        return f"{_base}.{_cases}{_default}"
+
+
+def case(dispatch: MaybeEvaluatable[A]) -> CaseWhen[A, B]:
+    return CaseWhen(dispatch, [])
diff --git a/labrea/dataset.py b/labrea/dataset.py
new file mode 100644
index 0000000..e3a8521
--- /dev/null
+++ b/labrea/dataset.py
@@ -0,0 +1,313 @@
+import sys
+
+if sys.version_info < (3, 10):
+    from typing_extensions import ParamSpec
+else:
+    from typing import ParamSpec
+
+import functools
+import typing
+from collections import OrderedDict
+from typing import (
+    Any,
+    Callable,
+    Dict,
+    Generic,
+    Hashable,
+    List,
+    Mapping,
+    Optional,
+    Set,
+    TypeVar,
+    Union,
+    overload,
+)
+
+from ._missing import MISSING, MaybeMissing
+from .application import FunctionApplication
+from .cache import Cache, MemoryCache, NoCache, cached
+from .computation import CallbackEffect, ChainedEffect, Computation, Effect
+from .option import Option, WithOptions
+from .overload import Overloaded
+from .types import Evaluatable, MaybeEvaluatable, Options, Value
+
+A = TypeVar("A", covariant=True)
+P = ParamSpec("P")
+EffectSet = typing.OrderedDict[str, Effect[A, A]]
+Callback = MaybeEvaluatable[Callable[[A], A]]
+
+
+class Dataset(Evaluatable[A]):
+    overloads: Overloaded[A]
+    effects: EffectSet[A]
+    cache: Cache[A]
+    options: Options
+
+    def __init__(
+        self,
+        overloads: Overloaded[A],
+        effects: EffectSet[A],
+        cache: Cache[A],
+        options: Options,
+    ):
+        self.overloads = overloads
+        self.effects = effects
+        self.cache = cache
+        self.options = options
+
+    @property
+    def _composed(self) -> Evaluatable[A]:
+        return WithOptions(
+            cached(
+                Computation(
+                    self.overloads,
+                    ChainedEffect(*self.effects.values()),
+                ),
+                self.cache,
+            ),
+            self.options,
+        )
+
+    def evaluate(self, options: Options) -> A:
+        return self._composed.evaluate(options)
+
+    def validate(self, options: Options) -> None:
+        self._composed.validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        return self._composed.keys(options)
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        return self._composed.explain(options)
+
+    def __repr__(self) -> str:
+        kind = "AbstractDataset" if self.is_abstract else "Dataset"
+        if hasattr(self, "__qualname__"):
+            return f"<{kind} {self.__qualname__}>"
+        if hasattr(self, "__name__"):
+            return f"<{kind} {self.__name__}>"
+
+        return (  # pragma: no cover
+            f"Dataset("
+            f"{self.overloads!r}, "
+            f"{', '.join(map(repr, self.effects))}, "
+            f"{self.cache!r})"
+        )
+
+    def overload(
+        self,
+        alias: Union[Hashable, List[Hashable]],
+    ) -> Callable[[Callable[P, A]], FunctionApplication[P, A]]:
+        return self.overloads.overload(alias)
+
+    def register(self, key: Hashable, value: Evaluatable[A]) -> None:
+        self.overloads.register(key, value)
+
+    def set_dispatch(self, dispatch: Evaluatable[Hashable]) -> None:
+        self.overloads = Overloaded(
+            dispatch,
+            self.overloads.lookup.copy(),
+            default=self.overloads.default,
+        )
+
+    def set_effect(self, name: str, effect: Union[Effect[A, A], Callback[A]]) -> None:
+        self.effects = OrderedDict(
+            [
+                *self.effects.items(),
+                (
+                    name,
+                    effect if isinstance(effect, Effect) else CallbackEffect(effect),
+                ),
+            ]
+        )
+
+    add_effect = set_effect
+
+    def drop_effect(self, name: str) -> None:
+        self.effects = OrderedDict(
+            [(key, val) for key, val in self.effects.items() if key != name]
+        )
+
+    def set_cache(self, cache: Union[Cache[A], Callable[..., Cache[A]]]) -> None:
+        cache = cache if isinstance(cache, Cache) else cache()
+        if not isinstance(cache, Cache):
+            raise TypeError(f"Invalid cache: {cache}")
+
+        self.cache = cache
+
+    @property
+    def default(self) -> MaybeMissing[Evaluatable[A]]:
+        return self.overloads.default
+
+    @property
+    def is_abstract(self) -> bool:
+        return self.overloads.default is MISSING
+
+
+class DatasetFactory(Generic[A]):
+    effects: EffectSet[A]
+    cache: Union[Cache[A], Callable[..., Cache[A]], None]
+    dispatch: Evaluatable[Hashable]
+    defaults: Dict[str, Evaluatable[Any]]
+    options: Options
+    abstract: bool
+
+    def __init__(
+        self,
+        effects: Optional[EffectSet[A]] = None,
+        cache: Union[Cache[A], Callable[..., Cache[A]], None] = None,
+        dispatch: Union[Evaluatable[Hashable], str, None] = None,
+        defaults: Optional[Dict[str, MaybeEvaluatable[Any]]] = None,
+        abstract: bool = False,
+        options: Optional[Options] = None,
+    ):
+        self.effects = effects or OrderedDict()
+        self.cache = cache
+        self.defaults = {
+            key: Evaluatable.ensure(val) for key, val in (defaults or {}).items()
+        }
+        self.abstract = abstract
+        self.options = options or {}
+
+        if dispatch is None:
+            self.dispatch = Value(MISSING)
+        elif isinstance(dispatch, str):
+            self.dispatch = Option(dispatch)
+        else:
+            self.dispatch = dispatch
+
+    @overload
+    def __call__(
+        self,
+        definition: Callable[..., A],
+        /,
+        *,
+        effects: Optional[Mapping[str, Union[Effect[A, A], Callback[A]]]] = ...,
+        cache: Union[Cache[A], Callable[..., Cache[A]], None] = ...,
+        dispatch: Optional[Union[Evaluatable[Hashable], str]] = ...,
+        defaults: Optional[Dict[str, MaybeEvaluatable[Any]]] = ...,
+        abstract: Optional[bool] = ...,
+        options: Optional[Options] = ...,
+    ) -> Dataset[A]:
+        ...  # pragma: no cover
+
+    @overload
+    def __call__(
+        self,
+        /,
+        *,
+        effects: Optional[Mapping[str, Union[Effect[A, A], Callback[A]]]] = ...,
+        cache: Union[Cache[A], Callable[..., Cache[A]], None] = ...,
+        dispatch: Optional[Union[Evaluatable[Hashable], str]] = ...,
+        defaults: Optional[Dict[str, MaybeEvaluatable[Any]]] = ...,
+        abstract: Optional[bool] = ...,
+        options: Optional[Options] = ...,
+    ) -> "DatasetFactory[A]":
+        ...  # pragma: no cover
+
+    @overload
+    def __call__(
+        self,
+        definition: None,
+        /,
+        *,
+        effects: Optional[Mapping[str, Union[Effect[A, A], Callback[A]]]] = ...,
+        cache: Union[Cache[A], Callable[..., Cache[A]], None] = ...,
+        dispatch: Optional[Union[Evaluatable[Hashable], str]] = ...,
+        defaults: Optional[Dict[str, MaybeEvaluatable[Any]]] = ...,
+        abstract: Optional[bool] = ...,
+        options: Optional[Options] = ...,
+    ) -> "DatasetFactory[A]":
+        ...  # pragma: no cover
+
+    def __call__(
+        self,
+        definition: Optional[Callable[..., A]] = None,
+        /,
+        *,
+        effects: Optional[Mapping[str, Union[Effect[A, A], Callback[A]]]] = None,
+        cache: Union[Cache[A], Callable[..., Cache[A]], None] = None,
+        dispatch: Optional[Union[Evaluatable[Hashable], str]] = None,
+        defaults: Optional[Dict[str, MaybeEvaluatable[Any]]] = None,
+        abstract: Optional[bool] = None,
+        options: Optional[Options] = None,
+    ) -> Union["DatasetFactory", Dataset[A]]:
+        _effects = OrderedDict(
+            [
+                (key, effect if isinstance(effect, Effect) else CallbackEffect(effect))
+                for key, effect in (effects or {}).items()
+            ]
+        )
+        factory = self.update(
+            effects=_effects,
+            cache=cache,
+            dispatch=dispatch,
+            defaults=defaults,
+            abstract=abstract,
+            options=options,
+        )
+
+        if definition is not None:
+            return factory.wrap(definition)
+
+        return factory
+
+    def wrap(self, definition: Callable[..., A]) -> Dataset[A]:
+        overloads: Overloaded[A]
+        if not self.abstract:
+            lifted = FunctionApplication.lift(definition, **self.defaults)
+            overloads = Overloaded(self.dispatch, {}, lifted)
+        else:
+            if self.dispatch == Value(MISSING):
+                raise ValueError("Abstract datasets must have a dispatch")
+            overloads = Overloaded(self.dispatch, {})
+
+        cache: Cache
+        if self.cache is None:
+            cache = MemoryCache()
+        elif callable(self.cache):
+            cache = self.cache()
+        elif isinstance(self.cache, Cache):
+            cache = self.cache
+        else:
+            raise TypeError(f"Invalid cache: {self.cache}")
+
+        _dataset = Dataset(
+            overloads,
+            effects=self.effects,
+            cache=cache,
+            options=self.options,
+        )
+
+        functools.update_wrapper(_dataset, definition, updated=())
+
+        return _dataset
+
+    def where(self, **defaults: MaybeEvaluatable[Any]) -> "DatasetFactory":
+        return self.update(defaults=defaults)
+
+    def update(
+        self,
+        effects: Optional[EffectSet[A]] = None,
+        cache: Union[Cache[A], Callable[..., Cache[A]], None] = None,
+        dispatch: Optional[Union[Evaluatable[Hashable], str]] = None,
+        defaults: Optional[Dict[str, MaybeEvaluatable[Any]]] = None,
+        abstract: Optional[bool] = None,
+        options: Optional[Options] = None,
+    ) -> "DatasetFactory":
+        return DatasetFactory(
+            effects=OrderedDict([*self.effects.items(), *(effects or {}).items()]),
+            cache=cache or self.cache,
+            dispatch=dispatch or self.dispatch,
+            defaults={**self.defaults, **(defaults or {})},
+            abstract=abstract if abstract is not None else self.abstract,
+            options=options or self.options,
+        )
+
+    @property
+    def nocache(self) -> "DatasetFactory":
+        return self.update(cache=NoCache())
+
+
+dataset: DatasetFactory = DatasetFactory()
+abstractdataset: DatasetFactory = dataset(abstract=True)
diff --git a/labrea/datasetclass.py b/labrea/datasetclass.py
new file mode 100644
index 0000000..00b067c
--- /dev/null
+++ b/labrea/datasetclass.py
@@ -0,0 +1,87 @@
+import sys
+
+if sys.version_info < (3, 11):
+    from typing_extensions import Self
+else:
+    from typing import Self
+
+import functools
+from dataclasses import dataclass, field, fields
+from typing import Iterable, Optional, Set, Type, TypeVar
+
+from .types import Evaluatable, Options
+
+T = TypeVar("T")
+D = TypeVar("D", bound="_DatasetClassMixin")
+
+
+class _DatasetClassMixin:
+    @classmethod
+    def _labrea_evaluate(cls, options: Options) -> Self:
+        self = cls()
+        for key, value in self.__dict__.items():
+            if not key.startswith("__") and isinstance(value, Evaluatable):
+                setattr(self, key, value.evaluate(options))
+
+        return self
+
+    @classmethod
+    def _labrea_validate(cls, options: Options) -> None:
+        for fld in cls._labrea_fields():
+            fld.validate(options)
+
+    @classmethod
+    def _labrea_keys(cls, options: Options) -> Set[str]:
+        return set().union(*(fld.keys(options) for fld in cls._labrea_fields()))
+
+    @classmethod
+    def _labrea_explain(cls, options: Optional[Options] = None) -> Set[str]:
+        return set().union(*(fld.explain(options) for fld in cls._labrea_fields()))
+
+    @classmethod
+    def _labrea_fields(cls) -> Iterable[Evaluatable]:
+        return (fld.default_factory() for fld in fields(cls))  # type: ignore
+
+
+class _DatasetClassEvaluatable(Evaluatable[D]):
+    datasetclass: Type[D]
+
+    def __init__(self, cls: Type[D]):
+        self.datasetclass = cls
+        functools.update_wrapper(self, cls, updated=())
+
+    def evaluate(self, options: Options) -> D:
+        return self.datasetclass._labrea_evaluate(options)
+
+    def validate(self, options: Options) -> None:
+        self.datasetclass._labrea_validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        return self.datasetclass._labrea_keys(options)
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        return self.datasetclass._labrea_explain(options)
+
+    def __repr__(self):
+        return f"<DatasetClass {self.datasetclass.__qualname__}>"
+
+
+def datasetclass(cls: Type[T]) -> Evaluatable[T]:
+    for key in getattr(cls, "__annotations__", {}):
+        try:
+            default = getattr(cls, key)
+        except AttributeError as e:
+            raise ValueError(f"Missing default value for field: {key}") from e
+
+        def _default_factory(x):
+            return lambda: Evaluatable.ensure(x)
+
+        setattr(cls, key, field(default_factory=_default_factory(default)))
+
+    @dataclass
+    class _DatasetClass(_DatasetClassMixin, dataclass(cls)):  # type: ignore
+        pass
+
+    functools.update_wrapper(_DatasetClass, cls, updated=())
+
+    return _DatasetClassEvaluatable(_DatasetClass)  # type: ignore
diff --git a/labrea/datasetclasses.py b/labrea/datasetclasses.py
deleted file mode 100644
index e0e2060..0000000
--- a/labrea/datasetclasses.py
+++ /dev/null
@@ -1,113 +0,0 @@
-import functools
-import operator
-from types import new_class
-from typing import Any, Generic, Set, Type, TypeVar
-
-from .types import Evaluatable, JSONDict, Value
-
-A = TypeVar("A")
-
-
-class DatasetClassMeta(type, Generic[A]):
-    def __init__(cls, *args, **kwargs):
-        annotations = functools.reduce(
-            operator.or_,
-            (
-                getattr(base, "__annotations__", {})
-                for base in reversed(cls.__bases__)
-                if base is not DatasetClass
-            ),
-        )
-
-        for key in annotations.keys():
-            try:
-                val = getattr(cls, key)
-            except AttributeError:
-                raise AttributeError(
-                    f"Annotation {key} on class {cls.__name__} has "
-                    f"no default value."
-                )
-
-            if not isinstance(val, Evaluatable):
-                setattr(cls, key, Field(val))
-
-        super().__init__(*args, **kwargs)
-
-    def evaluate(cls, options: JSONDict) -> A:
-        return cls(options)
-
-    def validate(cls, options: JSONDict) -> None:
-        for key in dir(cls):
-            dependency = getattr(cls, key, None)
-            if isinstance(dependency, Evaluatable) and not key.startswith("__"):
-                dependency.validate(options)
-
-    def keys(cls, options: JSONDict) -> Set[str]:
-        return {
-            key
-            for name in dir(cls)
-            if (
-                isinstance(getattr(cls, name), Evaluatable)
-                and not name.startswith("__")
-            )
-            for key in getattr(cls, name).keys(options)
-        }
-
-    @property
-    def result(cls) -> A:
-        return cls  # type: ignore
-
-
-class Field(Value):
-    """A wrapper for a static value.
-
-    This is an alias for :class:`labrea.types.Value`.
-    """
-
-    pass
-
-
-class DatasetClass:
-    def __init__(self, options: JSONDict):
-        key: str
-        val: Any
-        for key in dir(self.__class__):
-            val = getattr(self, key)
-            if isinstance(val, Evaluatable) and not key.startswith("__"):
-                setattr(self, key, val.evaluate(options))
-
-
-def datasetclass(c: Type[A]) -> DatasetClassMeta[A]:
-    """Create a new DatasetClass from a class definition.
-
-    DatasetClasses are classes that when instatiated, evaluate their
-    members using the options dict provided to the constructor. This
-    allows for the definition of complex data structures that can be
-    evaluated at runtime.
-
-    Any members of the class that are not Evaluatables are wrapped in
-    :class:`labrea.datasetclasses.Field` instances.
-
-    Example Usage
-    -------------
-    >>> from labrea import dataset, datasetclass, Option
-    >>> @dataset
-    ... def my_dataset(a: str = Option('A')) -> str:
-    ...     return a
-    >>>
-    >>> @datasetclass
-    ... class MyDatasetClass:
-    ...     a: str = my_dataset
-    ...     b: int = Option('B')
-    ...     c: bool = True
-    ...
-    >>> inst = MyDatasetClass({'A': 'Hello World!', 'B': 1})
-    >>> print(inst.a, inst.b, inst.c)  # Hello World! 1 True
-    """
-    dataset_class = new_class(
-        c.__name__, (c, DatasetClass), kwds={"metaclass": DatasetClassMeta}
-    )
-
-    functools.update_wrapper(dataset_class, c, updated=())
-
-    return dataset_class  # type: ignore
diff --git a/labrea/datasets.py b/labrea/datasets.py
deleted file mode 100644
index 187ae19..0000000
--- a/labrea/datasets.py
+++ /dev/null
@@ -1,1012 +0,0 @@
-import concurrent.futures
-import functools
-import inspect
-import logging
-import math
-import threading
-import typing
-import uuid
-import warnings
-from abc import ABC, abstractmethod
-from collections import OrderedDict
-from typing import (
-    Any,
-    Callable,
-    Dict,
-    Generic,
-    List,
-    Optional,
-    Set,
-    Type,
-    TypeVar,
-    Union,
-)
-
-from confectioner import mix
-
-import labrea.multithreading
-
-from ._aliases import default_alias, default_aliases
-from ._import import require
-from .cache import Cache, CacheInvalidationError, MemoryCache, NoCache
-from .callbacks import Callbacks, EarlyExit, PostCallback, PreCallback
-from .options import Option
-from .pipelines import LabreaPipelineData
-from .switch import Switch
-from .types import Alias, Evaluatable, EvaluationError, JSONDict, MultiAlias, Value
-
-A = TypeVar("A")
-T = TypeVar("T")
-
-
-class DatasetLike(ABC, Evaluatable[A]):
-    name: str
-    _callbacks: Callbacks[A]
-    _cache: Cache[A]
-    _options: JSONDict
-    _lock: threading.Lock
-    _logger: logging.Logger
-
-    def __init__(
-        self,
-        cache: Union[Callable[[Evaluatable], Cache], Cache, None] = None,
-        options: Optional[JSONDict] = None,
-        callbacks: Union[Callbacks[A], Dict[str, PostCallback[A]], None] = None,
-        lock: Optional[threading.Lock] = None,
-        logger: Optional[logging.Logger] = None,
-        name: Optional[str] = None,
-        doc: Optional[str] = None,
-        **kwargs,
-    ):
-        if isinstance(cache, Cache):
-            self._cache = cache
-        else:
-            self._cache = (cache or MemoryCache)(self)
-        self._options = options or {}
-
-        if isinstance(callbacks, dict):
-            self._callbacks = Callbacks(post=OrderedDict(callbacks))
-        elif callbacks is None:
-            self._callbacks = Callbacks()
-        elif not isinstance(callbacks, Callbacks):
-            raise TypeError(
-                f"Callbacks must be a list of PostCallbacks, a Callbacks "
-                f"object, or None, but is of type {type(callbacks)}."
-            )
-        else:
-            self._callbacks = callbacks
-
-        self.name = name or getattr(self, "__name__", str(self))  # type: ignore
-        if doc is not None:
-            self.__doc__ = doc
-
-        self._lock = lock or threading.Lock()
-        self._logger = logger or logging.getLogger(
-            getattr(self, "__module__", __name__)
-        )
-
-    @property
-    def callbacks(self) -> Callbacks[A]:
-        return Callbacks(
-            pre=OrderedDict(
-                {
-                    "__insert_options": self._insert_options,
-                    "__cache_get": self._cache_get,
-                    **self._callbacks.pre,
-                    "__log_evaluation": self._log_evaluation,
-                }
-            ),
-            post=OrderedDict({**self._callbacks.post, "__cache_set": self._cache_set}),
-        )
-
-    def add_pre_callbacks(
-        self, *args: PreCallback[A], **kwargs: PreCallback[A]
-    ) -> None:
-        self._callbacks.pre.update(
-            {
-                **{
-                    f"callback_{str(uuid.uuid4()).replace('-', '')}": callback
-                    for callback in args
-                },
-                **kwargs,
-            }
-        )
-
-    def add_post_callbacks(
-        self, *args: PostCallback[A], **kwargs: PostCallback[A]
-    ) -> None:
-        self._callbacks.post.update(
-            {
-                **{
-                    f"callback_{str(uuid.uuid4()).replace('-', '')}": callback
-                    for callback in args
-                },
-                **kwargs,
-            }
-        )
-
-    def add_callbacks(self, *args: PostCallback[A], **kwargs: PostCallback[A]) -> None:
-        self.add_post_callbacks(*args, **kwargs)
-
-    def _insert_options(self, obj: Evaluatable[A], options: JSONDict) -> JSONDict:
-        return mix(options, self._options)  # type: ignore
-
-    def _cache_get(self, obj: Evaluatable[A], options: JSONDict) -> None:
-        if (
-            options.get("LABREA", {})  # type: ignore
-            .get("CACHE", {})
-            .get("DISABLE", False)
-        ):
-            return
-
-        keys = self._cache.keys(options)
-        if self._cache.exists(keys):
-            self._logger.debug(f"Labrea: Retrieving {self.name} " f"from cache")
-            raise EarlyExit(self._cache.get(keys))
-
-    def _log_evaluation(self, obj: Evaluatable[A], options: JSONDict):
-        self._logger.info(f"Labrea: Evaluating {self.name}")
-
-    def _cache_set(self, value: A, options: JSONDict) -> A:
-        keys = self._cache.keys(options)
-        self._cache.set(keys, value)
-        if self._cache.exists(keys):
-            return self._cache.get(keys)
-        else:
-            return value
-
-    @staticmethod
-    def _check_return(
-        old: T, new: Union[T, LabreaPipelineData[Optional[T]], None]
-    ) -> T:
-        if isinstance(new, LabreaPipelineData):
-            new = new.value
-
-        return old if new is None else new
-
-    @abstractmethod
-    def _evaluate(self, options: JSONDict) -> A:
-        pass
-
-    def _evaluate_with_callbacks(self, options: JSONDict) -> A:
-        for name, pre_callback in self.callbacks.pre.items():
-            self._logger.debug(f"Labrea: Running pre-callback {name} for {self.name}")
-            try:
-                options = self._check_return(options, pre_callback(self, options))
-            except EarlyExit as e:
-                return e.value
-
-        value = self._evaluate(options)
-
-        for name, post_callback in self.callbacks.post.items():
-            self._logger.debug(f"Labrea: Running post-callback {name} for {self.name}")
-            try:
-                value = self._check_return(value, post_callback(value, options))
-            except EarlyExit as e:
-                return e.value
-
-        return value
-
-    def evaluate(self, options: JSONDict) -> A:
-        with self._lock:
-            retries = 0
-            max_retries: Union[int, float] = Option(
-                "LABREA.CACHE.MAX_RETRIES", 5
-            ).evaluate(options)
-            max_retries = max_retries or math.inf
-            while True:
-                try:
-                    return self._evaluate_with_callbacks(options)
-                except CacheInvalidationError as e:
-                    if retries < max_retries:
-                        retries += 1
-                        self._logger.debug(
-                            f"Labrea: Caught CacheInvalidationError for "
-                            f"{self.name}: {e.args}"
-                        )
-                        self._logger.debug(
-                            f"Labrea: Performing retry {retries} of "
-                            f"{max_retries} for {self.name}"
-                        )
-                        continue
-                    else:
-                        raise EvaluationError(
-                            f"Cache invalid for {self.name} - "
-                            f"maximum retries exceeded."
-                        ) from e
-
-
-class Overload(DatasetLike[A]):
-    """Overload Class, an Evaluatable wrapper for a function.
-
-    An Overload is an individual implementation of a Dataset.
-
-    Notes
-    -----
-    This class is not intended to be instantiated directly. Instead, use
-    <mydataset>.overload as a decorator.
-
-    See Also
-    --------
-    :class:`Dataset`
-    :meth:`Dataset.overload`
-    """
-
-    _definition: Callable[..., A]
-    _parent: Optional["Dataset"]
-    _eval_kwargs: Dict[str, Evaluatable]
-    _init_kwargs: Dict[str, Any]
-    _aliases: MultiAlias
-
-    def __init__(
-        self,
-        definition: Callable[..., A],
-        parent: Optional["Dataset"] = None,
-        defaults: Optional[Dict[str, Evaluatable]] = None,
-        aliases: Optional[MultiAlias] = None,
-        cache: Union[Callable[[Evaluatable], Cache], Cache, None] = NoCache,
-        _stack: int = 0,
-        **kwargs,
-    ):
-        definition_closure = inspect.getclosurevars(definition)
-        enclosed = {**definition_closure.globals, **definition_closure.nonlocals}
-        for key, val in enclosed.items():
-            if isinstance(val, Evaluatable):
-                warnings.warn(
-                    f"Variable {key} in the body of {definition.__name__} "
-                    f"refers to a {type(val)} object from the enclosing "
-                    f"scope. Did you mean to include {key} in the function "
-                    f"signature like "
-                    f'"def {definition.__name__}({key}={key}, ...)"?',
-                    stacklevel=_stack + 1,
-                )
-
-        signature = inspect.signature(definition)
-        defaults = {} if defaults is None else defaults
-        eval_kwargs = {
-            **{key: val.default for key, val in signature.parameters.items()},
-            **defaults,
-        }
-
-        self._init_kwargs = {
-            "definition": definition,
-            "defaults": defaults,
-            "parent": parent,
-            "aliases": aliases,
-            **kwargs,
-        }
-
-        for key, val in eval_kwargs.items():
-            if val is signature.empty:
-                raise TypeError(
-                    f"Dataset arguments must all have defaults, but {key} has "
-                    f"no default provided in {definition}."
-                )
-            if not isinstance(val, Evaluatable):
-                eval_kwargs[key] = Value(val)
-
-        functools.update_wrapper(self, definition)
-
-        self._definition = definition  # type: ignore
-        self._parent = parent
-        self._eval_kwargs = eval_kwargs
-        self._aliases = aliases or default_aliases(self)
-
-        if self._parent is not None:
-            for alias in self._aliases:
-                self._parent.register(alias, self)
-
-        super().__init__(
-            definition=definition,
-            parent=parent,
-            defaults=defaults,
-            aliases=aliases,
-            cache=cache,
-            **kwargs,
-        )
-
-    def validate(self, options: JSONDict) -> None:
-        """Validate the Overload.
-
-        Validate the Overload using the options dictionary, recursively
-        validating all dependencies.
-
-        See Also
-        --------
-        :meth:`labrea.types.Validatable.validate`
-        """
-        for dependency in self._eval_kwargs.values():
-            dependency.validate(options)
-
-    def keys(self, options: JSONDict) -> Set[str]:
-        """Get the keys that this Overload depends on.
-
-        Get the keys that this Overload depends on using the options
-        dictionary, recursively getting keys for all dependencies.
-
-        See Also
-        --------
-        :meth:`labrea.types.Validatable.keys`
-        """
-        return {
-            key
-            for dependency in self._eval_kwargs.values()
-            for key in dependency.keys(options)
-        }
-
-    def _evaluate_inputs(self, options: JSONDict) -> Dict[str, Any]:
-        if labrea.multithreading.is_parallel():
-            return self._evaluate_inputs_multithread(options)
-        else:
-            return self._evaluate_inputs_singlethread(options)
-
-    def _evaluate_inputs_multithread(self, options: JSONDict) -> Dict[str, Any]:
-        result = {}
-
-        with concurrent.futures.ThreadPoolExecutor() as executor:
-            future_to_key = {
-                executor.submit(
-                    lambda evaluatable, opts: evaluatable.evaluate(opts), val, options
-                ): key
-                for key, val in self._eval_kwargs.items()
-            }
-
-            for future in concurrent.futures.as_completed(future_to_key):
-                key = future_to_key[future]
-                result[key] = future.result()
-
-        return result
-
-    def _evaluate_inputs_singlethread(self, options: JSONDict) -> Dict[str, Any]:
-        return {key: val.evaluate(options) for key, val in self._eval_kwargs.items()}
-
-    def _evaluate(self, options: JSONDict) -> A:
-        return self._definition(**self._evaluate_inputs(options))
-
-    def cached(self, options: JSONDict) -> bool:
-        return options in self._cache
-
-    def where(self, **kwargs) -> "Overload[A]":
-        return OverloadFactory(
-            dataset=self._parent,
-            defaults={**self._init_kwargs.get("defaults", {}), **kwargs},
-            cache=self._init_kwargs.get("cache", None),
-            callbacks=self._callbacks,
-        ).wrap(self._definition)
-
-    def cache(self, cache: Callable[[Evaluatable], Cache]) -> "Overload[A]":
-        return OverloadFactory(
-            dataset=self._parent,
-            defaults=self._init_kwargs.get("defaults", {}).copy(),
-            cache=cache,
-            callbacks=self._callbacks,
-        ).wrap(self._definition)
-
-    @property
-    def nocache(self) -> "Overload[A]":
-        return self.cache(NoCache)
-
-    def __repr__(self):
-        words = []
-
-        if self._parent is None:
-            words.append("Unbound")
-
-        words.append("Overload")
-
-        if self.__qualname__:
-            words.append(self.__qualname__)
-
-        if self._parent is not None:
-            words.extend(["of", self._parent.__repr__()])
-
-        return f'<{" ".join(words)}>'
-
-
-class Dataset(DatasetLike[A]):
-    """Dataset Class, an Evaluatable wrapper for a function.
-
-    A Dataset is an Evaluatable wrapper around a function whose arguments are
-    Evaluatables. The Dataset will evaluate the defaults to each argument
-    using the options dictionary, and then pass the evaluated values to the
-    function. The Dataset will then cache the result of the function call
-    for the specific options used to evaluate that call.
-
-    A Dataset has 0 or more Overloads, which are the different implementations
-    of the Dataset. A Dataset can be abstract, in which case it has no
-    Overloads, or it can be concrete, in which case it has at least one
-    Overload. The default Overload is the one that will be used if no
-    implementation is specified. Which Overload is used can be specified
-    using the LABREA.IMPLEMENTATIONS.<dataset> option in the options,
-    or by providing a dispatch Evaluatable to the Dataset.
-
-    Notes
-    -----
-    This class is not intended to be instantiated directly. Instead, use the
-    :func:`dataset` or :func:`abstractdataset` factory functions,
-    which normally should be used as decorators.
-    """
-
-    _overloads: Switch[A]
-    _alias: Alias
-
-    def __init__(self, overloads: Switch[A], alias: Alias = None, **kwargs):
-        self._overloads = overloads
-        self._alias = alias or default_alias(self)
-
-        super().__init__(overloads=overloads, alias=alias, **kwargs)
-
-    @property
-    def overload(self) -> "OverloadFactory":
-        """Decorator for creating an Overload for this Dataset.
-
-        This method is used to create an Overload for this Dataset. It can be
-        used as a decorator, like :code:`@<dataset>.overload`, or as a
-        function, like :code:`<dataset>.overload(<definition>)`.
-
-        Parameters
-        ----------
-        definition : Callable[..., A]
-            The function to wrap in an Overload.
-        where : Dict[str, Any], optional
-            Default values for arguments to the Overload.
-        cache : Callable[[Overload], Cache], optional
-            A callable that returns a Cache object for the Overload. If not
-            provided, a MemoryCache will be used.
-        alias : Alias, optional
-            An Alias for the Overload. If not provided, the name of
-            the Overload will be used.
-        aliases : MultiAlias, optional
-            A list of Aliases for the Overload. If not provided, the name of
-            the Overload will be used.
-
-        See Also
-        --------
-        :class:`Overload`
-        """
-        return OverloadFactory(dataset=self)
-
-    def register(self, alias: Alias, overload: Overload[A]):
-        """Register an Overload for this Dataset.
-
-        This method is used to register an Overload for this Dataset. It is
-        not normally used directly, but is used by the OverloadFactory.
-        """
-        self._overloads.lookup[alias] = overload
-
-    def _import(self, options: JSONDict) -> None:
-        require(*options.get("LABREA", {}).get("REQUIRE", []))  # type: ignore
-
-        try:
-            name = self._overloads.option.evaluate(options)
-            if name not in self._overloads.lookup:
-                warnings.warn(
-                    f"Implementation {name} was specified for {self}, but "
-                    f"could not be found. Falling back on default "
-                    f"implementation if available."
-                )
-        except EvaluationError:
-            pass
-
-    def _evaluate(self, options: JSONDict) -> A:
-        """Evaluate the Dataset.
-
-        Identify the overload to use, and evaluate it using the options,
-        recursively evaluating all dependencies.
-
-        See Also
-        --------
-        :meth:`labrea.types.Evaluatable.evaluate`
-        """
-        self._import(options)
-        return self._overloads.evaluate(options)
-
-    def validate(self, options: JSONDict) -> None:
-        """Validate the Dataset.
-
-        Identify the overload to use, and validate it using the options,
-        recursively validating all dependencies.
-
-        See Also
-        --------
-        :meth:`labrea.types.Validatable.validate`
-        """
-        self._import(options)
-        self._overloads.validate(options)
-
-    def keys(self, options: JSONDict) -> Set[str]:
-        """Get the keys that this Dataset depends on.
-
-        Identify the overload to use, and get the keys that it depends on
-        using the options, recursively getting all dependencies.
-
-        See Also
-        --------
-        :meth:`labrea.types.Validatable.keys`
-        """
-        self._import(options)
-        return self._overloads.keys(options)
-
-    @property
-    def is_abstract(self) -> bool:
-        return not self._overloads.has_default
-
-    @property
-    def default(self) -> Overload[A]:
-        return self._overloads.default  # type: ignore
-
-    def where(self, **kwargs) -> Overload[A]:
-        if self.is_abstract:
-            raise TypeError("Cannot use where method on an abstract dataset.")
-
-        return self.default.where(**kwargs)
-
-    def cache(self, cache: Callable[[Evaluatable], Cache]) -> Overload[A]:
-        if self.is_abstract:
-            raise TypeError("Cannot use cache method on an abstract dataset.")
-
-        return self.default.cache(cache)
-
-    @property
-    def nocache(self) -> "Dataset[A]":
-        return self.with_cache(NoCache)
-
-    def with_options(self, options: JSONDict) -> "Dataset[A]":
-        return Dataset(
-            self._overloads,
-            alias=self._alias,
-            options=mix(self._options, options),  # type: ignore
-            callbacks=self._callbacks,
-            cache=self._cache,
-            name=self.name,
-            doc=self.__doc__,
-        )
-
-    def with_cache(
-        self, cache: Union[Callable[[Evaluatable], Cache], Cache]
-    ) -> "Dataset[A]":
-        return Dataset(
-            self._overloads,
-            alias=self._alias,
-            options=self._options,
-            callbacks=self._callbacks,
-            cache=cache,
-            name=self.name,
-            doc=self.__doc__,
-        )
-
-    def set_cache(self, cache: Union[Callable[[Evaluatable], Cache], Cache]):
-        self._cache = cache if isinstance(cache, Cache) else cache(self)
-
-    def __repr__(self):
-        return f'<Dataset {self._alias or "object"}>'
-
-
-class OverloadFactory(Generic[A]):
-    _dataset: Optional[Dataset[A]]
-    _defaults: Dict[str, Any]
-    _cache: Union[Type[Cache], Callable[[Evaluatable], Cache]]
-    _aliases: MultiAlias
-    _callbacks: Optional[Callbacks[A]]
-    _options: JSONDict
-
-    def __init__(
-        self,
-        dataset: Optional[Dataset[A]] = None,
-        defaults: Optional[Dict[str, Any]] = None,
-        cache: Optional[Callable[[Evaluatable], Cache]] = None,
-        aliases: Optional[MultiAlias] = None,
-        callbacks: Optional[Callbacks[A]] = None,
-        options: Optional[JSONDict] = None,
-    ):
-        self._dataset = dataset
-        self._defaults = defaults if defaults is not None else {}
-        self._cache = cache if cache is not None else MemoryCache  # type: ignore
-        self._aliases = aliases or []
-        self._callbacks = callbacks
-        self._options = options or {}
-
-    @typing.overload
-    def __call__(
-        self,
-        definition: Callable[..., A],
-        /,
-    ) -> Overload[A]:
-        ...  # pragma: nocover
-
-    @typing.overload
-    def __call__(self, /, **kwargs: Any) -> "OverloadFactory":
-        ...  # pragma: nocover
-
-    def __call__(
-        self,
-        definition: Optional[Callable[..., A]] = None,
-        /,
-        *,
-        where: Optional[Dict[str, Any]] = None,
-        cache: Optional[Callable[[Evaluatable], Cache]] = None,
-        alias: Optional[Alias] = None,
-        aliases: Optional[MultiAlias] = None,
-        callbacks: Union[
-            Callbacks[A], List[PostCallback[A]], Dict[str, PostCallback[A]], None
-        ] = None,
-        options: Optional[JSONDict] = None,
-        _stack: int = 1,
-        **kwargs,
-    ) -> Union["OverloadFactory[A]", Overload[A]]:
-        aliases = aliases or []
-        if alias is not None:
-            aliases.append(alias)
-
-        factory = self
-        if where is not None:
-            factory = factory.where(**where)
-        if cache is not None:
-            factory = factory.cache(cache)
-        if aliases:
-            factory = factory.alias(aliases)
-        if callbacks:
-            factory = factory.callbacks(callbacks)
-        if options:
-            factory = factory.options(options)
-
-        if definition is None:
-            return factory
-
-        return factory.wrap(definition, _stack=_stack + 1)
-
-    def wrap(self, definition: Callable[..., A], _stack: int = 1) -> Overload[A]:
-        overload = Overload(
-            definition=definition,
-            parent=self._dataset,
-            cache=self._cache,
-            defaults=self._defaults,
-            aliases=self._aliases,
-            callbacks=self._callbacks,
-            options=self._options,
-            doc=getattr(definition, "__doc__", None),
-            _stack=_stack + 1,
-        )
-
-        return overload
-
-    def where(self, **kwargs) -> "OverloadFactory[A]":
-        return OverloadFactory(
-            self._dataset,
-            defaults={**self._defaults, **kwargs},
-            cache=self._cache,
-            aliases=self._aliases,
-            callbacks=self._callbacks,
-            options=self._options,
-        )
-
-    def cache(self, cache: Callable[[Evaluatable], Cache]) -> "OverloadFactory[A]":
-        return OverloadFactory(
-            self._dataset,
-            defaults=self._defaults,
-            cache=cache,
-            aliases=self._aliases,
-            callbacks=self._callbacks,
-            options=self._options,
-        )
-
-    @property
-    def nocache(self) -> "OverloadFactory[A]":
-        return self.cache(NoCache)
-
-    def alias(self, aliases: MultiAlias) -> "OverloadFactory[A]":
-        return OverloadFactory(
-            self._dataset,
-            defaults=self._defaults,
-            cache=self._cache,
-            aliases=aliases,
-            callbacks=self._callbacks,
-            options=self._options,
-        )
-
-    def callbacks(
-        self,
-        callbacks: Union[
-            Callbacks[A], List[PostCallback[A]], Dict[str, PostCallback[A]]
-        ],
-    ) -> "OverloadFactory[A]":
-        if isinstance(callbacks, dict):
-            callbacks = Callbacks(post=OrderedDict(callbacks))
-        elif isinstance(callbacks, list):
-            callbacks = Callbacks(
-                post=OrderedDict(
-                    {
-                        f'callback_{str(uuid.uuid4()).replace("-", "")}': callback
-                        for callback in callbacks
-                    }
-                )
-            )
-        return OverloadFactory(
-            self._dataset,
-            defaults=self._defaults,
-            cache=self._cache,
-            aliases=self._aliases,
-            callbacks=callbacks,
-            options=self._options,
-        )
-
-    def options(self, options: JSONDict) -> "OverloadFactory[A]":
-        return OverloadFactory(
-            self._dataset,
-            defaults=self._defaults,
-            cache=self._cache,
-            aliases=self._aliases,
-            callbacks=self._callbacks,
-            options=mix(self._options, options),  # type: ignore
-        )
-
-
-class DatasetFactory(Generic[A]):
-    """A factory function for creating Datasets.
-
-    Used to create Datasets. Normally used as a decorator, like @dataset.
-    Can either be used bare like @dataset, or with extra arguments like
-    @dataset(dispatch=...).
-
-    Parameters
-    ----------
-    definition : Callable[..., A]
-        The function to wrap in a Dataset.
-    dispatch : Evaluatable[str], optional
-        An Evaluatable that returns the name of the implementation to use.
-        If not provided, the LABREA.IMPLEMENTATIONS.<dataset> option will
-        be used.
-    abstract : bool, optional
-        Whether the Dataset is abstract or not. If abstract, it will have
-        no default implementation.
-    defaults : Dict[str, Any], optional
-        Default values for arguments to the Dataset.
-    cache : Callable[[Overload], Cache], optional
-        A callable that returns a Cache object for the Dataset. If not
-        provided, a MemoryCache will be used.
-    callbacks : Callbacks[A] | List[PostCallback[A]] | Dict[str, PostCallback[A]], optional
-        Callbacks to be used for the Dataset. If a list is provided, the names
-        will be generated automatically.
-    alias : Alias, optional
-        An Alias for the default Overload. If not provided, the name of
-        the Dataset will be used.
-
-
-    Example Usage
-    -------------
-    >>> from labrea import dataset, Option
-    >>> @dataset
-    ... def my_dataset(a: str = Option('A')) -> str:
-    ...     return a
-    >>>
-    >>> print(my_dataset({'A': 'Hello World!'})) # Hello World!
-    >>>
-    >>> @dataset(dispatch=Option('OVERLOADED_DATASET.DISPATCH'))
-    ... def overloaded_dataset(b: str = Option('A')) -> str:
-    ...     return b
-    >>>
-    >>> @overloaded_dataset.overload(alias='OVERLOAD_1')
-    ... def _overload_1(b: str = Option('')) -> str:
-    ...     return b.upper()
-    >>>
-    >>> print(overloaded_dataset({
-    ...     'OVERLOADED_DATASET.DISPATCH': 'OVERLOAD_1',
-    ...     'A': 'Hello World!'}
-    ... )) # HELLO WORLD!
-
-
-    See Also
-    --------
-    :class:`Dataset`
-    """
-
-    _dispatch: Optional[Evaluatable[str]]
-    _abstract: bool
-    _defaults: Dict[str, Any]
-    _cache: Union[Type[Cache], Callable[[Evaluatable], Cache], None]
-    _alias: Optional[Alias]
-    _callbacks: Optional[Callbacks[A]]
-    _options: JSONDict
-
-    def __init__(
-        self,
-        dispatch: Optional[Evaluatable[str]] = None,
-        abstract: bool = False,
-        defaults: Optional[Dict[str, Any]] = None,
-        cache: Optional[Callable[[Evaluatable], Cache]] = None,
-        alias: Optional[Alias] = None,
-        callbacks: Optional[Callbacks[A]] = None,
-        options: Optional[JSONDict] = None,
-    ):
-        self._dispatch = dispatch
-        self._abstract = abstract
-        self._defaults = defaults or {}
-        self._cache = cache
-        self._alias = alias
-        self._callbacks = callbacks
-        self._options = options or {}
-
-    @typing.overload
-    def __call__(
-        self,
-        definition: Callable[..., A],
-        /,
-    ) -> Dataset[A]:
-        ...  # pragma: nocover
-
-    @typing.overload
-    def __call__(self, /, **kwargs: Any) -> "DatasetFactory[A]":
-        ...  # pragma: nocover
-
-    def __call__(
-        self,
-        definition: Optional[Callable[..., A]] = None,
-        /,
-        *,
-        dispatch: Optional[Evaluatable[str]] = None,
-        where: Optional[Dict[str, Any]] = None,
-        cache: Optional[Callable[[Evaluatable], Cache]] = None,
-        alias: Optional[Alias] = None,
-        callbacks: Union[
-            Callbacks[A], List[PostCallback[A]], Dict[str, PostCallback[A]], None
-        ] = None,
-        options: Optional[JSONDict] = None,
-        _stack: int = 1,
-        **kwargs,
-    ) -> Union["DatasetFactory[A]", Dataset[A]]:
-        factory = self
-        if dispatch is not None:
-            factory = factory.dispatch(dispatch)
-        if where is not None:
-            factory = factory.where(**where)
-        if cache is not None:
-            factory = factory.cache(cache)
-        if alias is not None:
-            factory = factory.alias(alias)
-        if callbacks is not None:
-            factory = factory.callbacks(callbacks)
-        if options is not None:
-            factory = factory.options(options)
-
-        if definition is None:
-            return factory
-
-        return factory.wrap(definition, _stack=_stack + 1)
-
-    def __getattribute__(self, item):
-        if super().__getattribute__("_abstract") and item in ("cache", "where"):
-            warnings.warn(
-                f"Method {item} has no effect for an abstract dataset", stacklevel=1
-            )
-
-        return super().__getattribute__(item)
-
-    def wrap(self, definition: Callable[..., A], _stack: int = 1) -> Dataset[A]:
-        alias = self._alias or default_alias(definition)
-
-        dispatch = self._dispatch or Option(f"LABREA.IMPLEMENTATIONS.{alias}")
-        overloads: Switch[A] = Switch(dispatch, {})
-
-        dataset = Dataset(
-            overloads,
-            alias=alias,
-            doc=getattr(definition, "__doc__", None),
-            cache=self._cache,
-            name=getattr(definition, "__name__", None),
-            callbacks=self._callbacks,
-            options=self._options,
-        )
-
-        if not self._abstract:
-            overloads.default = Overload(
-                definition, parent=dataset, defaults=self._defaults
-            )
-
-        return dataset
-
-    def dispatch(self, dispatch: Union[str, Evaluatable[str]]) -> "DatasetFactory[A]":
-        dispatch_: Evaluatable[str]
-        if isinstance(dispatch, str):
-            dispatch_ = Option(dispatch)
-        else:
-            dispatch_ = dispatch
-
-        return DatasetFactory(
-            dispatch=dispatch_,
-            abstract=self._abstract,
-            defaults=self._defaults,
-            cache=self._cache,
-            alias=self._alias,
-            callbacks=self._callbacks,
-            options=self._options,
-        )
-
-    def cache(self, cache: Callable[[Evaluatable], Cache]) -> "DatasetFactory[A]":
-        return DatasetFactory(
-            dispatch=self._dispatch,
-            abstract=self._abstract,
-            defaults=self._defaults,
-            cache=cache,
-            alias=self._alias,
-            callbacks=self._callbacks,
-            options=self._options,
-        )
-
-    def where(self, **kwargs) -> "DatasetFactory[A]":
-        return DatasetFactory(
-            dispatch=self._dispatch,
-            abstract=self._abstract,
-            defaults={**self._defaults, **kwargs},
-            cache=self._cache,
-            alias=self._alias,
-            callbacks=self._callbacks,
-            options=self._options,
-        )
-
-    @property
-    def nocache(self) -> "DatasetFactory[A]":
-        return self.cache(NoCache)
-
-    def alias(self, alias: Alias) -> "DatasetFactory[A]":
-        return DatasetFactory(
-            dispatch=self._dispatch,
-            abstract=self._abstract,
-            defaults=self._defaults,
-            cache=self._cache,
-            alias=alias,
-            callbacks=self._callbacks,
-            options=self._options,
-        )
-
-    def callbacks(
-        self,
-        callbacks: Union[
-            Callbacks[A],
-            List[PostCallback[A]],
-            Dict[str, PostCallback[A]],
-        ],
-    ) -> "DatasetFactory[A]":
-        if isinstance(callbacks, dict):
-            callbacks = Callbacks(post=OrderedDict(callbacks))
-        elif isinstance(callbacks, list):
-            callbacks = Callbacks(
-                post=OrderedDict(
-                    {
-                        f'callback_{str(uuid.uuid4()).replace("-", "")}': callback
-                        for callback in callbacks
-                    }
-                )
-            )
-        return DatasetFactory(
-            dispatch=self._dispatch,
-            abstract=self._abstract,
-            defaults=self._defaults,
-            cache=self._cache,
-            alias=self._alias,
-            callbacks=callbacks,
-            options=self._options,
-        )
-
-    def options(self, options: JSONDict) -> "DatasetFactory[A]":
-        return DatasetFactory(
-            dispatch=self._dispatch,
-            abstract=self._abstract,
-            defaults=self._defaults,
-            cache=self._cache,
-            alias=self._alias,
-            callbacks=self._callbacks,
-            options=mix(self._options, options),  # type: ignore
-        )
-
-
-dataset: DatasetFactory = DatasetFactory()
-abstractdataset: DatasetFactory = DatasetFactory(abstract=True)
-abstractdataset.__doc__ = """Factory function for creating abstract Datasets.
-
-    This is just an alias for :code:`dataset(abstract=True)`.
-
-    See Also
-    --------
-    :func:`labrea.datasets.dataset`
-    :class:`Dataset`
-"""
diff --git a/labrea/exceptions.py b/labrea/exceptions.py
new file mode 100644
index 0000000..b77354a
--- /dev/null
+++ b/labrea/exceptions.py
@@ -0,0 +1,41 @@
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from .types import Evaluatable
+
+
+class EvaluationError(Exception):
+    """Error raised when an object cannot be evaluated."""
+
+    msg: str
+    source: "Evaluatable"
+
+    def __init__(self, msg: str, source: "Evaluatable") -> None:
+        self.msg = msg
+        self.source = source
+
+        super().__init__(msg, source)
+
+    def __str__(self):
+        return f"Originating in {self.source} | {self.msg}"
+
+
+class KeyNotFoundError(EvaluationError):
+    """Error raised when a key is not found in the options dictionary."""
+
+    key: str
+    source: "Evaluatable"
+
+    def __init__(self, key: str, source: "Evaluatable") -> None:
+        self.key = key
+
+        super().__init__(f"Key '{self.key}' not found", source)
+
+
+class InsufficientInformationError(EvaluationError):
+    """Error raised when not enough information is provided to explain an object."""
+
+    def __init__(self, reason: str, source: "Evaluatable") -> None:
+        super().__init__(
+            f"Insufficient information to evaluate {source}: {reason}", source
+        )
diff --git a/labrea/functions.py b/labrea/functions.py
new file mode 100644
index 0000000..372706f
--- /dev/null
+++ b/labrea/functions.py
@@ -0,0 +1,56 @@
+import builtins
+import functools
+from typing import Callable, Iterable, TypeVar
+
+from ._missing import MISSING, MaybeMissing
+from .application import FunctionApplication
+from .pipeline import PipelineStep
+from .types import MaybeEvaluatable
+
+A = TypeVar("A")
+B = TypeVar("B")
+
+
+def map(
+    func: MaybeEvaluatable[Callable[[A], B]]
+) -> PipelineStep[Iterable[A], Iterable[B]]:
+    return PipelineStep(
+        FunctionApplication(
+            functools.partial, builtins.map, func  # type: ignore [arg-type]
+        ),
+        f"map({func!r})",
+    )
+
+
+def filter(
+    func: MaybeEvaluatable[Callable[[A], bool]]
+) -> PipelineStep[Iterable[A], Iterable[A]]:
+    return PipelineStep(
+        FunctionApplication(
+            functools.partial, builtins.filter, func  # type: ignore [arg-type]
+        ),
+        f"filter({func!r})",
+    )
+
+
+def _reduce(
+    func: Callable[[A, A], A], iterable: Iterable[A], initial: MaybeMissing[A]
+) -> A:
+    if initial is MISSING:
+        return functools.reduce(func, iterable)
+
+    return functools.reduce(func, iterable, initial)
+
+
+def reduce(
+    func: MaybeEvaluatable[Callable[[A, A], A]],
+    initial: MaybeMissing[MaybeEvaluatable[A]] = MISSING,
+) -> PipelineStep[Iterable[A], A]:
+    return PipelineStep(
+        FunctionApplication(
+            functools.partial, _reduce, func, initial=initial  # type: ignore [arg-type]
+        ),
+        f"reduce({func!r})"
+        if initial is MISSING
+        else f"reduce({func!r}, initial={initial!r})",
+    )
diff --git a/labrea/interface.py b/labrea/interface.py
new file mode 100644
index 0000000..ee22dbc
--- /dev/null
+++ b/labrea/interface.py
@@ -0,0 +1,216 @@
+from types import FunctionType
+from typing import (
+    Any,
+    Callable,
+    Dict,
+    Hashable,
+    List,
+    Protocol,
+    Tuple,
+    Type,
+    TypeVar,
+    Union,
+)
+
+from .application import FunctionApplication
+from .dataset import Dataset, abstractdataset, dataset
+from .option import Option
+from .types import Evaluatable, Value
+
+T = TypeVar("T", bound=Type)
+
+
+def interface(dispatch: Union[Evaluatable[Hashable], str]) -> Callable[[T], "T"]:
+    if isinstance(dispatch, type):
+        raise TypeError(
+            "@interface requires that an Evaluatable (or str representing an Option) be provided "
+            "to dispatch over, but a type was provided. Did you forget to provide a dispatch? "
+            '(e.g. @interface instead of @interface("DISPATCH.KEY"))?'
+        )
+
+    if isinstance(dispatch, str):
+        dispatch = Option(dispatch)
+
+    def wrapper(cls):
+        return Interface(cls.__name__, cls.__bases__, dict(cls.__dict__), dispatch)
+
+    return wrapper
+
+
+def implements(
+    *interfaces: "Interface", alias: Union[Hashable, List[Hashable], None] = None
+) -> Callable[[T], T]:
+    if alias is None:
+        raise ValueError(
+            "The @implements decorator requires at least one alias to be provided."
+        )
+    aliases = tuple(alias) if isinstance(alias, list) else (alias,)
+
+    def wrapper(cls):
+        return Implementation(
+            cls.__name__, cls.__bases__, dict(cls.__dict__), interfaces, aliases
+        )
+
+    return wrapper
+
+
+class Interface(type):
+    _dispatch: Evaluatable[Hashable]
+    _members: Dict[str, Dataset]
+
+    def __new__(mcs, name, bases, namespace, dispatch: Evaluatable[Hashable]):
+        namespace["_dispatch"] = dispatch
+        return super().__new__(mcs, name, bases, namespace)
+
+    def __init__(
+        cls,
+        name: str,
+        bases: Tuple[type, ...],
+        dct: Dict[str, Any],
+        dispatch: Evaluatable[Hashable],
+    ):
+        super().__init__(name, bases, dct)
+
+        for name, typ in dct.get("__annotations__", {}).items():
+            if name.startswith("_") or name in dct:
+                continue
+
+            def _abstractdataset():
+                pass  # pragma: no cover
+
+            _abstractdataset.__qualname__ = f"{cls.__name__}.{name}"
+            _abstractdataset.__name__ = name
+            setattr(cls, name, abstractdataset(_abstractdataset, dispatch=dispatch))
+
+        for key, val in dct.items():
+            if key.startswith("_"):
+                continue
+
+            if isinstance(val, staticmethod):
+                val = val.__func__
+                setattr(cls, key, val)
+
+            if isinstance(val, FunctionType):
+                setattr(cls, key, dataset(val, dispatch=dispatch))
+            elif isinstance(val, Dataset):
+                val.set_dispatch(dispatch)
+            else:
+
+                def _dataset(v=Evaluatable.ensure(val)):
+                    return v
+
+                _dataset.__qualname__ = f"{cls.__qualname__}.{key}"
+                _dataset.__name__ = key
+                setattr(cls, key, dataset(_dataset, dispatch=dispatch))
+
+    def __setattr__(self, key, value):
+        if key in ("implementation",):
+            raise AttributeError(f"Cannot create a member called {key} on Interface.")
+
+        super().__setattr__(key, value)
+
+    def implementation(cls, alias: Union[Hashable, List[Hashable]]) -> Callable[[T], T]:
+        if isinstance(alias, type):
+            raise TypeError(
+                f"@{cls.__name__}.implementation requires that at least one alias be provided, "
+                f"but a type was provided. Did you forget to provide an alias? "
+                f"(e.g. @{cls.__name__}.implementation instead of "
+                f'@{cls.__name__}.implementation("ALIAS"))?'
+            )
+
+        return implements(cls, alias=alias)
+
+    def __repr__(self):
+        return f"<Interface {self.__module__}.{self.__qualname__}>"
+
+
+class Implementation(type):
+    _interfaces: Tuple[Interface, ...]
+
+    def __new__(
+        mcs,
+        name,
+        bases,
+        namespace,
+        interfaces: Tuple[Interface, ...],
+        aliases: Tuple[Hashable, ...],
+    ):
+        namespace["_interfaces"] = interfaces
+        return super().__new__(mcs, name, bases, namespace)
+
+    def __init__(
+        cls,
+        name,
+        bases,
+        dct,
+        interfaces: Tuple[Interface, ...],
+        aliases: Tuple[Hashable, ...],
+    ):
+        super().__init__(name, bases, dct)
+
+        members = _get_members(interfaces)
+        overloads = _build_overloads(dct, members, interfaces)
+
+        for key, member_list in members.items():
+            overload = overloads.get(key)
+
+            for member in member_list:
+                if member.is_abstract and overload is None:
+                    raise TypeError(f"No implementation provided for {member}.")
+
+            if overload is not None:
+                for member in member_list:
+                    for alias in aliases:
+                        member.register(alias, overload)
+
+    def __repr__(self):
+        return (
+            f"<Implementation {self.__module__}.{self.__qualname__} of "
+            f'{", ".join(i.__qualname__ for i in self._interfaces)}>'
+        )
+
+
+def _get_members(interfaces: Tuple[Interface, ...]) -> Dict[str, List[Dataset]]:
+    members: Dict[str, List[Dataset]] = {}
+
+    for interface_ in interfaces:
+        for name, member in interface_.__dict__.items():
+            if not name.startswith("_") and isinstance(member, Dataset):
+                members.setdefault(name, []).append(member)
+
+    return members
+
+
+def _build_overloads(
+    dct: Dict[str, Any], members: Dict[str, List[Dataset]], interfaces
+) -> Dict[str, Evaluatable]:
+    overloads: Dict[str, Evaluatable] = {}
+
+    for key, val in dct.items():
+        if key.startswith("_"):
+            continue
+
+        if key not in members:
+            raise TypeError(
+                f"No interface in {interfaces} has a member named {key} to overload."
+            )
+
+        if isinstance(val, staticmethod):
+            val = val.__func__
+
+        overload: Evaluatable
+        if isinstance(val, FunctionType):
+            overload = FunctionApplication.lift(val)
+        elif not isinstance(val, Evaluatable):
+            overload = Value(val)
+        else:
+            overload = val
+
+        overloads[key] = overload
+
+    return overloads
+
+
+class _ImplDecoProto(Protocol):
+    def __call__(self, alias: Hashable, *aliases: Hashable) -> Callable[[T], T]:
+        ...
diff --git a/labrea/interfaces.py b/labrea/interfaces.py
deleted file mode 100644
index 615f020..0000000
--- a/labrea/interfaces.py
+++ /dev/null
@@ -1,305 +0,0 @@
-import typing
-from types import FunctionType
-from typing import Any, Dict, Optional, Tuple, Type, Union
-
-from ._aliases import default_alias, default_aliases
-from .datasets import Dataset, Overload, abstractdataset, dataset
-from .options import Option
-from .types import Alias, Evaluatable, MultiAlias, Value
-
-
-def _is_member(key: str):
-    return not key.startswith("_") and key not in ("implementation",)
-
-
-class ImplementationDecorator:
-    def __get__(self, instance, owner):
-        def wrapper_builder(*args, **outer_kwargs):
-            """Class decorator for implementing interfaces.
-
-            Can either by used as :code:`MyInterface.implementation` or
-            :code:`Interface.implementation(Interface1, Interface2)` to
-            implement multiple interfaces at once.
-
-            See :func:`interface` for example usage.
-            """
-
-            def implementation_wrapper(cls: Optional[Type] = None, **inner_kwargs):
-                kwargs = {**outer_kwargs, **inner_kwargs}
-                interfaces = (*args, *kwargs.pop("interfaces", tuple()))
-
-                if cls is None:
-                    return wrapper_builder(*interfaces, **kwargs)
-
-                return Implementation(
-                    cls.__name__,
-                    tuple(base for base in cls.__bases__ if base is not object),
-                    {
-                        **cls.__dict__,
-                        **{
-                            "__qualname__": cls.__qualname__,
-                        },
-                    },
-                    interfaces=interfaces,
-                    **kwargs,
-                )
-
-            return implementation_wrapper
-
-        if instance is None:
-            return wrapper_builder
-        else:
-            return wrapper_builder(instance)
-
-
-class Interface(type):
-    """An Interface is a collection of datasets with related implementations.
-
-    Interfaces are used to define a set of datasets that are related to each
-    other. An interface is defined by a class that inherits from Interface
-    (or is decorated with the :func:`interface` decorator). Interfaces are
-    implemented using :code:`@MyInterface.implementation` or
-    :code:`@Interface.implementation(Interface1, Interface2)` to implement
-    multiple interfaces at once.
-
-    A valid implementation of an interface must implement all of abstract
-    datasets defined by the interface. Any datasets that are not abstract
-    will be inherited from the interface if they are not implemented by the
-    implementation.
-    """
-
-    _members: Dict[str, Dataset]
-
-    def __init__(cls, name, bases, dct):
-        cls._members = {}
-        cls.__name__ = name
-
-        dispatch = getattr(cls, "_dispatch", None)
-        dispatch = dispatch or f"LABREA.IMPLEMENTATIONS.{default_alias(cls)}"
-        if isinstance(dispatch, str):
-            dispatch = Option(dispatch)
-        cls._dispatch = dispatch
-
-        for name, typ in getattr(cls, "__annotations__", {}).items():
-            if hasattr(cls, name):
-                continue
-
-            @abstractdataset(dispatch=dispatch)
-            def _dataset() -> typ:
-                return NotImplemented  # pragma: nocover
-
-            _dataset.__name__ = f"{cls.__qualname__}.{name}"
-
-            setattr(cls, name, _dataset)
-
-        for name, val in cls.__dict__.items():
-            if not _is_member(name):
-                continue
-
-            typ = getattr(cls, "__annotations__", {}).get(name, Any)
-
-            if isinstance(val, staticmethod):
-                val = val.__get__(cls)
-
-            if not isinstance(val, Evaluatable):
-                val = Value(val)
-
-            if not isinstance(val, Dataset):
-
-                @dataset(dispatch=dispatch)
-                def _dataset(dep: typ = val) -> typ:
-                    return dep
-
-                _dataset.__name__ = f"{cls.__qualname__}.{name}"
-
-                val = _dataset
-            else:
-                val._overloads.option = cls._dispatch
-
-            setattr(cls, name, val)
-
-        super().__init__(cls.__name__, bases, dct)
-
-    def __setattr__(cls, key: str, value: Any):
-        if _is_member(key):
-            cls._members[key] = value
-        else:
-            super().__setattr__(key, value)
-
-    def __getattribute__(cls, key: str):
-        if _is_member(key):
-            try:
-                return cls._members[key]
-            except KeyError as e:
-                raise AttributeError(*e.args) from e
-        else:
-            return super().__getattribute__(key)
-
-    def __repr__(cls):
-        return f"<Interface {cls.__qualname__}>"
-
-    implementation = ImplementationDecorator()
-
-
-class InterfaceFactory:
-    """Factory function for creating interfaces.
-
-    This is normally used as a decorator on a class to create an interface
-    from the class.
-
-    Parameters
-    ----------
-    dispatch : str | Evaluatable[str], optional
-        How to dispath to the correct implementation of the interface.
-
-
-    Example Usage
-    -------------
-    >>> from labrea import interface, abstractdataset, dataset, Option
-    >>> @interface(dispatch='MY_INTERFACE.IMPLEMENTATION')
-    ... class MyInterface:
-    ...     @staticmethod  # Not required, but will avoid IDE warnings
-    ...     @abstractdataset
-    ...     def my_abstract_dataset() -> str:
-    ...         return NotImplemented
-    ...
-    ...     @staticmethod
-    ...     @dataset
-    ...     def my_dataset(y: str = Option('Y')) -> str:
-    ...         return y
-    ...
-    >>> @MyInterface.implementation
-    ... class MyImplementation:
-    ...     @staticmethod
-    ...     def my_abstract_dataset(x: str = Option('X')) -> str:
-    ...         return x
-    ...
-    >>> options = {
-    ...     'X': 'x', 'Y': 'y',
-    ...     'MY_INTERFACE': {'IMPLEMENTATION': 'MY_IMPLEMENTATION'}
-    ... }
-    >>> print(MyInterface.my_abstract_dataset(options))  # x
-    >>> print(MyInterface.my_dataset(options))  # y
-
-    See Also
-    --------
-    :class:`Interface`
-    """
-
-    _dispatch: Optional[Union[str, Evaluatable[str]]]
-
-    def __init__(self, dispatch: Optional[Union[str, Evaluatable[str]]] = None):
-        self._dispatch = dispatch
-
-    @typing.overload
-    def __call__(self, cls: Type, /) -> Interface:
-        ...  # pragma: nocover
-
-    @typing.overload
-    def __call__(self, /, **kwargs: Any) -> "InterfaceFactory":
-        ...  # pragma: nocover
-
-    def __call__(
-        self,
-        cls: Optional[Type] = None,
-        /,
-        *,
-        dispatch: Optional[Union[str, Evaluatable[str]]] = None,
-        **kwargs,
-    ) -> Union[Interface, "InterfaceFactory"]:
-        factory = self
-
-        if dispatch is not None:
-            factory = factory.dispatch(dispatch)
-
-        if cls is not None:
-            return factory.wrap(cls)
-        else:
-            return factory
-
-    def wrap(self, cls: Type) -> Interface:
-        return Interface(
-            cls.__name__,
-            tuple(base for base in cls.__bases__ if base is not object),
-            {
-                **cls.__dict__,
-                **{"__qualname__": cls.__qualname__, "_dispatch": self._dispatch},
-            },
-        )
-
-    @staticmethod
-    def dispatch(dispatch: Union[str, Evaluatable[str]]):
-        return InterfaceFactory(dispatch=dispatch)
-
-
-interface = InterfaceFactory()
-
-
-class Implementation(type):
-    _interfaces: Tuple[Interface, ...]
-
-    def __new__(mcs, *args, **kwargs):
-        return super().__new__(mcs, *args)
-
-    def __init__(
-        cls,
-        *args,
-        interfaces: Tuple[Interface, ...] = tuple(),
-        alias: Optional[Alias] = None,
-        aliases: Optional[MultiAlias] = None,
-        **kwargs,
-    ):
-        if not (isinstance(interfaces, tuple) and len(interfaces)):
-            raise TypeError("Implementations must implement at least one Interface.")
-
-        cls._interfaces = interfaces
-
-        aliases = aliases or []
-        if alias is not None:
-            aliases.append(alias)
-
-        if not aliases:
-            aliases = default_aliases(cls)
-
-        for _interface in interfaces:
-            for name_, member in _interface._members.items():
-                overload = getattr(cls, name_, ...)
-                if overload is ...:
-                    if member.is_abstract:
-                        raise TypeError(
-                            f"Implementers of {_interface} " f"must implement {name_}"
-                        )
-                    else:
-                        for alias in aliases:
-                            member.register(alias, member.default)
-                        continue
-
-                if isinstance(overload, staticmethod):
-                    overload = overload.__get__(cls, None)
-
-                if isinstance(overload, FunctionType):
-                    overload = Overload(overload)
-                elif isinstance(overload, Overload):
-                    pass
-                elif isinstance(overload, Dataset):
-                    if overload.is_abstract:
-                        raise TypeError(
-                            "Implementations may not contain abstract " "datasets."
-                        )
-
-                    overload = overload.default
-                else:
-                    if not isinstance(overload, Evaluatable):
-                        overload = Value(overload)
-
-                    def _overload(dep=overload):
-                        return dep
-
-                    overload = Overload(_overload)
-
-                for alias in aliases:
-                    member.register(alias, overload)
-
-                setattr(cls, name_, overload)
-
-        super().__init__(*args)
diff --git a/labrea/iterable.py b/labrea/iterable.py
new file mode 100644
index 0000000..1b17a8b
--- /dev/null
+++ b/labrea/iterable.py
@@ -0,0 +1,125 @@
+import itertools
+from typing import Dict, Iterable, Optional, Set, Tuple, TypeVar
+
+from confectioner.templating import set_dotted_key
+
+from .exceptions import EvaluationError
+from .option import WithOptions
+from .types import JSON, Evaluatable, MaybeEvaluatable, Options
+
+A = TypeVar("A", covariant=True)
+
+
+class Iter(Evaluatable[Iterable[A]]):
+    """A class representing multiple evaluatables as an iterable."""
+
+    evaluatables: Iterable[Evaluatable[A]]
+
+    def __init__(self, *evaluatables: MaybeEvaluatable[A]):
+        self.evaluatables = tuple(Evaluatable.ensure(e) for e in evaluatables)
+
+    def evaluate(self, options: Options) -> Iterable[A]:
+        return (evaluatable.evaluate(options) for evaluatable in self.evaluatables)
+
+    def validate(self, options: Options) -> None:
+        for evaluatable in self.evaluatables:
+            evaluatable.validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        return {
+            key
+            for evaluatable in self.evaluatables
+            for key in evaluatable.keys(options)
+        }
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        return {
+            explanation
+            for evaluatable in self.evaluatables
+            for explanation in evaluatable.explain(options)
+        }
+
+    def __repr__(self) -> str:
+        return f"Iter({', '.join(map(repr, self.evaluatables))})"
+
+
+class Map(Evaluatable[Iterable[Tuple[Dict[str, JSON], A]]]):
+    """A class that represents the same evaluatable repeated over multiple options."""
+
+    evaluatable: Evaluatable[A]
+    iterables: Dict[str, Evaluatable[Iterable[JSON]]]
+
+    def __init__(
+        self,
+        evaluatable: Evaluatable[A],
+        iterables: Dict[str, MaybeEvaluatable[Iterable[JSON]]],
+    ) -> None:
+        self.evaluatable = evaluatable
+        self.iterables = {
+            key: Evaluatable.ensure(value) for key, value in iterables.items()
+        }
+
+    def evaluate(self, options: Options) -> Iterable[Tuple[Dict[str, JSON], A]]:
+        return self._iter(options).evaluate(options)
+
+    def validate(self, options: Options) -> None:
+        self._iter(options).validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        return set().union(
+            self._iter(options).keys(options),
+            *(iterable.keys(options) for iterable in self.iterables.values()),
+        )
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        try:
+            return set().union(
+                self._iter(options or {}).explain(options),
+                *(iterable.explain(options) for iterable in self.iterables.values()),
+            )
+        except EvaluationError:
+            return (
+                self.evaluatable.explain(options) - self.iterables.keys()
+            ) | set().union(
+                *(iterable.explain(options) for iterable in self.iterables.values())
+            )
+
+    def _iter(
+        self, options: Options
+    ) -> Evaluatable[Iterable[Tuple[Dict[str, JSON], A]]]:
+        return Iter(
+            *(
+                Iter(
+                    Iter(*option_tuples).apply(dict),  # type: ignore
+                    WithOptions(  # type: ignore
+                        self.evaluatable, self._create_option_set(*option_tuples)
+                    ),
+                ).apply(lambda x: tuple(x))
+                for option_tuples in self._iterate_over_options(options)
+            )
+        )
+
+    @staticmethod
+    def _create_option_set(*args: Tuple[str, JSON]) -> Options:
+        options: Dict[str, JSON] = {}
+        for key, value in args:
+            set_dotted_key(key, value, options)
+
+        return options
+
+    def _iterate_over_options(
+        self, options: Options
+    ) -> Tuple[Tuple[Tuple[str, JSON], ...], ...]:
+        return tuple(
+            tuple(zip(self.iterables.keys(), values))
+            for values in itertools.product(
+                *(iterable.evaluate(options) for iterable in self.iterables.values())
+            )
+        )
+
+    def __repr__(self) -> str:
+        return f"Iterate({self.evaluatable!r}, {self.iterables!r})"
+
+    @property
+    def values(self) -> Evaluatable[Iterable[A]]:
+        return self.apply(lambda items: (item[1] for item in items))
diff --git a/labrea/multithreading.py b/labrea/multithreading.py
deleted file mode 100644
index 0d88310..0000000
--- a/labrea/multithreading.py
+++ /dev/null
@@ -1,29 +0,0 @@
-__PARALLEL: bool = True
-
-
-def is_parallel() -> bool:
-    """Return whether multithreading is enabled"""
-    return __PARALLEL
-
-
-def enable():
-    """Enable multithreading (enabled by default).
-
-    When multithreading is enabled, the `labrea` library will attempt to
-    evaluate dataset dependencies in multiple threads. This can speed up
-    evaluation of datasets that involve significant IO operations, but can
-    also cause issues if the datasets are not thread-safe.
-    """
-    global __PARALLEL
-    __PARALLEL = True
-
-
-def disable():
-    """Disable multithreading.
-
-    When multithreading is disabled, the `labrea` library will evaluate dataset
-    dependencies in a single thread. This is useful for debugging, or when
-    datasets are not thread-safe.
-    """
-    global __PARALLEL
-    __PARALLEL = False
diff --git a/labrea/mypy/plugin.py b/labrea/mypy/plugin.py
index e99ce82..91c7e0b 100644
--- a/labrea/mypy/plugin.py
+++ b/labrea/mypy/plugin.py
@@ -2,52 +2,45 @@
 
 from mypy.plugin import ClassDefContext, Plugin
 from mypy.plugins.common import add_attribute_to_class  # type: ignore
-from mypy.types import Instance
 
-DATACLASSES_DECORATOR_FULLNAME = "labrea.datasetclasses.datasetclass"
-INTERFACE_DECORATOR_FULLNAME = "labrea.interfaces.interface"
-IMPLEMENTATION_DECO_TYPE_FULLNAME = "labrea.interfaces.ImplementationDecorator"
+INTERFACE_DECORATOR_FULLNAME = "labrea.interface.interface"
+IMPLEMENTATION_DECO_TYPE_FULLNAME = "labrea.interface._ImplDecoProto"
+EVALUATABLE_TYPE_FULLNAME = "labrea.evaluatable.Evaluatable"
 
 
 class LabreaPlugin(Plugin):
     def get_class_decorator_hook_2(
         self, fullname: str
     ) -> Optional[Callable[[ClassDefContext], bool]]:
-        """
-        For datasetclasses using the datasetclass decorator, tell mypy that
-        there is a .isntance attribute at the class level that returns an
-        instance of the class. In reality, this just returns the class itself,
-        but allows us to do this:
-        @datasetclass
-        class MyClass:
-            x: int = Option('X')
-        @dataset
-        def my_dataset(
-            inst: MyClass = MyClass.instance
-        ) -> int:
-            return inst.x
-        """
-        if fullname == DATACLASSES_DECORATOR_FULLNAME:
-            return self.add_result_attribute_callback
         if fullname == INTERFACE_DECORATOR_FULLNAME:
-            return self.add_implementation_attribute_callback
+            return self.interface_callback
 
         return None
 
     @staticmethod
-    def add_result_attribute_callback(ctx: ClassDefContext):
+    def interface_callback(ctx: ClassDefContext):
+        for name, attribute in ctx.cls.info.names.copy().items():
+            if name.startswith("_"):
+                continue
+            add_attribute_to_class(  # type: ignore [call-arg]
+                api=ctx.api,
+                cls=ctx.cls,
+                name=name,
+                typ=ctx.api.named_type_or_none(  # type: ignore [arg-type]
+                    EVALUATABLE_TYPE_FULLNAME
+                ),
+                overwrite_existing=True,
+            )
+
         add_attribute_to_class(
-            api=ctx.api, cls=ctx.cls, name="result", typ=Instance(ctx.cls.info, [])
+            api=ctx.api,
+            cls=ctx.cls,
+            name="implementation",
+            typ=ctx.api.named_type_or_none(  # type: ignore [arg-type]
+                IMPLEMENTATION_DECO_TYPE_FULLNAME
+            ),
         )
-        return True
 
-    @staticmethod
-    def add_implementation_attribute_callback(ctx: ClassDefContext):
-        typ = ctx.api.named_type_or_none(IMPLEMENTATION_DECO_TYPE_FULLNAME)
-        if typ is not None:
-            add_attribute_to_class(
-                api=ctx.api, cls=ctx.cls, name="implementation", typ=typ
-            )
         return True
 
 
diff --git a/labrea/option.py b/labrea/option.py
new file mode 100644
index 0000000..718aabc
--- /dev/null
+++ b/labrea/option.py
@@ -0,0 +1,134 @@
+from typing import Callable, Optional, Set, TypeVar
+
+from confectioner import mix
+from confectioner.templating import dotted_key_exists, get_dotted_key, resolve
+
+from ._missing import MISSING, MaybeMissing
+from .application import FunctionApplication
+from .exceptions import KeyNotFoundError
+from .template import Template
+from .types import JSON, Evaluatable, MaybeEvaluatable, Options
+
+A = TypeVar("A", covariant=True, bound=JSON)
+B = TypeVar("B", covariant=True)
+
+
+class Option(Evaluatable[A]):
+    key: str
+    default: MaybeMissing[Evaluatable[A]]
+
+    def __init__(
+        self,
+        key: str,
+        default: MaybeMissing[MaybeEvaluatable[A]] = MISSING,
+        default_factory: MaybeMissing[Callable[[], A]] = MISSING,
+        doc: str = "",
+    ) -> None:
+        self.key = key
+        if isinstance(default, str):
+            self.default = Template(default)  # type: ignore [assignment]
+        elif default is not MISSING:
+            self.default = Evaluatable.ensure(default)
+        elif default_factory is not MISSING:
+            self.default = FunctionApplication(default_factory)
+        else:
+            self.default = MISSING
+
+        self.__doc__ = doc
+
+    def evaluate(self, options: Options) -> A:
+        """Retrieves the key from the options dictionary.
+
+        If the key does not exist, the default key is returned. If no default
+        key is provided, an error is raised. If the default key is an
+        Evaluatable, it is evaluated using the options dictionary. If the
+        default key is not an Evaluatable, it is returned as-is.
+        """
+        try:
+            value = get_dotted_key(self.key, options)
+            return resolve(value, options)
+        except KeyError:
+            if self.default is MISSING:
+                raise KeyNotFoundError(self.key, self)
+            return self.default.evaluate(options)
+
+    def validate(self, options: Options) -> None:
+        """Validates that the key exists in the options dictionary.
+
+        If the key does not exist, the default key is validated. If the
+        default key is an Evaluatable, it is validated using the options
+        dictionary. If the default key is not an Evaluatable, it is ignored.
+        """
+        if dotted_key_exists(self.key, options):
+            _ = self.keys(options)
+        elif self.default is not MISSING:
+            self.default.validate(options)
+        else:
+            raise KeyNotFoundError(self.key, self)
+
+    def keys(self, options: Options) -> Set[str]:
+        if dotted_key_exists(self.key, options):
+            value = get_dotted_key(self.key, options)
+            if isinstance(value, str):
+                return {self.key} | Template(value).keys(options)
+            else:
+                return {self.key}
+        elif self.default is not MISSING:
+            return self.default.keys(options)
+        else:
+            raise KeyNotFoundError(self.key, self)
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        options = options or {}
+        if dotted_key_exists(self.key, options):
+            value = get_dotted_key(self.key, options)
+            if isinstance(value, str):
+                return {self.key} | Template(value).explain(options)
+            else:
+                return {self.key}
+        elif self.default is not MISSING:
+            return self.default.explain(options)
+        else:
+            return {self.key}
+
+    def __repr__(self) -> str:
+        return (
+            f"Option({self.key!r}, default={self.default!r})"
+            if self.default is not MISSING
+            else f"Option({self.key!r})"
+        )
+
+
+class WithOptions(Evaluatable[B]):
+    evaluatable: Evaluatable[B]
+    options: Options
+
+    def __init__(self, evaluatable: Evaluatable[B], options: Options) -> None:
+        self.evaluatable = evaluatable
+        self.options = options
+
+    def _options(self, options: Options) -> Options:
+        return mix(options, self.options)  # type: ignore
+
+    def evaluate(self, options: Options) -> B:
+        return self.evaluatable.evaluate(self._options(options))
+
+    def validate(self, options: Options) -> None:
+        self.evaluatable.validate(self._options(options))
+
+    def keys(self, options: Options) -> Set[str]:
+        return {
+            key
+            for key in self.evaluatable.keys(self._options(options))
+            if not dotted_key_exists(key, self.options)
+        }
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        return {
+            key
+            for key in self.evaluatable.explain(self._options(options or {}))
+            if not dotted_key_exists(key, self.options)
+        }
+
+    def __repr__(self) -> str:
+        return f"WithOptions({self.evaluatable!r}, {self.options!r})"
diff --git a/labrea/options.py b/labrea/options.py
deleted file mode 100644
index da7f1cd..0000000
--- a/labrea/options.py
+++ /dev/null
@@ -1,135 +0,0 @@
-from typing import Callable, Set, TypeVar, Union
-
-from confectioner.templating import dotted_key_exists, get_dotted_key, resolve
-
-from .template import Template
-from .types import Evaluatable, EvaluationError, JSONDict, ValidationError, Value
-
-A = TypeVar("A")
-
-
-MaybeEvaluatableA = Union[A, Evaluatable[A]]
-
-
-class Unreachable(Evaluatable[A]):
-    def evaluate(self, options: JSONDict) -> A:
-        raise EvaluationError("UNREACHABLE")  # pragma: nocover
-
-    def validate(self, options: JSONDict) -> None:
-        raise ValidationError("UNREACHABLE")  # pragma: nocover
-
-    def keys(self, options: JSONDict) -> Set[str]:
-        return set()  # pragma: nocover
-
-
-_UNREACHABLE: Evaluatable = Unreachable()
-
-
-class Option(Evaluatable[A]):
-    """Retrieves a single entry from the options dictionary.
-
-    If the key does not exist, the default value is returned. If no default
-    value is provided, an error is raised. If the default value is an
-    Evaluatable, it is evaluated using the options dictionary. If the
-    default value is not an Evaluatable, it is returned as-is. A
-    default_factory can be provided instead of a default value.
-
-    Parameters
-    ----------
-    key : str
-        The key to retrieve from the options dictionary.
-    default : Evaluatable[A] | A, optional
-        The default value to return if the key does not exist
-        in the options dictionary
-    default_factory : Callable[[], Evaluatable[A] | A], optional
-        A function that returns the default value to return if the key
-        does not exist in the options dictionary.
-    doc : str, optional
-        The docstring for the Option object
-    """
-
-    key: str
-    default: Evaluatable[A]
-    default_factory: Callable[[], MaybeEvaluatableA] = lambda: _UNREACHABLE
-
-    def __init__(
-        self,
-        key: str,
-        default: MaybeEvaluatableA = _UNREACHABLE,
-        default_factory: Callable[[], MaybeEvaluatableA] = lambda: _UNREACHABLE,
-        doc: str = "",
-    ):
-        if default is _UNREACHABLE:
-            default = default_factory()
-
-        if isinstance(default, str):
-            default = Template(default)
-        elif not isinstance(default, Evaluatable):
-            default = Value(default)
-
-        self.key = key
-        self.default = default  # type: ignore [assignment]
-        self.default_factory = default_factory
-        self.__doc__ = doc
-
-    def evaluate(self, options: JSONDict) -> A:
-        """Retrieves the value from the options dictionary.
-
-        If the key does not exist, the default value is returned. If no default
-        value is provided, an error is raised. If the default value is an
-        Evaluatable, it is evaluated using the options dictionary. If the
-        default value is not an Evaluatable, it is returned as-is.
-        """
-        try:
-            value = get_dotted_key(self.key, options)
-            if self.resolving(options):
-                return resolve(value, options)
-            else:
-                return value
-        except KeyError:
-            if not self.has_default:
-                raise EvaluationError(self.key)
-            return self.default.evaluate(options)
-
-    def validate(self, options: JSONDict) -> None:
-        """
-        Validates that the key exists in the options dictionary, or that
-        a default value is provided and can be validated against the options
-        dictionary.
-        """
-        if not self.has_default and not dotted_key_exists(self.key, dict(options)):
-            raise ValidationError(self.key)
-
-        if self.resolving(options):
-            try:
-                resolve(get_dotted_key(self.key, options), options)
-            except KeyError as e:
-                if self.has_default:
-                    self.default.validate(options)
-                else:
-                    raise ValidationError(self.key) from e
-
-    def keys(self, options: JSONDict) -> Set[str]:
-        """Returns the keys that this object depends on.
-
-        This method returns the key that this object depends on, if it exists
-        in the options dictionary. If the key does not exist, the default keys
-        are returned.
-        """
-        return (
-            {self.key}
-            if dotted_key_exists(self.key, dict(options))
-            else self.default.keys(options)
-        )
-
-    @property
-    def has_default(self):
-        return self.default is not _UNREACHABLE
-
-    @staticmethod
-    def resolving(options: JSONDict) -> bool:
-        return (
-            options.get("LABREA", {})  # type: ignore
-            .get("OPTIONS", {})
-            .get("RESOLVE", True)
-        )
diff --git a/labrea/overload.py b/labrea/overload.py
new file mode 100644
index 0000000..da80b20
--- /dev/null
+++ b/labrea/overload.py
@@ -0,0 +1,86 @@
+import sys
+
+if sys.version_info < (3, 10):
+    from typing_extensions import ParamSpec
+else:
+    from typing import ParamSpec
+
+import threading
+from typing import Callable, Dict, Hashable, List, Optional, Set, TypeVar, Union
+
+from ._missing import MISSING, MaybeMissing
+from .application import FunctionApplication
+from .conditional import switch
+from .types import Evaluatable, MaybeEvaluatable, Options
+
+A = TypeVar("A", covariant=True)
+P = ParamSpec("P")
+
+
+class Overloaded(Evaluatable[A]):
+    """A class representing multiple implementations of an Evaluatable."""
+
+    dispatch: Evaluatable[Hashable]
+    lookup: Dict[Hashable, Evaluatable[A]]
+    default: MaybeMissing[Evaluatable[A]]
+    _lock: threading.Lock
+
+    def __init__(
+        self,
+        dispatch: Evaluatable[Hashable],
+        lookup: Dict[Hashable, Evaluatable[A]],
+        default: MaybeMissing[MaybeEvaluatable[A]] = MISSING,
+    ):
+        self.dispatch = dispatch
+        self.lookup = lookup
+        self.default = (
+            Evaluatable.ensure(default) if default is not MISSING else default
+        )
+        self._lock = threading.Lock()
+
+    def evaluate(self, options: Options) -> A:
+        return self.switch.evaluate(options)
+
+    def validate(self, options: Options) -> None:
+        self.switch.validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        return self.switch.keys(options)
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        return self.switch.explain(options)
+
+    def __repr__(self) -> str:
+        if self.default is MISSING:
+            return f"Overloaded({self.dispatch!r}, {self.lookup!r})"
+
+        return f"Overloaded({self.dispatch!r}, {self.lookup!r}, {self.default!r})"
+
+    def register(self, key: Hashable, value: Evaluatable[A]) -> None:
+        with self._lock:
+            self.lookup = {**self.lookup, key: value}
+
+    @property
+    def switch(self) -> Evaluatable[A]:
+        return switch(self.dispatch, self.lookup, default=self.default)
+
+    def overload(
+        self,
+        alias: Union[Hashable, List[Hashable]],
+    ) -> Callable[[Callable[P, A]], FunctionApplication[P, A]]:
+        aliases = alias if isinstance(alias, list) else [alias]
+
+        def decorator(func: Callable[P, A]) -> FunctionApplication[P, A]:
+            overload = FunctionApplication.lift(func)
+            for key in aliases:
+                self.register(key, overload)
+
+            return overload
+
+        return decorator
+
+
+def overloaded(
+    dispatch: Evaluatable[Hashable],
+) -> Callable[[Evaluatable[A]], Overloaded[A]]:
+    return lambda default: Overloaded(dispatch, {}, default)
diff --git a/labrea/pipeline.py b/labrea/pipeline.py
new file mode 100644
index 0000000..7ae59ff
--- /dev/null
+++ b/labrea/pipeline.py
@@ -0,0 +1,171 @@
+import sys
+
+if sys.version_info < (3, 10):
+    from typing_extensions import Concatenate, ParamSpec
+else:
+    from typing import Concatenate, ParamSpec
+
+from typing import (
+    Any,
+    Callable,
+    Generic,
+    Iterable,
+    Iterator,
+    Optional,
+    Set,
+    TypeVar,
+    Union,
+    cast,
+    overload,
+)
+
+from .application import PartialApplication
+from .types import Evaluatable, MaybeEvaluatable, Options, Transformation, Value
+
+A = TypeVar("A")
+B = TypeVar("B", covariant=True)
+C = TypeVar("C")
+P = ParamSpec("P")
+
+
+class PipelineStep(Evaluatable[Callable[[A], B]], Transformation[A, B]):
+    step: Evaluatable[Callable[[A], B]]
+    _name: Optional[str]
+
+    def __init__(
+        self, step: Evaluatable[Callable[[A], B]], _name: Optional[str] = None
+    ) -> None:
+        self.step = step
+        self._name = _name
+
+    def evaluate(self, options: Options) -> Callable[[A], B]:
+        return self.step.evaluate(options)
+
+    def validate(self, options: Options) -> None:
+        self.step.validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        return self.step.keys(options)
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        return self.step.explain(options)
+
+    def transform(self, value: A, options: Optional[Options] = None) -> B:
+        return self(options)(value)
+
+    def __repr__(self) -> str:
+        return f"<PipelineStep {self._name or repr(self.step)}>"
+
+    def __add__(
+        self, other: Union["Pipeline[B, C]", "PipelineStep[B, C]"]
+    ) -> "Pipeline[A, C]":
+        base: Pipeline[A, B] = Pipeline(self)
+        return base + other
+
+    def __eq__(self, other: Any) -> bool:
+        return isinstance(other, PipelineStep) and self.step == other.step
+
+
+class _Identity(Generic[A], PipelineStep[A, A]):
+    def __new__(cls) -> "_Identity[A]":
+        return super().__new__(cls)
+
+    def __init__(self) -> None:
+        super().__init__(Value(lambda x: x), "Identity")
+
+
+Identity: _Identity = _Identity()
+
+
+class Pipeline(
+    Evaluatable[Callable[[A], B]],
+    Iterable[Evaluatable[Callable[[Any], Any]]],
+    Transformation[A, B],
+):
+    tail: PipelineStep[Any, B]
+    rest: Optional["Pipeline[A, Any]"]
+
+    @overload
+    def __new__(cls) -> "Pipeline[A, A]":
+        ...  # pragma: nocover
+
+    @overload
+    def __new__(cls, tail: PipelineStep[B, C]) -> "Pipeline[B, C]":
+        ...  # pragma: nocover
+
+    @overload
+    def __new__(
+        cls, tail: PipelineStep[B, C], rest: "Pipeline[A, B]"
+    ) -> "Pipeline[A, C]":
+        ...  # pragma: nocover
+
+    def __new__(
+        cls,
+        tail: PipelineStep[B, C] = Identity,
+        rest: Optional["Pipeline[A, B]"] = None,
+    ):
+        return super().__new__(cls)
+
+    def __init__(
+        self,
+        tail: PipelineStep[Any, B] = Identity,
+        rest: Optional["Pipeline[A, Any]"] = None,
+    ) -> None:
+        if rest is not None and rest.empty:
+            rest = None
+
+        self.tail = tail
+        self.rest = rest
+
+    def evaluate(self, options: Options) -> Callable[[A], B]:
+        tail = self.tail.evaluate(options)
+        rest = self.rest.evaluate(options) if self.rest else lambda x: x
+        return lambda x: tail(rest(x))
+
+    def validate(self, options: Options) -> None:
+        self.tail.validate(options)
+        if self.rest:
+            self.rest.validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        return self.tail.keys(options) | (
+            self.rest.keys(options) if self.rest else set()
+        )
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        return self.tail.explain(options) | (
+            self.rest.explain(options) if self.rest else set()
+        )
+
+    def transform(self, value: A, options: Optional[Options] = None) -> B:
+        return self(options)(value)
+
+    def __repr__(self) -> str:
+        if self.empty:
+            return "<Pipeline Identity>"
+        return f'<Pipeline {" + ".join(map(repr, self))}>'
+
+    def __add__(self, other: MaybeEvaluatable[Callable[[B], C]]) -> "Pipeline[A, C]":
+        if isinstance(other, PipelineStep):
+            return Pipeline(other, self)
+        elif isinstance(other, Pipeline):
+            if other.empty:
+                return cast(Pipeline[A, C], self)
+            elif other.rest is None:
+                return Pipeline(other.tail, self)
+            return (self + other.rest) + other.tail
+        else:
+            return Pipeline(PipelineStep(Evaluatable.ensure(other)), self)
+
+    def __iter__(self) -> Iterator[Evaluatable[Callable[[Any], Any]]]:
+        if self.rest is not None:
+            yield from self.rest
+        yield self.tail
+
+    @property
+    def empty(self) -> bool:
+        return self.tail == Identity and self.rest is None
+
+
+def pipeline_step(func: Callable[Concatenate[A, P], B]) -> "PipelineStep[A, B]":
+    return PipelineStep(PartialApplication.lift(func), getattr(func, "__name__", None))
diff --git a/labrea/pipelines.py b/labrea/pipelines.py
deleted file mode 100644
index 318eb6b..0000000
--- a/labrea/pipelines.py
+++ /dev/null
@@ -1,155 +0,0 @@
-import functools
-import inspect
-import sys
-import warnings
-from typing import Callable, Generic, Optional, TypeVar
-
-from functional_pypelines.core import Pipeline
-from functional_pypelines.validator import (
-    FAILURE,
-    SUCCESS,
-    ValidationResult,
-    ValidatorPipeline,
-)
-
-from .collections import DatasetDict
-from .types import Evaluatable, JSONDict, ValidationError, Value
-
-if sys.version_info < (3, 10):
-    from typing_extensions import Concatenate, ParamSpec
-else:
-    from typing import Concatenate, ParamSpec
-
-
-A = TypeVar("A")
-B = TypeVar("B")
-P = ParamSpec("P")
-
-
-# -----------------------------------------------------------------------------
-# Core of Labrea Pypeline
-# -----------------------------------------------------------------------------
-class LabreaPipelineData(Generic[A]):
-    value: A
-    options: JSONDict
-
-    def __init__(self, value: A, options: Optional[JSONDict] = None):
-        self.value = value
-        self.options = options or {}
-
-    def __eq__(self, other):
-        if not isinstance(other, LabreaPipelineData):
-            return False
-
-        return (self.value, self.options) == (other.value, other.options)
-
-    def __repr__(self):
-        return (
-            f"{self.__class__.__name__}("
-            f"{repr(self.value)}, "
-            f"options={repr(self.options)})"
-        )
-
-
-@Pipeline.step
-def _unwrap_pipeline_data(data: LabreaPipelineData):
-    return data.value
-
-
-class LabreaPipeline(Pipeline[LabreaPipelineData[A], LabreaPipelineData[B]]):
-    """Subclass of functional_pypelines.core.Pipeline for use with Labrea"""
-
-    @staticmethod
-    def default_data() -> LabreaPipelineData[None]:
-        return LabreaPipelineData(None)
-
-    @classmethod
-    def step(cls, f: Callable[Concatenate[A, P], B]) -> "LabreaPipeline[A, B]":
-        """Decorator to create a new LabreaPipeline step from a function
-
-        LabreaPipeline steps are created similarly to Datasets, but with
-        a single first argument with no default value. The first argument
-        is the value of the previous step in the pipeline. All other arguments
-        must have default values, which will be evaluated using the options
-        dictionary passed to the pipeline.
-
-        Example Usage
-        -------------
-        >>> from labrea import LabreaPipeline, Option
-        >>> @LabreaPipeline.step
-        ... def add_n(x: int, n: int = Option('N')) -> int:
-        ...    return x + n
-        >>>
-        >>> @LabreaPipeline
-        ... def multiply_by_m(x: int, m: int = Option('M')) -> int:
-        ...    return x * m
-        >>>
-        >>> pipeline = add_n >> multiply_by_m
-        >>> print(pipeline(1, options={'N': 2, 'M': 3})) # 9
-        """
-        signature = inspect.signature(f)
-        for i, (key, val) in enumerate(signature.parameters.items()):
-            if i == 0 and val.default is not signature.empty:
-                warnings.warn(
-                    f"Argument {key} has default value {val.default} that "
-                    f"that will be ignored",
-                    stacklevel=2,
-                )
-            elif i > 0 and val.default is signature.empty:
-                raise TypeError(f"Argument {key} must have a default value")
-        kwargs = DatasetDict(
-            {
-                key: (
-                    val.default
-                    if isinstance(val.default, Evaluatable)
-                    else Value(val.default)
-                )
-                for key, val in list(signature.parameters.items())[1:]
-            }
-        )
-
-        @functools.wraps(f)
-        def _step(data: LabreaPipelineData[A]) -> LabreaPipelineData[B]:
-            return LabreaPipelineData(
-                f(
-                    data.value,  # type: ignore [arg-type]
-                    **kwargs.evaluate(data.options),
-                ),
-                data.options,
-            )
-
-        setattr(_step, "_labrea_kwargs", kwargs)
-
-        return cls(_step)
-
-    @classmethod
-    def wrap(cls, *args, **kwargs) -> "LabreaPipelineData":
-        if len(args) and isinstance(args[0], LabreaPipelineData):
-            return args[0]
-        elif len(args) == 0 and "value" not in kwargs:
-            return LabreaPipelineData(None, **kwargs)
-
-        return LabreaPipelineData(*args, **kwargs)
-
-    @property
-    def base_validator(self) -> ValidatorPipeline:
-        return validator
-
-    unwrap = _unwrap_pipeline_data
-
-
-# -----------------------------------------------------------------------------
-# Validator Steps
-# -----------------------------------------------------------------------------
-@ValidatorPipeline.step
-def validator(pipeline: LabreaPipeline, data: LabreaPipelineData) -> ValidationResult:
-    for step in pipeline:
-        try:
-            step_kwargs: DatasetDict = getattr(
-                step, "_labrea_kwargs", DatasetDict({})  # type: ignore [arg-type]
-            )
-            step_kwargs.validate(data.options)
-        except ValidationError as e:
-            return FAILURE(e.args[0])
-
-    return SUCCESS
diff --git a/labrea/runtime.py b/labrea/runtime.py
new file mode 100644
index 0000000..82f933b
--- /dev/null
+++ b/labrea/runtime.py
@@ -0,0 +1,107 @@
+import sys
+
+if sys.version_info < (3, 11):
+    from typing_extensions import Self
+else:
+    from typing import Self
+
+import threading
+from typing import Callable, Dict, Generic, Mapping, Optional, Type, TypeVar, Union
+
+from .types import Options
+
+A = TypeVar("A", covariant=True)
+R = TypeVar("R", covariant=True, bound="Request")
+
+
+lock = threading.Lock()
+
+
+Handler = Callable[[R], A]
+
+
+class Request(Generic[A]):
+    options: Options
+
+    def run(self) -> A:
+        return current_runtime().run(self)
+
+    @classmethod
+    def handle(cls, handler: Handler[Self, A]) -> Handler[Self, A]:
+        handle_by_default(cls, handler)
+        return handler
+
+
+_DEFAULT_HANDLERS: Dict[Type[Request], Handler] = {}
+
+
+class Runtime:
+    handlers: Mapping[Type[Request], Handler]
+    previous: Optional["Runtime"]
+
+    def __init__(
+        self, handlers: Optional[Mapping[Type[Request], Handler]] = None
+    ) -> None:
+        self.handlers = {**_DEFAULT_HANDLERS, **(handlers or {})}
+        self.previous = None
+
+    def handle(
+        self,
+        request: Union[Type[R], Mapping[Type[Request], Handler]],
+        handler: Optional[Handler[R, A]] = None,
+    ) -> "Runtime":
+        if isinstance(request, Mapping):
+            return Runtime({**self.handlers, **request})
+        elif isinstance(request, type) and callable(handler):
+            return Runtime({**self.handlers, request: handler})  # type: ignore
+
+        raise TypeError(
+            "Runtime.handle() requires either a request type and handler, "
+            "or a mapping of request types to handlers."
+        )
+
+    def run(self, request: Request[A]) -> A:
+        try:
+            handler = self.handlers[type(request)]
+        except KeyError as e:
+            raise TypeError(
+                f"No handler for request type {type(request).__qualname__}"
+            ) from e
+
+        return handler(request)
+
+    def __enter__(self):
+        with lock:
+            self.previous = _RUNTIMES.get(threading.current_thread())
+            _RUNTIMES[threading.current_thread()] = self
+            return self
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        with lock:
+            _RUNTIMES[threading.current_thread()] = self.previous
+            self.previous = None
+
+
+_RUNTIMES: Dict[threading.Thread, Runtime] = {}
+
+
+def current_runtime() -> Runtime:
+    with lock:
+        return _RUNTIMES.setdefault(threading.current_thread(), Runtime())
+
+
+def handle(
+    request: Union[Type[R], Mapping[Type[Request], Handler]],
+    handler: Optional[Handler[R, A]] = None,
+) -> Runtime:
+    return current_runtime().handle(request, handler)
+
+
+def handle_by_default(request: Type[R], handler: Handler[R, A]) -> None:
+    with lock:
+        _DEFAULT_HANDLERS[request] = handler
+
+
+def inherit(parent: threading.Thread):
+    with lock:
+        _RUNTIMES[threading.current_thread()] = _RUNTIMES.get(parent, Runtime())
diff --git a/labrea/switch.py b/labrea/switch.py
deleted file mode 100644
index 4d7d65b..0000000
--- a/labrea/switch.py
+++ /dev/null
@@ -1,133 +0,0 @@
-from typing import Dict, Set, TypeVar, Union
-
-from .options import _UNREACHABLE, Option
-from .types import (
-    Evaluatable,
-    EvaluationError,
-    JSONDict,
-    JSONType,
-    ValidationError,
-    Value,
-)
-
-A = TypeVar("A")
-
-
-class Switch(Evaluatable[A]):
-    """A switch statement that evaluates to one of several Evaluatables
-
-    Takes an Evaluatable that evaluates to a value, and a dict of values to
-    lookup. If the value is in the dict, evaluates to the corresponding
-    Evaluatable. If the value is not in the dict, evaluates to the default
-    Evaluatable (if provided). If no default is provided, raises an
-    EvaluationError.
-    """
-
-    option: Evaluatable
-    lookup: Dict[JSONType, Evaluatable[A]]
-    default: Evaluatable[A]
-
-    def __init__(
-        self,
-        option: Union[str, Evaluatable[JSONType]],
-        lookup: Dict[JSONType, Union[Evaluatable[A], A]],
-        default: Evaluatable[A] = _UNREACHABLE,
-    ):
-        """Create a new Switch Evaluatable
-
-        Parameters
-        ----------
-        option : Union[str, Evaluatable[JSONType]]
-            The Evaluatable that evaluates to the value to lookup.
-            If a string is provided, it will be wrapped in an Option.
-        lookup : Dict[JSONType, Union[Evaluatable[A], A]]
-            The dict of values to lookup. If a value is not in the dict, the
-            default will be used.
-        default : Evaluatable[A]
-            The default Evaluatable to use if the value is not in the dict.
-            If not provided, an EvaluationError will be raised if the value is
-            not in the lookup dict.
-        """
-        self.option = option if isinstance(option, Evaluatable) else Option(option)
-        self.lookup = {
-            key: val if isinstance(val, Evaluatable) else Value(val)
-            for key, val in lookup.items()
-        }
-        self.default = default
-
-    def get_dispatch(self, options: JSONDict) -> Evaluatable[A]:
-        try:
-            option_val = self.option.evaluate(options)
-        except EvaluationError:
-            option_val = "{NONE}"
-
-        if not isinstance(option_val, (str, int, float, bool)):
-            raise EvaluationError(
-                self.option,
-                option_val,
-                f"{self.option} has value {option_val} of type "
-                f"{type(option_val)}, but it must be of type string, int, "
-                f"float, or bool.",
-            )
-
-        dispatch = self.lookup.get(option_val, self.default)
-
-        if dispatch is _UNREACHABLE:
-            raise EvaluationError(
-                self.option,
-                option_val,
-                f"{self.option} has value {option_val}, but it "
-                f"must take a value in {list(self.lookup.keys())}.",
-            )
-
-        return dispatch
-
-    def evaluate(self, options: JSONDict) -> A:
-        """Evaluate the switch statement
-
-        Determines which entry in the lookup dict to use, and evaluates it.
-
-        See Also
-        --------
-        labrea.types.Evaluatable.evaluate
-        """
-        dispatch = self.get_dispatch(options)
-        return dispatch.evaluate(options)
-
-    def validate(self, options: JSONDict) -> None:
-        """Validate the switch statement
-
-        Determines which entry in the lookup dict to use, and validates it.
-
-        See Also
-        --------
-        labrea.types.Evaluatable.validate
-        """
-        try:
-            dispatch = self.get_dispatch(options)
-        except EvaluationError as err:
-            raise ValidationError(*err.args) from err
-
-        dispatch.validate(options)
-
-    def keys(self, options: JSONDict) -> Set[str]:
-        """Return the keys that the switch statement depends on
-
-        Determines which entry in the lookup dict to use, and returns the keys
-        that it depends on.
-
-        See Also
-        --------
-        labrea.types.Evaluatable.keys
-        """
-        dispatch = self.get_dispatch(options)
-
-        keys = dispatch.keys(options).copy()
-        if dispatch is not self.default:
-            keys.update(self.option.keys(options))
-
-        return keys
-
-    @property
-    def has_default(self):
-        return self.default is not _UNREACHABLE
diff --git a/labrea/template.py b/labrea/template.py
index b6fc3d2..90f8229 100644
--- a/labrea/template.py
+++ b/labrea/template.py
@@ -1,11 +1,12 @@
 import re
 import warnings
-from typing import Any, Dict, Set
+from typing import Any, Dict, Optional, Set
 
 from confectioner import mix
-from confectioner.templating import dotted_key_exists, find_template_keys, resolve
+from confectioner.templating import find_template_keys, resolve
 
-from .types import Evaluatable, EvaluationError, JSONDict, ValidationError, Value
+from .exceptions import KeyNotFoundError
+from .types import Evaluatable, Options, Value
 
 TEMPLATE_PARAM = re.compile(r"^:[a-zA-Z_][a-zA-Z0-9_]*:$")
 
@@ -20,14 +21,14 @@ class Template(Evaluatable[str]):
     the template using the standard :code:`{NESTED.KEY}` syntax from
     confectioner.
 
-    Parameters
+    Arguments
     ----------
     template : str
         The template string to evaluate
     kwargs : Any
         The parameters to use when evaluating the template. Each parameter
         can be any Evaluatable. If a parameter is not an Evaluatable, it is
-        used as a constant value.
+        used as a constant key.
 
     Raises
     ------
@@ -39,11 +40,11 @@ class Template(Evaluatable[str]):
     -------------
     >>> from labrea import Template, dataset, Option
     >>> @dataset
-    ... def b_dataset(b: str = Option('B')) -> str:
+    ... def b_dataset(b: str = Option('V')) -> str:
     ...     return b
     >>>
     >>> t = Template('{A.X} {:b:}', b=b_dataset)
-    >>> t({'A': {'X': 'Hello'}, 'B': 'World!'})  # 'Hello World!'
+    >>> t({'A': {'X': 'Hello'}, 'V': 'World!'})  # 'Hello World!'
 
     """
 
@@ -77,35 +78,65 @@ def __init__(self, template: str, **kwargs):
                 f"{template}"
             )
 
-    def evaluate(self, options: JSONDict) -> str:
+    def evaluate(self, options: Options) -> str:
         """Evaluates the template using the options dictionary."""
         params = {f":{key}:": val.evaluate(options) for key, val in self.params.items()}
 
         try:
             return str(resolve(self.template, mix(options, params)))  # type: ignore
         except KeyError as e:
-            raise EvaluationError(
-                f"Missing option {e.args[0]} for {self.template}"
-            ) from e
+            raise KeyNotFoundError((*e.args, "UNKNOWN")[0], self) from e
 
-    def validate(self, options: JSONDict) -> None:
+    def validate(self, options: Options) -> None:
         """Validates that the template can be evaluated using the options."""
+        from .option import Option
+
         for val in self.params.values():
             val.validate(options)
 
         for key in find_template_keys(self.template):
             if TEMPLATE_PARAM.match(key):
                 continue
-            if not dotted_key_exists(key, options):
-                raise ValidationError(f"Missing option {key} for {self.template}")
+            try:
+                Option(key).validate(options)
+            except KeyNotFoundError as e:
+                raise KeyNotFoundError(e.key, self) from e
 
-    def keys(self, options: JSONDict) -> Set[str]:
+    def keys(self, options: Options) -> Set[str]:
         """Returns the keys that this object depends on."""
-        return set.union(
-            *(value.keys(options) for value in self.params.values()),
-            {
-                key
-                for key in find_template_keys(self.template)
-                if not TEMPLATE_PARAM.match(key)
-            },
-        )
+        from .option import Option
+
+        keys = set().union(*(value.keys(options) for value in self.params.values()))
+        for key in find_template_keys(self.template):
+            if TEMPLATE_PARAM.match(key):
+                continue
+            try:
+                keys.update(Option(key).keys(options))
+            except KeyNotFoundError as e:
+                raise KeyNotFoundError(e.key, self) from e
+
+        return keys
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        """Returns the keys that this object depends on."""
+        from .option import Option
+
+        options = options or {}
+        keys = set().union(*(value.explain(options) for value in self.params.values()))
+        for key in find_template_keys(self.template):
+            if TEMPLATE_PARAM.match(key):
+                continue
+
+            keys.update(Option(key).explain(options))
+
+        return keys
+
+    def __repr__(self) -> str:
+        if self.params:
+            return (
+                f"Template({self.template!r}, "
+                f"{', '.join(f'{key}={value!r}' for key, value in self.params.items())}"
+                f")"
+            )
+
+        return f"Template({self.template!r})"
diff --git a/labrea/types.py b/labrea/types.py
index 0fab936..722d9ee 100644
--- a/labrea/types.py
+++ b/labrea/types.py
@@ -1,47 +1,53 @@
-import sys
+import hashlib
+import json
+from abc import ABC, abstractmethod
 from copy import deepcopy
-from dataclasses import dataclass
-from typing import Callable, List, Mapping, Optional, Set, TypeVar, Union
+from typing import (
+    Callable,
+    Generic,
+    List,
+    Mapping,
+    Optional,
+    Protocol,
+    Set,
+    TypeVar,
+    Union,
+)
 
-# Support Python < 3.8
-if sys.version_info >= (3, 8):  # pragma: nocover
-    from typing import Protocol, runtime_checkable
-else:  # pragma: nocover
-    from typing_extensions import Protocol, runtime_checkable
+from confectioner.templating import get_dotted_key
 
+from .exceptions import EvaluationError, InsufficientInformationError
 
-JSONScalar = Union[int, float, bool, str, None]
-JSONType = Union[JSONScalar, "JSONDict", List["JSONType"]]  # type: ignore
-JSONDict = Mapping[str, JSONType]  # type: ignore
-
-
-Alias = JSONScalar
-MultiAlias = List[Alias]
+JSONScalar = Union[str, int, float, bool, None]
+JSON = Union[JSONScalar, Mapping[str, "JSON"], List["JSON"]]
+Options = Mapping[str, JSON]
 
 
 A = TypeVar("A", covariant=True)
 B = TypeVar("B", covariant=True)
+R = TypeVar("R", covariant=True)
+T = TypeVar("T", contravariant=True)
 
 
-@runtime_checkable
-class Validatable(Protocol):
-    """A protocol for objects that can be validated.
+class Transformation(Protocol[T, R]):
+    def transform(self, value: T, options: Optional[Options] = None) -> R:
+        raise NotImplementedError  # pragma: nocover
 
-    This protocol is used to validate objects that are not yet evaluated.
-    """
 
-    def validate(self, options: JSONDict) -> None:
+class Validatable(ABC):
+    @abstractmethod
+    def validate(self, options: Options) -> None:
         """Validate the object.
 
-        Parameters
+        Arguments
         ----------
-        options : JSONDict
+        options : Options
             The options dictionary to validate against.
 
         Raises
         ------
-        ValidationError
-            If the object is invalid.
+        KeyNotFoundError
+            If a required key is not found in the options dictionary.
 
         Notes
         -----
@@ -50,13 +56,19 @@ def validate(self, options: JSONDict) -> None:
         """
         raise NotImplementedError  # pragma: nocover
 
-    def keys(self, options: JSONDict) -> Set[str]:
+
+class Cacheable(ABC):
+    @abstractmethod
+    def keys(self, options: Options) -> Set[str]:
         """
-        Return the keys that this object depends on.
+        Return the keys from the Options that this object depends on.
 
-        Parameters
+        This should only return the keys that are present in the options dictionary.
+        If a key is not present but is required, a KeyNotFoundError should be raised.
+
+        Arguments
         ----------
-        options : JSONDict
+        options : Options
             The options dictionary to validate against.
 
         Returns
@@ -64,6 +76,11 @@ def keys(self, options: JSONDict) -> Set[str]:
         Set[str]
             The keys that this object depends on.
 
+        Raises
+        ------
+        KeyNotFoundError
+            If a required key is not found in the options dictionary.
+
         Notes
         -----
         This method should be called recursively on all child objects where
@@ -71,9 +88,51 @@ def keys(self, options: JSONDict) -> Set[str]:
         """
         raise NotImplementedError  # pragma: nocover
 
+    def fingerprint(self, options: Options) -> bytes:
+        """Return a fingerprint, which is a unique identifier for a given evaluation."""
+        fingerprint = hashlib.blake2b(digest_size=64)
+
+        for key in sorted(self.keys(options)):
+            fingerprint.update(key.encode())
+            fingerprint.update(
+                json.dumps(get_dotted_key(key, options), sort_keys=True).encode()
+            )
+
+        return fingerprint.digest()
+
+
+class Explainable(ABC):
+    @abstractmethod
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        """Return all keys that this object depends on.
+
+        This should return all keys that this object depends on, including those that are not
+        present in the options dictionary. If the keys required cannot be determined, an
+        InsufficientInformationError should be raised.
+
+        Arguments
+        ----------
+        options : Options
+            The options dictionary to validate against.
+
+        Returns
+        -------
+        Set[str]
+            The keys that this object depends on.
+
+        Raises
+        ------
+        InsufficientInformationError
+            If the keys required cannot be determined.
+
+        Notes
+        -----
+        This method should be called recursively on all child objects where
+        applicable.
+        """
+
 
-@runtime_checkable
-class Evaluatable(Validatable, Protocol[A]):
+class Evaluatable(Generic[A], Cacheable, Explainable, Validatable, ABC):
     """A protocol for objects that can be evaluated.
 
     This protocol is used to define a common interface for objects that can be
@@ -81,41 +140,42 @@ class Evaluatable(Validatable, Protocol[A]):
     defined in this library implement this protocol.
     """
 
-    def __call__(self, options: JSONDict) -> A:
+    def __call__(self, options: Optional[Options] = None) -> A:
         """Evaluate the object.
 
-        Any object that explicitly inherits from Evaluatble can be called as a
+        Any object that explicitly inherits from Evaluatable can be called as a
         function
 
-        Parameters
+        Arguments
         ----------
-        options : JSONDict
+        options : Options
             The options dictionary to evaluate against.
 
         Returns
         -------
         A
-            The evaluated value.
+            The evaluated key.
 
         Raises
         ------
-        EvaluationError
-            If the object cannot be evaluated.
+        KeyNotFoundError
+            If a required key is not found in the options dictionary.
         """
-        return self.evaluate(options)
+        return self.evaluate(options or {})
 
-    def evaluate(self, options: JSONDict) -> A:
+    @abstractmethod
+    def evaluate(self, options: Options) -> A:
         """Evaluate the object.
 
-        Parameters
+        Arguments
         ----------
-        options : JSONDict
+        options : Options
             The options dictionary to evaluate against.
 
         Returns
         -------
         A
-            The evaluated value.
+            The evaluated key.
 
         Raises
         ------
@@ -129,43 +189,100 @@ def evaluate(self, options: JSONDict) -> A:
         """
         raise NotImplementedError  # pragma: nocover
 
-    @property
-    def result(self) -> A:
-        """Dummy property to allow for type checking.
+    @abstractmethod
+    def __repr__(self) -> str:
+        """Return a string representation of the object.
 
-        This property is used to allow for type checking of the return value of
-        an Evaluatable. At runtime, it simply returns the Evaluatable itself.
+        Returns
+        -------
+        str
+            A string representation of the object.
         """
-        return self  # type: ignore
+        raise NotImplementedError  # pragma: nocover
 
+    @staticmethod
+    def unit(value: T) -> "Value[T]":
+        """Wrap a key in a Value object.
 
-Callback = Callable[[A, JSONDict], Optional[B]]
-Callback.__doc__ = """A callback function
+        This method is used to wrap a value in a Value object. This is useful
+        when you want to treat a value as an Evaluatable.
 
-Callbacks will be used to extend the behavior of an Evaluatable, either by
-running a computation before or after the .evaluate() method is called.
-"""
+        Arguments
+        ----------
+        value : A
+            The value to wrap.
+
+        Returns
+        -------
+        Value[A]
+            The wrapped value.
+        """
+        return Value(value)
+
+    @staticmethod
+    def ensure(value: "MaybeEvaluatable[T]") -> "Evaluatable[T]":
+        """Ensure that a value is an Evaluatable.
+
+        This method is used to ensure that a value is an Evaluatable. If the
+        value is already an Evaluatable, it is returned as is. If the value is
+        not an Evaluatable, it is wrapped in a Value object.
+
+        Arguments
+        ----------
+        value : MaybeEvaluatable[A]
+            The value to ensure is an Evaluatable.
+
+        Returns
+        -------
+        Evaluatable[A]
+            The value as an Evaluatable.
+        """
+        if isinstance(value, Evaluatable):
+            return value
+        return Value(value)
+
+    def apply(self, func: "MaybeEvaluatable[Callable[[A], B]]") -> "Evaluatable[B]":
+        if not isinstance(func, Evaluatable) and not callable(func):
+            raise TypeError(f"Cannot apply object of type ({type(func)})")
+
+        return Apply(self, self.ensure(func))
+
+    def bind(self, func: Callable[[A], "Evaluatable[B]"]) -> "Evaluatable[B]":
+        if not callable(func):
+            raise TypeError(f"Cannot bind object of type ({type(func)})")
+        return Bind(self, func)
+
+    @property
+    def result(self) -> A:
+        return self  # type: ignore
+
+    def __rshift__(
+        self, other: "MaybeEvaluatable[Callable[[A], B]]"
+    ) -> "Evaluatable[B]":
+        return self.apply(other)
 
 
-@dataclass
 class Value(Evaluatable[A]):
-    """Simple wrapper for a plain value.
+    """Simple wrapper for a plain key.
 
-    This class is used to wrap a value that is not an Evaluatable and make it
+    This class is used to wrap a key that is not an Evaluatable and make it
     an Evaluatable.
 
-    Parameters
+    Arguments
     ----------
     value : A
-        The value to wrap.
+        The key to wrap.
     """
 
     value: A
 
-    def evaluate(self, options: JSONDict) -> A:
+    def __init__(self, value: A) -> None:
+        self.value = value
+
+    def evaluate(self, options: Options) -> A:
         """Evaluate the object.
 
-        Returns the value that was wrapped. If possible, the value is deep
+        Returns the key that was wrapped. If possible, the key is deep
         copied.
         """
         try:
@@ -173,35 +290,90 @@ def evaluate(self, options: JSONDict) -> A:
         except Exception:  # noqa: E722
             return self.value
 
-    def validate(self, options: JSONDict) -> None:
+    def validate(self, options: Options) -> None:
         """Validate the object.
 
-        This method does nothing, as the value can always be evaluated.
+        This method does nothing, as the key can always be evaluated.
         """
         pass
 
-    def keys(self, options: JSONDict) -> Set[str]:
+    def keys(self, options: Options) -> Set[str]:
         """Return the keys that this object depends on.
 
-        This method returns an empty set, as the value does not depend on any
+        This method returns an empty set, as the key does not depend on any
         keys.
         """
         return set()
 
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        return set()
 
-class EvaluationError(ValueError):
-    """Error raised when an object cannot be evaluated."""
+    def __repr__(self) -> str:
+        return f"Value({self.value!r})"
 
-    pass  # pragma: nocover
+    def __eq__(self, other):
+        return isinstance(other, Value) and self.value == other.value
 
 
-class ValidationError(KeyError):
-    """Error raised when an object cannot be validated.
+class Apply(Generic[A, B], Evaluatable[B]):
+    evaluatable: Evaluatable[A]
+    func: Evaluatable[Callable[[A], B]]
+
+    def __init__(
+        self, evaluatable: Evaluatable[A], func: Evaluatable[Callable[[A], B]]
+    ) -> None:
+        self.evaluatable = evaluatable
+        self.func = func
+
+    def evaluate(self, options: Options) -> B:
+        return self.func(options)(self.evaluatable(options))
+
+    def validate(self, options: Options) -> None:
+        self.evaluatable.validate(options)
+        self.func.validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        return self.evaluatable.keys(options) | self.func.keys(options)
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        return self.evaluatable.explain(options) | self.func.explain(options)
+
+    def __repr__(self) -> str:
+        return f"{self.evaluatable!r}.apply({self.func!r})"
+
+
+class Bind(Generic[A, B], Evaluatable[B]):
+    evaluatable: Evaluatable[A]
+    func: Callable[[A], Evaluatable[B]]
+
+    def __init__(
+        self, evaluatable: Evaluatable[A], func: Callable[[A], Evaluatable[B]]
+    ) -> None:
+        self.evaluatable = evaluatable
+        self.func = func
+
+    def evaluate(self, options: Options) -> B:
+        return self.func(self.evaluatable(options)).evaluate(options)
+
+    def validate(self, options: Options) -> None:
+        self.evaluatable.validate(options)
+        self.func(self.evaluatable(options)).validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        return self.evaluatable.keys(options) | self.func(
+            self.evaluatable(options)
+        ).keys(options)
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        try:
+            return self.evaluatable.explain(options) | self.func(
+                self.evaluatable(options)
+            ).explain(options)
+        except EvaluationError as e:
+            raise InsufficientInformationError(f"Cannot explain {self}", self) from e
+
+    def __repr__(self) -> str:
+        return f"{self.evaluatable!r}.bind({self.func!r})"
 
-    This error is raised when an object cannot be validated against an options
-    dictionary. This means that we can statically determine that the object
-    cannot be evaluated, usually due to a missing key in the options
-    dictionary.
-    """
 
-    pass  # pragma: nocover
+MaybeEvaluatable = Union[Evaluatable[A], A]
diff --git a/mypy.ini b/mypy.ini
new file mode 100644
index 0000000..dac7fa5
--- /dev/null
+++ b/mypy.ini
@@ -0,0 +1,2 @@
+[mypy]
+plugins = labrea/mypy/plugin.py
diff --git a/pyproject.toml b/pyproject.toml
index 54309be..1255de8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -26,9 +26,8 @@ classifiers = [
     "Programming Language :: Python",
 ]
 dependencies = [
-    "typing_extensions>=4.0; python_version<\"3.10\"",
+    "typing_extensions>=4.0; python_version<\"3.11\"",
     "confectioner>=1.0,<2.0",
-    "functional-pypelines>=1.0,<2.0",
 ]
 dynamic = ["version"]
 
@@ -75,6 +74,9 @@ testpaths = [
     "tests/"
 ]
 
+[tool.mypy]
+plugins = ["labrea.mypy.plugin"]
+
 [tool.black]
 line-length = 88
 include = '\.pyi?$'


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index fc0d495..e69de29 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,4 +0,0 @@
-import pathlib
-import sys
-
-sys.path.insert(0, str(pathlib.Path.cwd()))
diff --git a/tests/coverage.svg b/tests/coverage.svg
deleted file mode 100644
index 3438732..0000000
--- a/tests/coverage.svg
+++ /dev/null
@@ -1,21 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" width="99" height="20">
-    <linearGradient id="b" x2="0" y2="100%">
-        <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
-        <stop offset="1" stop-opacity=".1"/>
-    </linearGradient>
-    <mask id="a">
-        <rect width="99" height="20" rx="3" fill="#fff"/>
-    </mask>
-    <g mask="url(#a)">
-        <path fill="#555" d="M0 0h63v20H0z"/>
-        <path fill="#4c1" d="M63 0h36v20H63z"/>
-        <path fill="url(#b)" d="M0 0h99v20H0z"/>
-    </g>
-    <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
-        <text x="31.5" y="15" fill="#010101" fill-opacity=".3">coverage</text>
-        <text x="31.5" y="14">coverage</text>
-        <text x="80" y="15" fill="#010101" fill-opacity=".3">97%</text>
-        <text x="80" y="14">97%</text>
-    </g>
-</svg>
diff --git a/tests/ext/__init__.py b/tests/ext/__init__.py
deleted file mode 100644
index 91a16ed..0000000
--- a/tests/ext/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from . import datasets, interfaces
-
-__all__ = ["datasets", "interfaces"]
diff --git a/tests/ext/datasets.py b/tests/ext/datasets.py
deleted file mode 100644
index 68efaec..0000000
--- a/tests/ext/datasets.py
+++ /dev/null
@@ -1,17 +0,0 @@
-from labrea import Option
-from tests.pkg.datasets import abstract, basic, dispatch
-
-
-@basic.overload
-def basic_overload(bsc: bool = Option("BASIC", False)) -> bool:
-    return bsc
-
-
-@dispatch.overload(alias="custom_alias_dispatch_overload")
-def dispatch_overload() -> str:
-    return "overload"
-
-
-@abstract.overload
-def abstract_overload() -> bool:
-    return True
diff --git a/tests/ext/interfaces.py b/tests/ext/interfaces.py
deleted file mode 100644
index 2874a9a..0000000
--- a/tests/ext/interfaces.py
+++ /dev/null
@@ -1,14 +0,0 @@
-from labrea import dataset
-from tests.pkg.interfaces import Interface1
-
-
-@Interface1.implementation
-class Int1Imp:
-    @staticmethod
-    def a() -> int:
-        return 1
-
-    @staticmethod
-    @dataset
-    def c() -> str:
-        return "overload"
diff --git a/tests/pkg/__init__.py b/tests/pkg/__init__.py
deleted file mode 100644
index 91a16ed..0000000
--- a/tests/pkg/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from . import datasets, interfaces
-
-__all__ = ["datasets", "interfaces"]
diff --git a/tests/pkg/datasets.py b/tests/pkg/datasets.py
deleted file mode 100644
index 613eea5..0000000
--- a/tests/pkg/datasets.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from labrea import Option, abstractdataset, dataset
-
-
-@dataset
-def basic(bsc: bool = Option("BASIC", True)) -> bool:
-    return bsc
-
-
-@dataset(dispatch="CUSTOM_DISPATCH")
-def dispatch() -> str:
-    return "default"
-
-
-@abstractdataset
-def abstract():
-    ...
diff --git a/tests/pkg/interfaces.py b/tests/pkg/interfaces.py
deleted file mode 100644
index e46a204..0000000
--- a/tests/pkg/interfaces.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from labrea import Option, dataset, interface
-
-
-@interface
-class Interface1:
-    a: int
-
-    @staticmethod
-    @dataset
-    def b(bb: str = Option("B")) -> str:
-        return bb
-
-    @staticmethod
-    @dataset
-    def c() -> str:
-        return "default"
diff --git a/tests/requirements.txt b/tests/requirements.txt
deleted file mode 100644
index f416386..0000000
--- a/tests/requirements.txt
+++ /dev/null
@@ -1,6 +0,0 @@
-coverage
-coverage-badge
-pytest
-pytest-console-scripts
-pytest-cov
-pytest-env
diff --git a/tests/test_application.py b/tests/test_application.py
new file mode 100644
index 0000000..e5f3c13
--- /dev/null
+++ b/tests/test_application.py
@@ -0,0 +1,114 @@
+from labrea import Option
+from labrea.application import FunctionApplication, PartialApplication
+from labrea.exceptions import KeyNotFoundError
+import pytest
+
+
+def add(x: float, y: float) -> float:
+    return x + y
+
+
+def test_application():
+    a = Option('A')
+    b = Option('B')
+
+    app = FunctionApplication(add, a, b)
+
+    assert app.evaluate({'A': 1, 'B': 2}) == 3
+    with pytest.raises(KeyNotFoundError):
+        app.evaluate({'A': 1})
+
+    app.validate({'A': 1, 'B': 2})
+    with pytest.raises(KeyNotFoundError):
+        app.validate({'A': 1})
+
+    assert app.keys({'A': 1, 'B': 2}) == {'A', 'B'}
+    with pytest.raises(KeyNotFoundError):
+        app.keys({'A': 1})
+
+    assert app.explain() == {'A', 'B'}
+
+    assert repr(app) == "FunctionApplication(add, Option('A'), Option('B'))"
+
+
+def test_lift():
+    def good(a: float = Option('A'), b: float = Option('B')) -> float:
+        return a + b
+
+    def bad(a: float, b: float) -> float:
+        return a + b
+
+    assert FunctionApplication.lift(good).evaluate({'A': 1, 'B': 2}) == 3
+
+    with pytest.raises(TypeError):
+        FunctionApplication.lift(bad)
+
+    assert FunctionApplication.lift(bad, a=Option('A'), b=Option('B')).evaluate({'A': 1, 'B': 2}) == 3
+
+    @FunctionApplication.lift
+    def good_deco(a: float = Option('A'), b: float = Option('B')) -> float:
+        return a + b
+
+    assert good_deco.evaluate({'A': 1, 'B': 2}) == 3
+
+    @FunctionApplication.lift(a=Option('A'), b=Option('B'))
+    def good_deco_kwargs(a: float, b: float = Option('X')) -> float:
+        return a + b
+
+    assert good_deco_kwargs.evaluate({'A': 1, 'B': 2}) == 3
+
+
+def test_partial_application():
+    b = Option('B')
+
+    app = PartialApplication(add, b)
+
+    assert app.evaluate({'B': 2})(1) == 3
+    with pytest.raises(KeyNotFoundError):
+        app.evaluate({})
+
+    app.validate({'B': 2})
+    with pytest.raises(KeyNotFoundError):
+        app.validate({})
+
+    assert app.keys({'B': 2}) == {'B'}
+    with pytest.raises(KeyNotFoundError):
+        app.keys({})
+
+    assert app.explain() == {'B'}
+
+    assert repr(app) == "PartialApplication(add, Option('B'))"
+
+
+def test_lift_partial():
+    def good(a: float, b: float = Option('B')) -> float:
+        return a + b
+
+    def bad(a: float, b: float) -> float:
+        return a + b
+
+    def bad2(a: float = Option('A'), b: float = Option('B')) -> float:
+        return a + b
+
+    assert PartialApplication.lift(good).evaluate({'B': 2})(1) == 3
+
+    with pytest.raises(TypeError):
+        PartialApplication.lift(bad)
+
+    with pytest.raises(TypeError):
+        PartialApplication.lift(bad2)
+
+    assert PartialApplication.lift(bad, b=Option('B')).evaluate({'B': 2})(1) == 3
+
+    @PartialApplication.lift
+    def good_deco(a: float, b: float = Option('B')) -> float:
+        return a + b
+
+    assert good_deco.evaluate({'B': 2})(1) == 3
+
+    @PartialApplication.lift(b=Option('B'))
+    def good_deco_kwargs(a: float, b: float = Option('X')) -> float:
+        return a + b
+
+    assert good_deco_kwargs.evaluate({'B': 2})(1) == 3
+
diff --git a/tests/test_arguments.py b/tests/test_arguments.py
new file mode 100644
index 0000000..1a028df
--- /dev/null
+++ b/tests/test_arguments.py
@@ -0,0 +1,62 @@
+import pytest
+
+from labrea.arguments import Arguments, EvaluatableArgs, EvaluatableKwargs, arguments
+from labrea.exceptions import KeyNotFoundError
+from labrea.option import Option
+
+
+def test_arguments():
+    assert repr(Arguments(1, 2, 3)) == "Arguments(1, 2, 3)"
+    assert repr(Arguments(a=1, b=2, c=3)) == "Arguments(a=1, b=2, c=3)"
+    assert repr(Arguments(1, b=2)) == "Arguments(1, b=2)"
+    assert repr(Arguments()) == "Arguments()"
+
+    assert Arguments(1, b=2) == Arguments(1, b=2)
+    assert Arguments(1, b=2) != Arguments(1, b=3)
+    assert Arguments(1, b=2) != object()
+
+
+
+def test_evaluatable_args():
+    args = EvaluatableArgs(Option('A'), Option('B'), Option('C'))
+
+    assert args.evaluate({'A': 1, 'B': 2, 'C': 3}) == (1, 2, 3)
+    args.validate({'A': 1, 'B': 2, 'C': 3})
+    with pytest.raises(KeyNotFoundError):
+        args.validate({'A': 1, 'B': 2})
+    assert args.keys({'A': 1, 'B': 2, 'C': 3}) == {'A', 'B', 'C'}
+    with pytest.raises(KeyNotFoundError):
+        args.keys({'A': 1, 'B': 2})
+    assert args.explain() == {'A', 'B', 'C'}
+
+    assert repr(args) == "EvaluatableArgs(Option('A'), Option('B'), Option('C'))"
+
+
+def test_evaluatable_kwargs():
+    kwargs = EvaluatableKwargs(a=Option('A'), b=Option('B'), c=Option('C'))
+
+    assert kwargs.evaluate({'A': 1, 'B': 2, 'C': 3}) == {'a': 1, 'b': 2, 'c': 3}
+    kwargs.validate({'A': 1, 'B': 2, 'C': 3})
+    with pytest.raises(KeyNotFoundError):
+        kwargs.validate({'A': 1, 'B': 2})
+    assert kwargs.keys({'A': 1, 'B': 2, 'C': 3}) == {'A', 'B', 'C'}
+    with pytest.raises(KeyNotFoundError):
+        kwargs.keys({'A': 1, 'B': 2})
+    assert kwargs.explain() == {'A', 'B', 'C'}
+
+    assert repr(kwargs) == "EvaluatableKwargs(a=Option('A'), b=Option('B'), c=Option('C'))"
+
+
+def test_evaluatable_arguments():
+    args = arguments(Option('A'), b=Option('B'))
+
+    assert args.evaluate({'A': 1, 'B': 2}) == Arguments(1, b=2)
+    args.validate({'A': 1, 'B': 2})
+    with pytest.raises(KeyNotFoundError):
+        args.validate({'A': 1})
+    assert args.keys({'A': 1, 'B': 2}) == {'A', 'B'}
+    with pytest.raises(KeyNotFoundError):
+        args.keys({'A': 1})
+    assert args.explain() == {'A', 'B'}
+
+    assert repr(args) == "EvaluatableParameters(Option('A'), b=Option('B'))"
diff --git a/tests/test_cache.py b/tests/test_cache.py
new file mode 100644
index 0000000..f7e9e71
--- /dev/null
+++ b/tests/test_cache.py
@@ -0,0 +1,61 @@
+from typing import List
+import uuid
+
+import pytest
+
+from labrea.application import FunctionApplication
+from labrea.cache import cached, NoCache
+from labrea.option import Option
+import labrea.cache
+
+
+def test_cached():
+    uuid4 = FunctionApplication(uuid.uuid4)
+    cached_uuid4 = cached(uuid4)
+
+    assert uuid4() != uuid4()
+    assert cached_uuid4() == cached_uuid4()
+
+
+def test_nocache():
+    uuid4 = FunctionApplication(uuid.uuid4)
+    cached_uuid4 = cached(uuid4, NoCache())
+
+    assert uuid4() != uuid4()
+    assert cached_uuid4() != cached_uuid4()
+
+
+def test_cached_decorator():
+    @cached
+    @FunctionApplication.lift
+    def uuid4_list(n: int = Option('N')) -> List[uuid.UUID]:
+        return [uuid.uuid4() for _ in range(n)]
+
+    assert uuid4_list({'N': 3}) == uuid4_list({'N': 3})
+    assert uuid4_list({'N': 3}) != uuid4_list({'N': 4})
+
+    @cached(NoCache())
+    @FunctionApplication.lift
+    def uuid4_list(n: int = Option('N')) -> List[uuid.UUID]:
+        return [uuid.uuid4() for _ in range(n)]
+
+
+@pytest.mark.parametrize('method', ['ctx', 'options'])
+def test_disable_cache(method):
+    uuid4 = cached(FunctionApplication(uuid.uuid4))
+
+    a = uuid4()
+    b = uuid4()
+
+    if method == 'ctx':
+        with labrea.cache.disabled():
+            c = uuid4()
+            d = uuid4()
+    else:
+        c = uuid4({'LABREA': {'CACHE': {'DISABLED': True}}})
+        d = uuid4({'LABREA': {'CACHE': {'DISABLED': True}}})
+
+    e = uuid4()
+
+    assert a == b == e
+    assert a != c and c != d
diff --git a/tests/test_coalesce.py b/tests/test_coalesce.py
new file mode 100644
index 0000000..d618816
--- /dev/null
+++ b/tests/test_coalesce.py
@@ -0,0 +1,36 @@
+from labrea.coalesce import Coalesce
+from labrea.option import Option
+from labrea.exceptions import KeyNotFoundError
+import pytest
+
+
+def test_coalesce():
+    a = Option('A')
+    b = Option('B')
+
+    c = Coalesce(a, b)
+
+    assert c({'A': 'Hello'}) == 'Hello'
+    assert c({'B': 'World!'}) == 'World!'
+    assert c({'A': 'Hello', 'B': 'World!'}) == 'Hello'
+    assert c({'B': 'World!', 'C': 'foo'}) == 'World!'
+
+    with pytest.raises(KeyNotFoundError):
+        c({})
+
+    with pytest.raises(KeyNotFoundError):
+        c.validate({})
+
+    with pytest.raises(KeyNotFoundError):
+        c.keys({})
+
+    assert c.explain() == {'B'}
+
+
+def test_repr():
+    assert repr(Coalesce(Option('A'), Option('B'))) == "Coalesce(Option('A'), Option('B'))"
+
+
+def test_init():
+    with pytest.raises(TypeError):
+        Coalesce()
diff --git a/tests/test_collections.py b/tests/test_collections.py
new file mode 100644
index 0000000..35d613b
--- /dev/null
+++ b/tests/test_collections.py
@@ -0,0 +1,23 @@
+from labrea.collections import (
+    evaluatable_dict,
+    evaluatable_list,
+    evaluatable_set,
+    evaluatable_tuple,
+)
+from labrea.option import Option
+
+
+def test_dict():
+    assert evaluatable_dict({'a': Option('A'), 'b': 2}).evaluate({'A': 1}) == {'a': 1, 'b': 2}
+
+
+def test_list():
+    assert evaluatable_list(Option('A'), 2).evaluate({'A': 1}) == [1, 2]
+
+
+def test_set():
+    assert evaluatable_set(Option('A'), 2).evaluate({'A': 1}) == {1, 2}
+
+
+def test_tuple():
+    assert evaluatable_tuple(Option('A'), 2).evaluate({'A': 1}) == (1, 2)
\ No newline at end of file
diff --git a/tests/test_computation.py b/tests/test_computation.py
new file mode 100644
index 0000000..2fba7e5
--- /dev/null
+++ b/tests/test_computation.py
@@ -0,0 +1,48 @@
+import pytest
+
+from labrea.computation import Computation, ChainedEffect, CallbackEffect
+from labrea.exceptions import EvaluationError
+from labrea.option import Option
+from labrea.pipeline import pipeline_step
+
+
+def test_chained_effect():
+    incr = lambda x: x + 1
+    double = lambda x: x * 2
+
+    effect = ChainedEffect(CallbackEffect(incr), CallbackEffect(double))
+
+    assert effect.transform(1) == 4
+    effect.validate({})
+    assert effect.explain() == set()
+    assert repr(effect) == f"ChainedEffect(CallbackEffect(Value({incr!r})), CallbackEffect(Value({double!r})))"
+
+
+def test_callback_effect():
+    @pipeline_step
+    def add_a(value: int, a: int = Option('A')) -> int:
+        return value + a
+
+    effect = CallbackEffect(add_a)
+
+    assert effect.transform(1, {'A': 1}) == 2
+    effect.validate({'A': 1})
+    assert effect.explain() == {'A'}
+    assert repr(effect) == f"CallbackEffect({add_a!r})"
+
+    with pytest.raises(EvaluationError):
+        effect.transform(1, {})
+    with pytest.raises(EvaluationError):
+        effect.validate({})
+
+
+def test_computation():
+    incr = lambda x: x + 1
+    comp = Computation(Option('A'), CallbackEffect(incr))
+
+    assert comp.evaluate({'A': 1}) == 2
+    comp.validate({'A': 1})
+    assert comp.keys({'A': 1}) == {'A'}
+    assert comp.explain() == {'A'}
+
+    assert repr(comp) == f"Computation(Option('A'), CallbackEffect(Value({incr!r})))"
diff --git a/tests/test_conditional.py b/tests/test_conditional.py
new file mode 100644
index 0000000..9880478
--- /dev/null
+++ b/tests/test_conditional.py
@@ -0,0 +1,133 @@
+from labrea.conditional import switch, SwitchError, case, CaseWhenError
+from labrea.exceptions import KeyNotFoundError, InsufficientInformationError
+from labrea.option import Option
+import pytest
+
+
+def test_switch():
+    s = switch(Option('A'), {'X': 42, 'Y': Option('Z')})
+
+    assert s({'A': 'X'}) == 42
+    assert s({'A': 'Y', 'Z': 43}) == 43
+    with pytest.raises(KeyNotFoundError):
+        s.evaluate({})
+    with pytest.raises(KeyNotFoundError):
+        s.evaluate({'A': 'Y'})
+    with pytest.raises(SwitchError):
+        s.evaluate({'A': 'Z'})
+
+    s.validate({'A': 'X'})
+    s.validate({'A': 'Y', 'Z': 43})
+    with pytest.raises(KeyNotFoundError):
+        s.validate({})
+    with pytest.raises(KeyNotFoundError):
+        s.validate({'A': 'Y'})
+    with pytest.raises(SwitchError):
+        s.validate({'A': 'Z'})
+
+    assert s.keys({'A': 'X'}) == {'A'}
+    assert s.keys({'A': 'Y', 'Z': 43}) == {'A', 'Z'}
+    with pytest.raises(KeyNotFoundError):
+        s.keys({})
+    with pytest.raises(KeyNotFoundError):
+        s.keys({'A': 'Y'})
+    with pytest.raises(SwitchError):
+        s.keys({'A': 'Z'})
+
+    assert s.explain({'A': 'X'}) == {'A'}
+    assert s.explain({'A': 'Y', 'Z': 43}) == {'A', 'Z'}
+    with pytest.raises(InsufficientInformationError):
+        s.explain({})
+    assert s.explain({'A': 'Y'}) == {'A', 'Z'}
+    with pytest.raises(InsufficientInformationError):
+        assert s.explain({'A': 'Z'}) == {'A'}
+
+    assert repr(s) == "switch(Option('A'), {'X': Value(42), 'Y': Option('Z')})"
+    assert (
+        repr(switch(Option('A'), {'X': 42, 'Y': Option('Z')}, 43)) ==
+        "switch(Option('A'), {'X': Value(42), 'Y': Option('Z')}, Value(43))"
+    )
+
+    assert switch('A', {'X': 1})({'A': 'X'}) == 1
+
+
+def test_case_when():
+    c = case(Option('A')).when(
+        lambda x: x == 'X',
+        42
+    ).when(
+        lambda x: x == 'Y',
+        Option('Z')
+    ).otherwise(43)
+
+    assert c({'A': 'X'}) == 42
+    assert c({'A': 'Y', 'Z': 44}) == 44
+    assert c({'A': 'Z'}) == 43
+    with pytest.raises(KeyNotFoundError):
+        c.evaluate({})
+    with pytest.raises(KeyNotFoundError):
+        c.evaluate({'A': 'Y'})
+
+    c.validate({'A': 'X'})
+    c.validate({'A': 'Y', 'Z': 44})
+    c.validate({'A': 'Z'})
+    with pytest.raises(KeyNotFoundError):
+        c.validate({})
+    with pytest.raises(KeyNotFoundError):
+        c.validate({'A': 'Y'})
+    c.validate({'A': 'Z'})
+
+    assert c.keys({'A': 'X'}) == {'A'}
+    assert c.keys({'A': 'Y', 'Z': 44}) == {'A', 'Z'}
+    assert c.keys({'A': 'Z'}) == {'A'}
+    with pytest.raises(KeyNotFoundError):
+        c.keys({})
+    with pytest.raises(KeyNotFoundError):
+        c.keys({'A': 'Y'})
+    assert c.keys({'A': 'Z'}) == {'A'}
+
+    assert c.explain({'A': 'X'}) == {'A'}
+    assert c.explain({'A': 'Y', 'Z': 44}) == {'A', 'Z'}
+    assert c.explain({'A': 'Z'}) == {'A'}
+    with pytest.raises(InsufficientInformationError):
+        c.explain({})
+    assert c.explain({'A': 'Y'}) == {'A', 'Z'}
+    assert c.explain({'A': 'Z'}) == {'A'}
+
+
+def test_case_when_no_default():
+    c = case(Option('A')).when(
+        lambda x: x == 'X',
+        42
+    ).when(
+        lambda x: x == 'Y',
+        Option('Z')
+    )
+
+    assert c({'A': 'X'}) == 42
+    assert c({'A': 'Y', 'Z': 44}) == 44
+    with pytest.raises(CaseWhenError):
+        c({'A': 'Z'})
+    with pytest.raises(KeyNotFoundError):
+        c.evaluate({})
+
+    c.validate({'A': 'X'})
+    c.validate({'A': 'Y', 'Z': 44})
+    with pytest.raises(CaseWhenError):
+        c.validate({'A': 'Z'})
+    with pytest.raises(KeyNotFoundError):
+        c.validate({})
+
+    assert c.keys({'A': 'X'}) == {'A'}
+    assert c.keys({'A': 'Y', 'Z': 44}) == {'A', 'Z'}
+    with pytest.raises(CaseWhenError):
+        c.keys({'A': 'Z'})
+    with pytest.raises(KeyNotFoundError):
+        c.keys({})
+
+    assert c.explain({'A': 'X'}) == {'A'}
+    assert c.explain({'A': 'Y', 'Z': 44}) == {'A', 'Z'}
+    with pytest.raises(InsufficientInformationError):
+        c.explain({'A': 'Z'})
+    with pytest.raises(InsufficientInformationError):
+        c.explain({})
diff --git a/tests/test_dataset.py b/tests/test_dataset.py
new file mode 100644
index 0000000..443df4a
--- /dev/null
+++ b/tests/test_dataset.py
@@ -0,0 +1,194 @@
+import pytest
+import uuid
+
+from labrea.computation import CallbackEffect
+from labrea.dataset import dataset, abstractdataset
+from labrea.exceptions import EvaluationError
+from labrea.option import Option
+
+
+def test_dataset():
+    @dataset
+    def add(a: int = Option('A'), b: int = Option('B')) -> int:
+        return a + b
+
+    assert add.evaluate({'A': 1, 'B': 2}) == 3
+    add.validate({'A': 1, 'B': 2})
+    assert add.keys({'A': 1, 'B': 2}) == {'A', 'B'}
+    assert add.explain() == {'A', 'B'}
+
+
+def test_overloads():
+    @dataset(dispatch='X')
+    def combine(a: int = Option('A'), b: int = Option('B')) -> int:
+        return a + b
+
+    @combine.overload(alias='Y')
+    def multiply(a: int = Option('C'), b: int = Option('D')) -> int:
+        return a * b
+
+    assert combine.evaluate({'A': 1, 'B': 2}) == 3
+    combine.validate({'A': 1, 'B': 2})
+    assert combine.keys({'A': 1, 'B': 2}) == {'A', 'B'}
+    assert combine.explain() == {'A', 'B'}
+
+    assert multiply.evaluate({'C': 1, 'D': 2}) == 2
+    multiply.validate({'C': 1, 'D': 2})
+    assert multiply.keys({'C': 1, 'D': 2}) == {'C', 'D'}
+    assert multiply.explain() == {'C', 'D'}
+
+    assert combine.evaluate({'X': 'Y', 'C': 1, 'D': 2}) == 2
+    combine.validate({'X': 'Y', 'C': 1, 'D': 2})
+    assert combine.keys({'X': 'Y', 'C': 1, 'D': 2}) == {'X', 'C', 'D'}
+    assert combine.explain({'X': 'Y'}) == {'X', 'C', 'D'}
+
+    assert combine.evaluate({'X': 'Z', 'A': 1, 'B': 2}) == 3
+    combine.validate({'X': 'Z', 'A': 1, 'B': 2})
+    assert combine.keys({'X': 'Z', 'A': 1, 'B': 2}) == {'A', 'B'}
+    assert combine.explain({'X': 'Z'}) == {'A', 'B'}
+
+
+def test_abstract():
+    with pytest.raises(ValueError):
+        @abstractdataset
+        def x() -> int:
+            pass
+
+    @abstractdataset(dispatch='A')
+    def x() -> int:
+        pass
+
+    @x.overload('B')
+    def y() -> int:
+        return 1
+
+    with pytest.raises(EvaluationError):
+        x()
+
+    assert x.evaluate({'A': 'B'}) == 1
+
+
+def test_repr():
+    def add(a: int = Option('A'), b: int = Option('B')) -> int:
+        return a + b
+
+    assert repr(dataset(add)) == f"<Dataset {add.__qualname__}>"
+
+
+def test_cache():
+    @dataset
+    def uuid4() -> uuid.UUID:
+        return uuid.uuid4()
+
+    assert uuid4() == uuid4()
+
+
+def test_nocache():
+    @dataset.nocache
+    def uuid4() -> uuid.UUID:
+        return uuid.uuid4()
+
+    assert uuid4() != uuid4()
+
+
+def test_effect():
+    store = None
+
+    def set_store(value):
+        nonlocal store
+        store = value
+        return value
+
+    @dataset.nocache(effects={'store': CallbackEffect(set_store)})
+    def uuid4() -> uuid.UUID:
+        return uuid.uuid4()
+
+    result = uuid4()
+    assert store == result
+
+    @dataset.nocache(effects={'store': set_store})
+    def uuid4() -> uuid.UUID:
+        return uuid.uuid4()
+
+    @dataset.nocache
+    def uuid4() -> uuid.UUID:
+        return uuid.uuid4()
+
+    uuid4.add_effect('store', CallbackEffect(set_store))
+    result = uuid4()
+    assert store == result
+
+    result = uuid4()
+    assert store == result
+
+    @dataset.nocache
+    def uuid4() -> uuid.UUID:
+        return uuid.uuid4()
+
+    uuid4.set_effect('store', set_store)
+    result = uuid4()
+    assert store == result
+
+    uuid4.drop_effect('store')
+    result = uuid4()
+    assert store != result
+
+    uuid4.drop_effect('store')
+
+
+def test_set_dispatch():
+    @dataset
+    def x() -> int:
+        return 1
+
+    @x.overload('A')
+    def y() -> int:
+        return 2
+
+    x.set_dispatch(Option('A'))
+
+    assert x() == 1
+    assert x.evaluate({'A': 'A'}) == 2
+
+
+def test_where():
+    def add(x: int, y: int) -> int:
+        return x + y
+
+    add = dataset.where(x=Option('X'), y=Option('Y')).wrap(add)
+
+    assert add.evaluate({'X': 1, 'Y': 2}) == 3
+    add.validate({'X': 1, 'Y': 2})
+    assert add.keys({'X': 1, 'Y': 2}) == {'X', 'Y'}
+    assert add.explain() == {'X', 'Y'}
+
+
+def test_default_options():
+    @dataset(options={'A': 1})
+    def x(a: int = Option('A'), b: int = Option('B')) -> int:
+        return a + b
+
+    assert x({'B': 2}) == x({'A': 2, 'B': 2}) == 3
+    x.validate({'B': 2})
+    assert x.keys({'B': 2}) == {'B'}
+    assert x.explain() == {'B'}
+
+    with pytest.raises(EvaluationError):
+        x()
+    with pytest.raises(EvaluationError):
+        x.validate({})
+    with pytest.raises(EvaluationError):
+        x.keys({})
+
+
+def test_default():
+    @dataset(dispatch='X')
+    def x() -> str:
+        return 'x'
+
+    @x.overload('Y')
+    def y() -> str:
+        return 'y'
+
+    assert x.default({'X': 'Y'}) == 'x'
+
diff --git a/tests/test_datasetclass.py b/tests/test_datasetclass.py
new file mode 100644
index 0000000..5fcc51c
--- /dev/null
+++ b/tests/test_datasetclass.py
@@ -0,0 +1,57 @@
+import pytest
+
+from labrea import datasetclass, Option
+from labrea.exceptions import KeyNotFoundError
+
+
+@datasetclass
+class X:
+    a: int = Option('A')
+    b: bool = True
+    c: str = Option('C').apply(str)
+
+
+def test_evaluate():
+    x = X({'A': 1, 'C': 3})
+
+    assert x.a == 1
+    assert x.b is True
+    assert x.c == '3'
+
+    with pytest.raises(KeyNotFoundError) as exc_info:
+        X({'A': 1})
+        assert exc_info.value.key == 'C'
+
+
+def test_validate():
+    X.validate({'A': 1, 'C': 3})
+
+    with pytest.raises(KeyNotFoundError) as exc_info:
+        X.validate({'A': 1})
+        assert exc_info.value.key == 'C'
+
+
+def test_keys():
+    assert X.keys({'A': 1, 'C': 3}) == {'A', 'C'}
+
+    with pytest.raises(KeyNotFoundError) as exc_info:
+        X.keys({'A': 1})
+        assert exc_info.value.key == 'C'
+
+
+def test_explain():
+    assert X.explain() == X.explain({'A': 1, 'C': 3}) == {'A', 'C'}
+
+
+def test_repr():
+    assert repr(X) == '<DatasetClass X>'
+    assert repr(X({'A': 1, 'C': 3})) == 'X(a=1, b=True, c=\'3\')'
+
+
+def test_missing_default():
+    with pytest.raises(ValueError):
+        @datasetclass
+        class Y:
+            a: int = Option('A')
+            b: bool
+            c: str = Option('C').apply(str)
diff --git a/tests/test_evaluatable.py b/tests/test_evaluatable.py
new file mode 100644
index 0000000..250b3f0
--- /dev/null
+++ b/tests/test_evaluatable.py
@@ -0,0 +1,97 @@
+import pytest
+
+from labrea.types import Evaluatable, Value
+from labrea.exceptions import EvaluationError, KeyNotFoundError
+from labrea.option import Option
+
+
+def test_value():
+    value = Value(42)
+    assert value.evaluate({}) == value() == 42
+    assert value.validate({}) is None
+    assert value.keys({}) == set()
+    assert value.explain() == value.explain({}) == set()
+    assert repr(value) == "Value(42)"
+    assert value == value
+    assert value != Value(43)
+
+    class Uncopyable:
+        def __deepcopy__(self, memo):
+            raise NotImplementedError('Cannot copy this.')
+
+    uncopyable = Uncopyable()
+
+    assert Value(uncopyable).evaluate({}) is uncopyable
+
+
+def test_unit():
+    assert Evaluatable.unit(1)() == 1
+
+
+@pytest.mark.parametrize(
+    'wrapper,method',
+    [
+        (wrapper, method)
+        for wrapper in (Value, lambda x: x)
+        for method in ('apply', 'rshift')
+    ]
+)
+def test_apply(wrapper, method):
+    value = Value(42)
+
+    def incr(x):
+        return x + 1
+
+    if method == 'apply':
+        apply = value.apply(wrapper(incr))
+    else:
+        apply = value >> wrapper(incr)
+
+    assert apply.evaluate({}) == 43
+    assert apply.validate({}) is None
+    assert apply.keys({}) == set()
+    assert apply.explain() == set()
+    assert repr(apply) == f"Value(42).apply(Value({repr(incr)}))"
+
+
+def test_bind():
+    value = Value(42)
+
+    def incr(x):
+        return Value(x + 1)
+
+    bind = value.bind(incr)
+    assert bind.evaluate({}) == 43
+    assert bind.validate({}) is None
+    assert bind.keys({}) == set()
+    assert bind.explain() == set()
+    assert repr(bind) == f"Value(42).bind({repr(incr)})"
+
+
+def test_type_error():
+    with pytest.raises(TypeError):
+        Value(42).bind(42)
+
+    with pytest.raises(TypeError):
+        Value(42).apply(42)
+
+
+def test_error_str():
+    assert str(EvaluationError('message', Value(1))) == "Originating in Value(1) | message"
+    assert str(KeyNotFoundError('key', Value(1))) == "Originating in Value(1) | Key 'key' not found"
+
+
+def test_fingerprint():
+    value = Value(42)
+    option = Option('A')
+
+    assert value.fingerprint({}) == value.fingerprint({'A': 1})
+    assert value.fingerprint({'A': 1}) != option.fingerprint({'A': 1})
+    assert option.fingerprint({'A': 1}) == option.fingerprint({'A': 1})
+    assert option.fingerprint({'A': 1}) != option.fingerprint({'A': 2})
+    assert option.fingerprint({'A': 1}) == option.fingerprint({'A': 1, 'V': 2})
+
+
+def test_result():
+    option = Option('A')
+    assert option.result is option
diff --git a/tests/test_functions.py b/tests/test_functions.py
new file mode 100644
index 0000000..58b1366
--- /dev/null
+++ b/tests/test_functions.py
@@ -0,0 +1,22 @@
+from labrea.option import Option
+import labrea.functions as lf
+
+
+def test_map():
+    m = Option('A') >> lf.map(str) >> list
+
+    assert m({'A': [1, 2]}) == ['1', '2']
+
+
+def test_filter():
+    f = Option('A') >> lf.filter(lambda x: x % 2 == 0) >> list
+
+    assert f({'A': [1, 2, 3, 4]}) == [2, 4]
+
+
+def test_reduce():
+    r1 = Option('A') >> lf.reduce(lambda x, y: x + y)
+    r2 = Option('A') >> lf.reduce(lambda x, y: x + y, 10)
+
+    assert r1({'A': [1, 2, 3, 4]}) == 10
+    assert r2({'A': [1, 2, 3, 4]}) == 20
diff --git a/tests/test_interface.py b/tests/test_interface.py
new file mode 100644
index 0000000..a948f29
--- /dev/null
+++ b/tests/test_interface.py
@@ -0,0 +1,120 @@
+import pytest
+
+from labrea.interface import interface, implements
+from labrea.option import Option
+from labrea.dataset import dataset
+from labrea.exceptions import EvaluationError
+
+
+@interface('DISPATCH')
+class MyInterface:
+    a: str
+
+    def b() -> str:
+        return 'b'
+
+    @staticmethod
+    @dataset
+    def c() -> str:
+        return 'c'
+
+    d: str = Option('D', 'd')
+
+
+@interface('DISPATCH_2')
+class MyInterface2:
+    e = 'e'
+
+
+def test_no_implementation():
+    with pytest.raises(EvaluationError):
+        MyInterface.a()
+
+    assert MyInterface.b() == 'b'
+    assert MyInterface.c() == 'c'
+    assert MyInterface.d() == 'd'
+
+
+def test_good_implementation():
+    @MyInterface.implementation('GOOD')
+    class Good:
+        a = 'a'
+
+    assert MyInterface.a({'DISPATCH': 'GOOD'}) == 'a'
+    assert MyInterface.b({'DISPATCH': 'GOOD'}) == 'b'
+    assert MyInterface.c({'DISPATCH': 'GOOD'}) == 'c'
+    assert MyInterface.d({'DISPATCH': 'GOOD'}) == 'd'
+
+
+def test_bad_implementation():
+    with pytest.raises(TypeError):
+        @MyInterface.implementation('BAD')
+        class Bad:
+            pass
+
+    with pytest.raises(TypeError):
+        @MyInterface.implementation('BAD')
+        class Bad:
+            a = 'a'
+            f = 'f'
+
+
+def test_override():
+    @MyInterface.implementation('OVERRIDE')
+    class Good:
+        a = 'A'
+
+        def b():
+            return 'B'
+
+        @staticmethod
+        @dataset
+        def c():
+            return 'C'
+
+        d = Option('D', 'D')
+
+    assert MyInterface.a({'DISPATCH': 'OVERRIDE'}) == 'A'
+    assert MyInterface.b({'DISPATCH': 'OVERRIDE'}) == 'B'
+    assert MyInterface.c({'DISPATCH': 'OVERRIDE'}) == 'C'
+    assert MyInterface.d({'DISPATCH': 'OVERRIDE'}) == 'D'
+
+
+def test_multi_implementation():
+    @implements(MyInterface, MyInterface2, alias='MULTI')
+    class Multi:
+        a = 'a'
+
+    assert MyInterface.a({'DISPATCH': 'MULTI'}) == 'a'
+    assert MyInterface.b({'DISPATCH': 'MULTI'}) == 'b'
+    assert MyInterface.c({'DISPATCH': 'MULTI'}) == 'c'
+    assert MyInterface.d({'DISPATCH': 'MULTI'}) == 'd'
+    assert MyInterface2.e({'DISPATCH_2': 'MULTI'}) == 'e'
+
+
+def test_missing_args():
+    with pytest.raises(TypeError):
+        @interface
+        class A:
+            pass
+
+    with pytest.raises(TypeError):
+        @MyInterface.implementation
+        class A:
+            pass
+
+
+def test_invalid_member():
+    with pytest.raises(AttributeError):
+        @interface('A')
+        class A:
+            implementation = 'implementation'
+
+
+def test_repr():
+    @MyInterface.implementation('A')
+    class A:
+        a: int = 1
+
+    assert repr(MyInterface) == '<Interface test_interface.MyInterface>'
+    assert repr(A) == '<Implementation test_interface.A of MyInterface>'
diff --git a/tests/test_iterable.py b/tests/test_iterable.py
new file mode 100644
index 0000000..2a795ad
--- /dev/null
+++ b/tests/test_iterable.py
@@ -0,0 +1,51 @@
+import pytest
+
+from labrea.iterable import Iter, Map
+from labrea.conditional import switch
+from labrea.exceptions import EvaluationError, InsufficientInformationError
+from labrea.option import Option
+
+
+def test_iter():
+    i = Iter(Option('A'), 2)
+
+    assert i.apply(list).evaluate({'A': 1}) == [1, 2]
+    i.validate({'A': 1})
+    assert i.keys({'A': 1}) == {'A'}
+    assert i.explain() == {'A'}
+
+    with pytest.raises(EvaluationError):
+        i.apply(list).evaluate({})
+    with pytest.raises(EvaluationError):
+        i.validate({})
+    with pytest.raises(EvaluationError):
+        i.keys({})
+
+    assert repr(i) == "Iter(Option('A'), Value(2))"
+
+
+def test_iterate():
+    i = Map(Option('A'), {'A': Option('B')})
+
+    assert i.apply(list).evaluate({'B': [1, 2]}) == [({'A': 1}, 1), ({'A': 2}, 2)]
+    assert i.values.apply(list).evaluate({'B': [1, 2]}) == [1, 2]
+    i.validate({'B': [1, 2]})
+    assert i.keys({'B': [1, 2]}) == {'B'}
+    assert i.explain({'B': [1, 2]}) == {'B'}
+
+    with pytest.raises(EvaluationError):
+        i.apply(list).evaluate({})
+    with pytest.raises(EvaluationError):
+        i.validate({})
+    with pytest.raises(EvaluationError):
+        i.keys({})
+    assert i.explain() == {'B'}
+
+    assert repr(i) == "Iterate(Option('A'), {'A': Option('B')})"
+
+
+def test_iterate_explain_insufficient_information():
+    i = Map(switch(Option('A'), {'X': 1}), {'A': Option('B')})
+
+    with pytest.raises(InsufficientInformationError):
+        i.explain()
diff --git a/tests/test_missing.py b/tests/test_missing.py
new file mode 100644
index 0000000..a6bf85e
--- /dev/null
+++ b/tests/test_missing.py
@@ -0,0 +1,5 @@
+from labrea._missing import MISSING
+
+
+def test_missing():
+    assert repr(MISSING) == "MISSING"
diff --git a/tests/test_option.py b/tests/test_option.py
new file mode 100644
index 0000000..8f1ea37
--- /dev/null
+++ b/tests/test_option.py
@@ -0,0 +1,172 @@
+from labrea.exceptions import KeyNotFoundError
+from labrea.option import Option, WithOptions
+from labrea.template import Template
+import pytest
+
+
+def test_basic():
+    option = Option('A')
+    options = {'A': 42}
+
+    assert option.evaluate(options) == 42
+    option.validate(options)
+    assert option.keys(options) == {'A'}
+    assert option.explain(options) == {'A'}
+    assert option.explain() == {'A'}
+
+
+def test_multiple_provided():
+    option = Option('A')
+    options = {'A': 42, 'V': 43}
+
+    assert option.evaluate(options) == 42
+    option.validate(options)
+    assert option.keys(options) == {'A'}
+    assert option.explain(options) == {'A'}
+
+
+def test_missing():
+    option = Option('A')
+    options = {'V': 42}
+
+    with pytest.raises(KeyNotFoundError) as excinfo:
+        option.evaluate(options)
+        assert excinfo.value.key == 'A'
+
+    with pytest.raises(KeyNotFoundError) as excinfo:
+        option.validate(options)
+        assert excinfo.value.key == 'A'
+
+    with pytest.raises(KeyNotFoundError) as excinfo:
+        option.keys(options)
+        assert excinfo.value.key == 'A'
+
+    assert option.explain(options) == {'A'}
+
+
+def test_default():
+    option = Option('A', default=42)
+    missing = {}
+    provided = {'A': 43}
+
+    assert option.evaluate(missing) == 42
+    option.validate(missing)
+    assert option.keys(missing) == set()
+    assert option.explain(missing) == set()
+
+    assert option.evaluate(provided) == 43
+    option.validate(provided)
+    assert option.keys(provided) == {'A'}
+    assert option.explain(provided) == {'A'}
+
+
+def test_default_factory():
+    option = Option('A', default_factory=lambda: 42)
+    missing = {}
+    provided = {'A': 43}
+
+    assert option.evaluate(missing) == 42
+    option.validate(missing)
+    assert option.keys(missing) == set()
+    assert option.explain(missing) == set()
+
+    assert option.evaluate(provided) == 43
+    option.validate(provided)
+    assert option.keys(provided) == {'A'}
+    assert option.explain(provided) == {'A'}
+
+
+def test_evaluatable_default():
+    option = Option('A', default=Option('V'))
+    options = {'V': 42}
+
+    assert option.evaluate(options) == 42
+    option.validate(options)
+    assert option.keys(options) == {'V'}
+    assert option.explain(options) == {'V'}
+    assert option.explain() == {'V'}
+
+
+def test_nested():
+    option = Option('A.V')
+    options = {'A': {'V': 42}}
+
+    assert option.evaluate(options) == 42
+    option.validate(options)
+    assert option.keys(options) == {'A.V'}
+    assert option.explain(options) == {'A.V'}
+
+
+def test_nested_missing():
+    option = Option('A.V')
+    options = {'A': {}}
+
+    with pytest.raises(KeyNotFoundError) as excinfo:
+        option.evaluate(options)
+        assert excinfo.value.key == 'A.V'
+
+    with pytest.raises(KeyNotFoundError) as excinfo:
+        option.validate(options)
+        assert excinfo.value.key == 'A.V'
+
+    with pytest.raises(KeyNotFoundError) as excinfo:
+        option.keys(options)
+        assert excinfo.value.key == 'A.V'
+
+    assert option.explain(options) == {'A.V'}
+
+
+def test_template_default():
+    option = Option('A', default='{V}.{C}')
+    assert isinstance(option.default, Template)
+
+
+def test_confectioner_templating():
+    option = Option('A')
+    present = {'A': '{V} {C}!', 'V': 'Hello', 'C': 'World'}
+    missing = {'A': '{V} {C}!', 'V': 'Hello'}
+
+    assert option.evaluate(present) == 'Hello World!'
+    option.validate(present)
+    assert option.keys(present) == {'A', 'V', 'C'}
+
+    with pytest.raises(KeyNotFoundError) as excinfo:
+        option.evaluate(missing)
+        assert excinfo.value.key == 'C'
+
+    with pytest.raises(KeyNotFoundError) as excinfo:
+        option.validate(missing)
+        assert excinfo.value.key == 'C'
+
+    with pytest.raises(KeyNotFoundError) as excinfo:
+        option.keys(missing)
+        assert excinfo.value.key == 'C'
+
+    assert option.explain(present) == {'A', 'V', 'C'}
+    assert option.explain(missing) == {'A', 'V', 'C'}
+    assert option.explain() == {'A'}
+
+
+def test_repr():
+    assert repr(Option('A')) == "Option('A')"
+    assert repr(Option('A', default=42)) == "Option('A', default=Value(42))"
+    assert repr(Option('A', default='{V}.{C}')) == f"Option('A', default={repr(Template('{V}.{C}'))})"
+    assert repr(Option('A', doc='Hello, World!')) == "Option('A')"
+
+
+def test_with_options():
+    w = WithOptions(Option('A'), {'A': 42})
+
+    assert w.evaluate({}) == 42
+    assert w.evaluate({'A': 43}) == 42
+
+    w.validate({})
+    w.validate({'A': 43})
+
+    assert w.keys({}) == set()
+    assert w.keys({'A': 43}) == set()
+
+    assert w.explain({}) == set()
+    assert w.explain({'A': 43}) == set()
+
+    assert repr(w) == "WithOptions(Option('A'), {'A': 42})"
diff --git a/tests/test_overload.py b/tests/test_overload.py
new file mode 100644
index 0000000..da23cf3
--- /dev/null
+++ b/tests/test_overload.py
@@ -0,0 +1,46 @@
+import pytest
+
+from labrea.application import FunctionApplication
+from labrea import Value
+from labrea.exceptions import KeyNotFoundError
+from labrea.option import Option
+from labrea.overload import Overloaded, overloaded
+
+
+def test_overloaded():
+    @overloaded(Option('A'))
+    @FunctionApplication.lift
+    def add(x: int = Option('X'), y: int = Option('Y')) -> int:
+        return x + y
+
+    @add.overload('dummy')
+    def dummy_add():
+        return 0
+
+    with pytest.raises(TypeError):
+        @add.overload()
+        def _():
+            pass
+
+    assert add.evaluate({'X': 1, 'Y': 2}) == 3
+    assert add.evaluate({'A': 'dummy'}) == 0
+
+    add.validate({'X': 1, 'Y': 2})
+    add.validate({'A': 'dummy'})
+    with pytest.raises(KeyNotFoundError):
+        add.validate({'X': 1})
+
+    assert add.keys({'X': 1, 'Y': 2}) == {'X', 'Y'}
+    assert add.keys({'A': 'dummy'}) == {'A'}
+    with pytest.raises(KeyNotFoundError):
+        add.keys({'X': 1})
+
+    assert add.explain({'X': 1, 'Y': 2}) == {'X', 'Y'}
+    assert add.explain({'A': 'dummy'}) == {'A'}
+    assert add.explain() == {'X', 'Y'}
+
+
+def test_repr():
+    assert repr(Overloaded(Option('A'), {'X': Value(42)})) == "Overloaded(Option('A'), {'X': Value(42)})"
+    assert repr(Overloaded(Option('A'), {'X': Value(42)}, default=43)) == "Overloaded(Option('A'), {'X': Value(42)}, Value(43))"
+
diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py
new file mode 100644
index 0000000..eaab81d
--- /dev/null
+++ b/tests/test_pipeline.py
@@ -0,0 +1,68 @@
+import pytest
+from typing import List
+
+from labrea import pipeline_step, Option, Value
+from labrea.exceptions import EvaluationError
+from labrea.pipeline import Pipeline, PipelineStep
+
+
+@pipeline_step
+def incr(x: float) -> float:
+    return x + 1
+
+
+@pipeline_step
+def repeat(x: float, n: int = Option('N')) -> List[float]:
+    return [x for _ in range(n)]
+
+
+incr_then_repeat = incr + repeat
+
+
+def test_types():
+    assert isinstance(incr, PipelineStep)
+    assert isinstance(repeat, PipelineStep)
+    assert isinstance(incr_then_repeat, Pipeline)
+
+
+def test_pipeline_valid():
+    assert incr_then_repeat.evaluate({'N': 3})(1) == [2, 2, 2]
+    incr_then_repeat.validate({'N': 3})
+    assert incr_then_repeat.keys({'N': 3}) == {'N'}
+    assert incr_then_repeat.explain({'N': 3}) == {'N'}
+
+
+def test_pipeline_invalid():
+    with pytest.raises(EvaluationError):
+        incr_then_repeat.evaluate({})
+    with pytest.raises(EvaluationError):
+        incr_then_repeat.validate({})
+    with pytest.raises(EvaluationError):
+        incr_then_repeat.keys({})
+    assert incr_then_repeat.explain() == {'N'}
+
+
+def test_repr():
+    assert repr(incr) == '<PipelineStep incr>'
+    assert repr(incr_then_repeat) == '<Pipeline <PipelineStep incr> + <PipelineStep repeat>>'
+    assert repr(Pipeline()) == '<Pipeline Identity>'
+
+
+def test_iter():
+    assert list(incr_then_repeat) == [incr, repeat]
+
+
+def test_add():
+    def decr(x: float) -> float:
+        return x - 1
+
+    assert list(incr + repeat) == [incr, repeat]
+    assert list(incr + repeat + decr) == [incr, repeat, PipelineStep(Value(decr))]
+    assert list(incr_then_repeat + incr_then_repeat) == [incr, repeat, incr, repeat]
+    assert list(Pipeline() + incr) == list(incr + Pipeline()) == [incr]
+
+
+def test_transformation():
+    assert incr.transform(1) == 2
+    assert list(repeat.transform(1, {'N': 3})) == [1, 1, 1]
+    assert list(incr_then_repeat.transform(1, {'N': 3})) == [2, 2, 2]
diff --git a/tests/test_runtime.py b/tests/test_runtime.py
new file mode 100644
index 0000000..163c032
--- /dev/null
+++ b/tests/test_runtime.py
@@ -0,0 +1,71 @@
+import pytest
+import threading
+from labrea.runtime import Request, handle, inherit
+
+
+class UnhandledRequest(Request):
+    def __init__(self, value):
+        self.value = value
+
+
+class EchoRequest(Request[str]):
+    message: str
+
+    def __init__(self, message: str):
+        self.message = message
+
+
+@EchoRequest.handle
+def echo(request: EchoRequest) -> str:
+    return request.message
+
+
+def void(request) -> None:
+    pass
+
+
+def test_request():
+    assert EchoRequest("hello").run() == "hello"
+
+    with pytest.raises(TypeError):
+        UnhandledRequest("world").run()
+
+
+def test_handle():
+    assert EchoRequest("hello").run() == "hello"
+
+    with handle(EchoRequest, void):
+        assert EchoRequest("hello").run() is None
+
+    with handle(EchoRequest, void):
+        with handle(EchoRequest, echo):
+            assert EchoRequest("hello").run() is "hello"
+
+    with handle({EchoRequest: void}):
+        assert EchoRequest("hello").run() is None
+
+    with handle({EchoRequest: void, UnhandledRequest: void}):
+        assert EchoRequest("hello").run() is None
+        assert UnhandledRequest("world").run() is None
+
+    assert EchoRequest("hello").run() == "hello"
+
+    with pytest.raises(TypeError):
+        with handle(42, void):
+            pass
+
+    with pytest.raises(TypeError):
+        with handle(EchoRequest, 42):
+            pass
+
+
+def test_inherit():
+    def worker(parent: threading.Thread, request: Request):
+        inherit(parent)
+        return request.run()
+
+    with pytest.raises(TypeError):
+        threading.Thread(target=worker, args=(threading.current_thread(), UnhandledRequest("hello"))).run()
+
+    with handle(UnhandledRequest, void):
+        threading.Thread(target=worker, args=(threading.current_thread(), UnhandledRequest("hello"))).run()
diff --git a/tests/test_template.py b/tests/test_template.py
new file mode 100644
index 0000000..5fd11a9
--- /dev/null
+++ b/tests/test_template.py
@@ -0,0 +1,71 @@
+from labrea.template import Template
+from labrea.exceptions import KeyNotFoundError
+import pytest
+
+
+def test_basic():
+    template = Template('{GREETING}, {:name:}!', name='World')
+    options = {'GREETING': 'Hello'}
+
+    assert template(options) == 'Hello, World!'
+    template.validate(options)
+    assert template.keys(options) == {'GREETING'}
+    assert template.explain(options) == {'GREETING'}
+
+
+def test_malformed():
+    with pytest.raises(ValueError):
+        Template('{GREETING} {:name:}')
+
+    with pytest.warns(UserWarning):
+        Template('{GREETING}', name='World')
+
+
+def test_missing():
+    template = Template('{GREETING}, {:name:}!', name='World')
+    options = {}
+
+    with pytest.raises(KeyNotFoundError) as excinfo:
+        template(options)
+        assert excinfo.value.key == 'GREETING'
+
+    with pytest.raises(KeyNotFoundError) as excinfo:
+        template.validate(options)
+        assert excinfo.value.key == 'GREETING'
+
+    with pytest.raises(KeyNotFoundError) as excinfo:
+        template.keys(options)
+        assert excinfo.value.key == 'GREETING'
+
+    assert template.explain(options) == {'GREETING'}
+
+
+def test_confectioner_templating():
+    template = Template('{A} {B}{:punc:}', punc='!')
+    present = {'A': '{C}', 'B': '{D}', 'C': 'Hello', 'D': 'World'}
+    missing = {'A': '{C}', 'B': '{D}', 'C': 'Hello'}
+
+    assert template(present) == 'Hello World!'
+    template.validate(present)
+    assert template.keys(present) == {'A', 'B', 'C', 'D'}
+
+    with pytest.raises(KeyNotFoundError) as excinfo:
+        template(missing)
+        assert excinfo.value.key == 'D'
+
+    with pytest.raises(KeyNotFoundError) as excinfo:
+        template.validate(missing)
+        assert excinfo.value.key == 'D'
+
+    with pytest.raises(KeyNotFoundError) as excinfo:
+        template.keys(missing)
+        assert excinfo.value.key == 'D'
+
+    assert template.explain(present) == {'A', 'B', 'C', 'D'}
+    assert template.explain(missing) == {'A', 'B', 'C', 'D'}
+    assert template.explain() == {'A', 'B'}
+
+
+def test_repr():
+    assert repr(Template('{GREETING}, {:name:}!', name='World')) == "Template('{GREETING}, {:name:}!', name=Value('World'))"
+    assert repr(Template('{GREETING}')) == "Template('{GREETING}')"
diff --git a/tests/unit/test_cache.py b/tests/unit/test_cache.py
deleted file mode 100644
index 3136c25..0000000
--- a/tests/unit/test_cache.py
+++ /dev/null
@@ -1,35 +0,0 @@
-import uuid
-
-from labrea import abstractdataset, dataset
-from labrea.cache import NoCache
-
-
-@dataset
-def cached():
-    return str(uuid.uuid4())
-
-
-@dataset.nocache
-def not_cached():
-    return str(uuid.uuid4())
-
-
-@abstractdataset
-def abstract():
-    ...
-
-
-@abstract.overload.nocache
-def overload():
-    pass
-
-
-not_cached_2 = cached.nocache
-
-
-def test_cache():
-    assert cached({}) == cached({})
-    assert not_cached({}) != not_cached({})
-    assert not_cached_2({}) != not_cached_2({})
-
-    assert isinstance(overload._cache, NoCache)
diff --git a/tests/unit/test_coalesce.py b/tests/unit/test_coalesce.py
deleted file mode 100644
index 73097dd..0000000
--- a/tests/unit/test_coalesce.py
+++ /dev/null
@@ -1,36 +0,0 @@
-import uuid
-
-import pytest
-
-from labrea import Coalesce, Option
-from labrea.types import EvaluationError, ValidationError
-
-
-def test_no_default():
-    coalesce = Coalesce(Option("A"), Option("B"), Option("C"))
-
-    assert coalesce({"A": 1}) == 1
-    assert coalesce({"B": 2}) == 2
-    assert coalesce({"C": 3}) == 3
-    assert coalesce({"A": 1, "C": 3}) == 1
-    with pytest.raises(EvaluationError):
-        coalesce({})
-
-    coalesce.validate({"A": 1})
-    with pytest.raises(ValidationError):
-        coalesce.validate({})
-
-    assert coalesce.keys({"A": 1}) == {"A"}
-    assert coalesce.keys({"A": 1, "B": 2}) == {"A"}
-    assert coalesce.keys({}) == set()
-
-
-def test_with_default():
-    default = uuid.uuid4()
-    coalesce = Coalesce(Option("A"), default)
-
-    assert coalesce({"A": 1}) == 1
-    assert coalesce({}) == default
-
-    coalesce.validate({"A": 1})
-    coalesce.validate({})
diff --git a/tests/unit/test_datasetclasses.py b/tests/unit/test_datasetclasses.py
deleted file mode 100644
index 672d7d1..0000000
--- a/tests/unit/test_datasetclasses.py
+++ /dev/null
@@ -1,19 +0,0 @@
-import pytest
-
-from labrea import Field, datasetclass
-
-
-def test_bad_annotation():
-    with pytest.raises(AttributeError):
-
-        @datasetclass
-        class Bad:
-            x: int
-
-
-def test_auto_field():
-    @datasetclass
-    class AutoField:
-        x: int = 1
-
-    assert AutoField.x == Field(1)
diff --git a/tests/unit/test_datasets.py b/tests/unit/test_datasets.py
deleted file mode 100644
index edf4109..0000000
--- a/tests/unit/test_datasets.py
+++ /dev/null
@@ -1,312 +0,0 @@
-import random
-
-import pytest
-
-import tests.ext.datasets
-import tests.pkg.datasets
-from labrea import (
-    DatasetDict,
-    DatasetList,
-    Field,
-    Option,
-    Switch,
-    abstractdataset,
-    dataset,
-    datasetclass,
-)
-from labrea.cache import NoCache
-from labrea.datasets import Overload
-from labrea.types import EvaluationError, ValidationError, Value
-
-
-@datasetclass
-class DSC:
-    val = Option("DSC.VAL")
-    const = Field(0)
-
-    def __eq__(self, other):
-        return self.val == other
-
-
-@dataset
-def dependency(dependent_input=Option("DEPENDENT.INPUT")):
-    return dependent_input
-
-
-unwrapped_value = []
-
-
-@dataset
-def tst_dataset(
-    x=Option("X", 1),
-    y=Switch("Y", {True: 1, False: 2}),
-    dsc=DSC,
-    dsl=DatasetList(Option("LIST.A"), Option("LIST.B")),
-    dsd=DatasetDict({"a": Option("DICT.A"), "b": Option("DICT.B")}),
-    val=Value("value"),
-    lst=unwrapped_value,
-):
-    return x, y, dsc, dsl, dsd, val, lst
-
-
-config = {
-    "X": random.random(),
-    "Y": random.choice([True, False]),
-    "DSC": {"VAL": random.random()},
-    "LIST": {
-        "A": random.random(),
-        "B": random.random(),
-    },
-    "DICT": {
-        "A": random.random(),
-        "B": random.random(),
-    },
-}
-
-
-def test_dataset():
-    x, y, dsc, dsl, dsd, val, lst = tst_dataset(config)
-
-    assert x == config["X"]
-    assert 1 if config["Y"] else 2
-    assert dsc == config["DSC"]["VAL"]
-    assert dsc.const == 0
-    assert dsl == list(config["LIST"].values())
-    assert dsd == {key.lower(): val for key, val in config["DICT"].items()}
-    assert lst == unwrapped_value and lst is not unwrapped_value
-
-
-def test_validate():
-    with pytest.raises(ValidationError):
-        tst_dataset.validate({})
-
-    good_config = {k: v for k, v in config.items() if k != "X"}
-    tst_dataset.validate(good_config)
-
-    bad_config = {k: v for k, v in good_config.items() if k != "Y"}
-    with pytest.raises(ValidationError):
-        tst_dataset.validate(bad_config)
-
-    with pytest.raises(EvaluationError):
-        tst_dataset.evaluate(bad_config)
-
-
-def test_warning():
-    @dataset
-    def x():
-        return 1
-
-    with pytest.warns(UserWarning):
-
-        @dataset
-        def y():
-            return x
-
-
-def test_no_default():
-    with pytest.raises(TypeError):
-
-        @dataset
-        def x(y):
-            pass
-
-
-def test_where():
-    @dataset.where(a=Option("Y"))
-    def x(a=Option("A")):
-        return a
-
-    assert x.evaluate({"Y": 1}) == 1
-    assert x.where(a=Option("Z")).evaluate({"Z": 2}) == 2
-
-    with pytest.raises(TypeError):
-        tests.pkg.datasets.abstract.where(x=1)
-
-
-def test_overloads():
-    overloads = {
-        "LABREA": {
-            "IMPLEMENTATIONS": {
-                "tests": {
-                    "pkg": {
-                        "datasets": {
-                            "basic": "tests.ext.datasets.basic_overload",
-                            "abstract": "tests.ext.datasets.abstract_overload",
-                        }
-                    }
-                }
-            },
-            "REQUIRE": ["tests.ext"],
-        },
-        "CUSTOM_DISPATCH": "custom_alias_dispatch_overload",
-    }
-
-    assert tests.pkg.datasets.basic.evaluate({})
-    assert not tests.pkg.datasets.basic.evaluate(overloads)
-
-    assert tests.pkg.datasets.dispatch.evaluate({}) == "default"
-    assert tests.pkg.datasets.dispatch.evaluate(overloads) == "overload"
-
-    with pytest.raises(EvaluationError):
-        tests.pkg.datasets.abstract.evaluate({})
-
-    assert tests.pkg.datasets.abstract.evaluate(overloads)
-
-    bad_overload = {"CUSTOM_DISPATCH": "does.not.exist"}
-
-    with pytest.warns(UserWarning):
-        tests.pkg.datasets.dispatch.evaluate(bad_overload)
-
-
-def test_repr():
-    assert repr(tests.pkg.datasets.basic) == "<Dataset tests.pkg.datasets.basic>"
-    assert (
-        repr(tests.ext.datasets.basic_overload)
-        == "<Overload basic_overload of <Dataset tests.pkg.datasets.basic>>"
-    )
-
-    def x():
-        pass
-
-    assert repr(Overload(x)) == f"<Unbound Overload {x.__qualname__}>"
-
-
-def test_decorator_args():
-    @dataset(dispatch="DISPATCH", where={"x": Option("X")}, cache=NoCache)
-    def y(x=1):
-        return x
-
-    @y.overload(where={"x": Option("XX")}, cache=NoCache, alias="y_overload")
-    def y_overload(x=1):
-        return x
-
-    assert isinstance(y.default._cache, NoCache)
-    assert isinstance(y_overload._cache, NoCache)
-
-    assert y.evaluate({"X": 10}) == 10
-    assert y.evaluate({"XX": 20, "DISPATCH": "y_overload"}) == 20
-
-
-def test_abstract_dataset_warnings():
-    with pytest.warns(Warning):
-
-        @abstractdataset.nocache
-        def x():
-            pass
-
-    with pytest.warns(Warning):
-
-        @abstractdataset.where(y=Option("X"))
-        def z(y=1):
-            return y
-
-
-def test_complex_dispatch():
-    @dataset
-    def custom_dispatch() -> str:
-        return "overload"
-
-    @abstractdataset.dispatch(dispatch=custom_dispatch)
-    def abstract():
-        ...
-
-    @abstract.overload(alias="overload")
-    def overload():
-        return "overload"
-
-    assert abstract({}) == "overload"
-
-
-def test_custom_callback():
-    store = []
-
-    @dataset(callbacks=[lambda val, opts: store.append(val)])
-    def x():
-        return 1
-
-    x.evaluate({})
-    x.evaluate({})
-
-    assert store == [1]  # Only called once because of cache
-
-    store = []
-
-    @dataset.nocache(callbacks=[lambda val, opts: store.append(val)])
-    def y():
-        return 1
-
-    y.evaluate({})
-    y.evaluate({})
-
-    assert store == [1, 1]  # Called every time because of no cache
-
-
-def test_add_callback():
-    store = []
-
-    @dataset
-    def x():
-        return 1
-
-    x.add_callbacks(lambda val, opts: store.append(val))
-
-    x.evaluate({})
-    x.evaluate({})
-
-    assert store == [1]  # Called only once
-
-
-def test_scoped_options_deco():
-    @dataset
-    def x(a: int = Option("A")):
-        return a
-
-    @dataset(options={"A": 2})
-    def y(x: int = x, b: int = Option("B")) -> int:
-        return x + b
-
-    assert y({"B": 3}) == 5
-    assert y({"A": 3, "B": 3}) == 5
-
-
-def test_with_options():
-    @dataset
-    def x(a: int = Option("A")):
-        return a
-
-    @dataset
-    def y(x: int = x, b: int = Option("B")) -> int:
-        return x + b
-
-    z = y.with_options({"A": 2})
-
-    assert z({"B": 3}) == 5
-    assert z({"A": 3, "B": 3}) == 5
-
-
-def test_with_cache():
-    @dataset
-    def rand():
-        return random.random()
-
-    assert rand.evaluate({}) == rand.evaluate({})
-
-    rand_no_cache = rand.with_cache(NoCache)
-
-    assert rand_no_cache.evaluate({}) != rand_no_cache.evaluate({})
-
-    rand.set_cache(NoCache)
-
-    assert rand.evaluate({}) != rand.evaluate({})
-
-
-def test_distinct_callbacks():
-    @dataset
-    def x():
-        return 1
-
-    @dataset
-    def y():
-        return 2
-
-    assert x._callbacks is not y._callbacks
diff --git a/tests/unit/test_interfaces.py b/tests/unit/test_interfaces.py
deleted file mode 100644
index 53683e3..0000000
--- a/tests/unit/test_interfaces.py
+++ /dev/null
@@ -1,85 +0,0 @@
-import pytest
-
-import tests.ext.interfaces
-import tests.pkg.interfaces
-from labrea import Interface, Option, interface
-from labrea.types import EvaluationError
-
-
-def test_base():
-    config = {"B": "B"}
-
-    with pytest.raises(EvaluationError):
-        _ = tests.pkg.interfaces.Interface1.a(config)
-
-    assert tests.pkg.interfaces.Interface1.b.evaluate(config) == "B"
-    assert tests.pkg.interfaces.Interface1.c.evaluate(config) == "default"
-
-
-def test_imp():
-    config = {
-        "B": "B",
-        "LABREA": {
-            "IMPLEMENTATIONS": {
-                "tests": {
-                    "pkg": {
-                        "interfaces": {"Interface1": "tests.ext.interfaces.Int1Imp"}
-                    }
-                }
-            },
-            "REQUIRES": ["tests.ext"],
-        },
-    }
-
-    assert tests.pkg.interfaces.Interface1.a.evaluate(config) == 1
-    assert tests.pkg.interfaces.Interface1.b.evaluate(config) == "B"
-    assert tests.pkg.interfaces.Interface1.c.evaluate(config) == "overload"
-
-
-def test_multi():
-    config = {"A": "A", "B": "B", "LABREA": {"IMPLEMENTATIONS": {"A": "X", "B": "XX"}}}
-
-    @interface.dispatch("LABREA.IMPLEMENTATIONS.A")
-    class A:
-        a: int
-
-    @interface.dispatch("LABREA.IMPLEMENTATIONS.B")
-    class B:
-        b: int
-
-    with pytest.raises(TypeError):
-
-        @Interface.implementation(A, B)
-        class W:
-            a = Option("A")
-
-    @Interface.implementation(A, B, aliases=["X", "XX"])
-    class X:
-        a = Option("A")
-        b = Option("B")
-
-    assert A.a(config) == "A"
-    assert B.b(config) == "B"
-
-
-def test_other_evaluatable():
-    @interface(dispatch="INT")
-    class Int:
-        a: int = Option("A")
-        b: int = 2
-
-    @Int.implementation(alias="Imp")
-    class Imp:
-        a: int = 3
-
-    assert Int.a({"A": 1}) == 1
-    assert Int.b({}) == 2
-    assert Int.a({"INT": "Imp"}) == 3
-
-
-def test_no_interfaces():
-    with pytest.raises(TypeError):
-
-        @Interface.implementation()
-        class X:
-            pass
diff --git a/tests/unit/test_multithreading.py b/tests/unit/test_multithreading.py
deleted file mode 100644
index b114598..0000000
--- a/tests/unit/test_multithreading.py
+++ /dev/null
@@ -1,28 +0,0 @@
-import threading
-
-import labrea.multithreading
-from labrea import dataset
-
-
-@dataset.nocache
-def inner():
-    return threading.currentThread()
-
-
-@dataset.nocache
-def outer(inr=inner):
-    return inr
-
-
-def test_multithreading():
-    assert labrea.multithreading.__PARALLEL
-
-    assert outer({}) is not threading.currentThread()
-
-    labrea.multithreading.disable()
-    assert not labrea.multithreading.__PARALLEL
-
-    assert outer({}) is threading.currentThread()
-
-    labrea.multithreading.enable()
-    assert labrea.multithreading.__PARALLEL
diff --git a/tests/unit/test_options.py b/tests/unit/test_options.py
deleted file mode 100644
index 2663d4d..0000000
--- a/tests/unit/test_options.py
+++ /dev/null
@@ -1,42 +0,0 @@
-import pytest
-
-from labrea import Option
-from labrea.types import ValidationError
-
-
-def test_templating():
-    opt = Option("C", "{A.X} {B}")
-
-    assert opt({"A": {"X": "Hello"}, "B": "World!"}) == "Hello World!"
-    assert opt({"C": "{D} {E}", "D": "foo", "E": "bar"}) == "foo bar"
-    assert (
-        opt(
-            {
-                "C": "{D} {E}",
-                "D": "foo",
-                "E": "bar",
-                "LABREA": {"OPTIONS": {"RESOLVE": False}},
-            }
-        )
-        == "{D} {E}"
-    )
-
-
-def test_validate():
-    with pytest.raises(ValidationError):
-        Option("A.B").validate({"A": {"X": 1}})
-
-    with pytest.raises(ValidationError):
-        Option("A").validate({"A": "{X}"})
-
-    with pytest.raises(ValidationError):
-        Option("A", "{B}").validate({})
-
-    Option("A", "{B}").validate({"B": 1})
-
-    Option("A").validate({"A": "{X}", "LABREA": {"OPTIONS": {"RESOLVE": False}}})
-
-
-def test_docstring():
-    assert Option("A").__doc__ == ""
-    assert Option("A", doc="Hello, World!").__doc__ == "Hello, World!"
diff --git a/tests/unit/test_pipelines.py b/tests/unit/test_pipelines.py
deleted file mode 100644
index f249d5c..0000000
--- a/tests/unit/test_pipelines.py
+++ /dev/null
@@ -1,57 +0,0 @@
-import pytest
-from functional_pypelines.validator import ValidationError
-
-from labrea import LabreaPipeline, Option, dataset
-from labrea.pipelines import validator
-from labrea.types import EvaluationError
-
-
-@dataset
-def double_a(a: int = Option("A").result) -> int:
-    return a * 2
-
-
-@LabreaPipeline.step
-def add_double_a(x: int, dbl_a: int = double_a.result) -> int:
-    return x + dbl_a
-
-
-@LabreaPipeline.step
-def divide(x: int, divisor: int = Option("DIVISOR").result) -> float:
-    return x / divisor
-
-
-pipeline = add_double_a >> divide >> LabreaPipeline.unwrap
-
-
-def test_valid_input():
-    options = {"A": 4, "DIVISOR": 3}
-    validator.validate(pipeline, 1, options)
-    assert pipeline(1, options) == 3
-
-
-def test_invalid_input():
-    with pytest.raises(ValidationError):
-        validator.validate(pipeline, 1, {})
-    with pytest.raises(ValidationError):
-        validator.validate(pipeline, 1, {"A": 4})
-    with pytest.raises(ValidationError):
-        validator.validate(pipeline, 1, {"DIVISOR": 3})
-    with pytest.raises(EvaluationError):
-        pipeline(1, {})
-
-
-def test_warning():
-    with pytest.warns():
-
-        @LabreaPipeline.step
-        def default_argument(x: int = 1, y: int = 2) -> int:
-            return x + y
-
-
-def test_no_value():
-    @LabreaPipeline.step
-    def no_default_value(_, x: int = Option("X").result):
-        return x
-
-    assert no_default_value(options={"X": 1}).value == 1
diff --git a/tests/unit/test_switch.py b/tests/unit/test_switch.py
deleted file mode 100644
index f64b33c..0000000
--- a/tests/unit/test_switch.py
+++ /dev/null
@@ -1,16 +0,0 @@
-import pytest
-
-from labrea import Switch
-from labrea.types import EvaluationError
-
-
-def test_dispatch():
-    switch = Switch("X", {1: 1})
-
-    with pytest.raises(EvaluationError):
-        switch.evaluate({"X": []})
-
-
-def test_has_default_prop():
-    assert not Switch("X", {1: 1}).has_default
-    assert Switch("X", {1: 1}, 10).has_default
diff --git a/tests/unit/test_template.py b/tests/unit/test_template.py
deleted file mode 100644
index 0226ba9..0000000
--- a/tests/unit/test_template.py
+++ /dev/null
@@ -1,47 +0,0 @@
-import pytest
-
-from labrea import Option, Template, dataset
-from labrea.types import EvaluationError, ValidationError
-
-
-@dataset
-def b(b_: str = Option("B")) -> str:
-    return b_
-
-
-t = Template("{A.X} {:b:}", b=b)
-
-
-def test_evaluate():
-    assert t({"A": {"X": "Hello"}, "B": "World!"}) == "Hello World!"
-
-    with pytest.raises(EvaluationError):
-        t({"A": {"X": "Hello"}})
-
-    with pytest.raises(EvaluationError):
-        t({"B": "World!"})
-
-
-def test_validate():
-    t.validate({"A": {"X": "Hello"}, "B": "World!"})
-
-    with pytest.raises(ValidationError):
-        t.validate({"A": {"X": "Hello"}})
-
-    with pytest.raises(ValidationError):
-        t.validate({"B": "World!"})
-
-
-def test_keys():
-    assert t.keys({"A": {"X": "Hello"}, "B": "World!"}) == {"A.X", "B"}
-
-
-def test_invalid_template():
-    with pytest.raises(ValueError):
-        Template("{A.X} {:b:}")
-
-    with pytest.raises(ValueError):
-        Template("{A.X} {:b:}", a="Unused")
-
-    with pytest.warns(UserWarning):
-        Template("{A.X} {:b:}", a="Unused", b=b)
diff --git a/tests/unit/test_value.py b/tests/unit/test_value.py
deleted file mode 100644
index a4f211c..0000000
--- a/tests/unit/test_value.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from labrea.types import Value
-
-
-def test_uncopyable():
-    class Uncopyable:
-        def __deepcopy__(self, memo):
-            raise NotImplementedError("Cannot copy this.")
-
-    uncopyable = Uncopyable()
-
-    assert Value(uncopyable).evaluate({}) is uncopyable