Repository: drhagen/tensora

Issue:
Centralize validation of formats against assignment
Currently, all the logic for checking that the formats match the variables in the assignment is done in `tensora.function.PureTensorMethod`. This code is not run by the CLI. This logic should be centralized into a class `tensora.desugar.Problem` that encapsulates the desugared assignment and validated format dictionary. Then this logic should be used upstream of code generation for both `evaluate` and the CLI.

Discussion:
codecov-commenter: ## [Codecov](https://app.codecov.io/gh/drhagen/tensora/pull/32?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Hagen) Report
Attention: `32 lines` in your changes are missing coverage. Please review.
> Comparison is base [(`dd2c73f`)](https://app.codecov.io/gh/drhagen/tensora/commit/dd2c73f92d4ccba28dc9bd5d1d4dd7357467c71a?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Hagen) 88.67% compared to head [(`c242b23`)](https://app.codecov.io/gh/drhagen/tensora/pull/32?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Hagen) 88.51%.

| [Files](https://app.codecov.io/gh/drhagen/tensora/pull/32?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Hagen) | Patch % | Lines |
|---|---|---|
| [src/tensora/cli.py](https://app.codecov.io/gh/drhagen/tensora/pull/32?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Hagen#diff-c3JjL3RlbnNvcmEvY2xpLnB5) | 52.00% | [9 Missing and 3 partials :warning: ](https://app.codecov.io/gh/drhagen/tensora/pull/32?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Hagen) |
| [src/tensora/problem.py](https://app.codecov.io/gh/drhagen/tensora/pull/32?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Hagen#diff-c3JjL3RlbnNvcmEvcHJvYmxlbS5weQ==) | 79.16% | [6 Missing and 4 partials :warning: ](https://app.codecov.io/gh/drhagen/tensora/pull/32?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Hagen) |
| [src/tensora/generate.py](https://app.codecov.io/gh/drhagen/tensora/pull/32?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Hagen#diff-c3JjL3RlbnNvcmEvZ2VuZXJhdGUucHk=) | 84.61% | [5 Missing and 3 partials :warning: ](https://app.codecov.io/gh/drhagen/tensora/pull/32?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Hagen) |
| [src/tensora/function.py](https://app.codecov.io/gh/drhagen/tensora/pull/32?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Hagen#diff-c3JjL3RlbnNvcmEvZnVuY3Rpb24ucHk=) | 80.00% | [1 Missing and 1 partial :warning: ](https://app.codecov.io/gh/drhagen/tensora/pull/32?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Hagen) |

:exclamation: Your organization needs to install the [Codecov GitHub app](https://github.com/apps/codecov/installations/select_target) to enable full functionality.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master      #32      +/-   ##
==========================================
- Coverage   88.67%   88.51%   -0.17%     
==========================================
  Files          50       52       +2     
  Lines        2924     2996      +72     
  Branches      518      525       +7     
==========================================
+ Hits         2593     2652      +59     
- Misses        254      266      +12     
- Partials       77       78       +1     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/drhagen/tensora/pull/32?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Hagen).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Hagen).


Patch:
diff --git a/README.md b/README.md
index b13fd6d..4e6722d 100644
--- a/README.md
+++ b/README.md
@@ -6,9 +6,9 @@ Tensors are n-dimensional generalizations of matrices. Instead of being confined
 
 In a dense tensor, each element is explicitly stored in memory. If the vast majority of elements are zero, then this is an inefficient layout, taking far more memory to store and far more time to operate on. There are many different sparse tensor formats, each one better or worse depending on which elements of the tensor are nonzero.
 
-Many tensor kernels (functions that perform a specific algebraic calculation between tensors with specific sparse formats) have been written to solve specific problems. Recently, the [Tensor Algebra Compiler](http://tensor-compiler.org/) (taco) was invented to automate the construction and optimization of tensor kernels for arbitrary algebraic expressions for arbitrary sparse formats. taco takes an algebraic expression and description of the format of each tensor in the expression and returns a C function that efficiently evaluates the given expression for those tensor arguments.
+Many tensor kernels (functions that perform a specific algebraic calculation between tensors with specific sparse formats) have been written to solve specific problems. Recently, the [Tensor Algebra Compiler](http://tensor-compiler.org/) (TACO) was invented to automate the construction and optimization of tensor kernels for arbitrary algebraic expressions for arbitrary sparse formats. TACO takes an algebraic expression and description of the format of each tensor in the expression and returns a C function that efficiently evaluates the given expression for those tensor arguments.
 
-Tensora is a Python wrapper around two tensor algebra compilers: the original taco library and an independent Python implementation in Tensora. Tensora has a central class `Tensor` that simply has a pointer to a taco tensor held in C memory, managed by the `cffi` package. Tensora exposes functions that take a string of an algebraic expression and return a Python function the performs that operation in fast C code. In order to do that, the string is parsed and passed to the tensor algebra compiler; the C code generated by the tensor algebra compiler is compiled "on the fly" by `cffi` and then wrapped by code that provides good error handling.
+Tensora is a Python wrapper around two tensor algebra compilers: the original TACO library and an independent Python implementation in Tensora. Tensora has a central class `Tensor` that simply has a pointer to a taco tensor held in C memory, managed by the `cffi` package. Tensora exposes functions that take a string of an algebraic expression and return a Python function the performs that operation in fast C code. In order to do that, the string is parsed and passed to the tensor algebra compiler; the C code generated by the tensor algebra compiler is compiled "on the fly" by `cffi` and then wrapped by code that provides good error handling.
 
 Tensora comes with a command line tool `tensora`, which provides the C code to the user for given algebraic expressions and tensor formats.
 
@@ -157,7 +157,7 @@ There is also `evaluate_tensora` and `evaluate_taco` that have identical interfa
 
 ## Getting the C code
 
-The `tensora` CLI tool emits the C code for a given algebraic expression, tensor formats, and kernel type. It comes installed with Tensora and can be run like:
+The `tensora` CLI tool emits the C code for a given algebraic expression, tensor formats, and kernel type. It can emit the C code it generates itself, or the C code generated by TACO. It comes installed with Tensora and can be run like:
 
 ```bash
 tensora 'y(i) = A(i,j) * x(j)' -f A:ds -t compute -o kernel.c
@@ -183,6 +183,10 @@ Here is the output of `tensora --help` for reference:
 │                                                            be generated. Can be          │
 │                                                            mentioned multiple times.     │
 │                                                            [default: compute]            │
+│ --compiler            -c      [tensora|taco]               The tensor algebra compiler   │
+│                                                            to use to generate the        │
+│                                                            kernel.                       │
+│                                                            [default: tensora]            │
 │ --output              -o      PATH                         The file to which the kernel  │
 │                                                            will be written. If not       │
 │                                                            specified, prints to standard │
diff --git a/src/tensora/__init__.py b/src/tensora/__init__.py
index a858d3f..9b3a383 100644
--- a/src/tensora/__init__.py
+++ b/src/tensora/__init__.py
@@ -1,4 +1,4 @@
-from .compile import TensorCompiler
 from .format import Format, Mode
 from .function import evaluate, evaluate_taco, evaluate_tensora, tensor_method
+from .generate import TensorCompiler
 from .tensor import Tensor
diff --git a/src/tensora/cli.py b/src/tensora/cli.py
index 2929139..65c7736 100644
--- a/src/tensora/cli.py
+++ b/src/tensora/cli.py
@@ -4,13 +4,14 @@ from pathlib import Path
 from typing import Annotated, Optional
 
 import typer
-from parsita import Failure, Success
+from parsita import ParseError
+from returns.result import Failure, Success
 
-from .desugar.exceptions import DiagonalAccessError, NoKernelFoundError
 from .expression import parse_assignment
-from .format import Format, Mode, parse_format
+from .format import parse_named_format
+from .generate import TensorCompiler, generate_c_code
 from .kernel_type import KernelType
-from .native import generate_c_code_from_parsed
+from .problem import make_problem
 
 app = typer.Typer()
 
@@ -43,6 +44,14 @@ def tensora(
             help="The type of kernel that will be generated. Can be mentioned multiple times.",
         ),
     ] = [KernelType.compute],
+    tensor_compiler: Annotated[
+        TensorCompiler,
+        typer.Option(
+            "--compiler",
+            "-c",
+            help="The tensor algebra compiler to use to generate the kernel.",
+        ),
+    ] = TensorCompiler.tensora,
     output_path: Annotated[
         Optional[Path],
         typer.Option(
@@ -61,48 +70,51 @@ def tensora(
         case Failure(error):
             typer.echo(f"Failed to parse assignment:\n{error}", err=True)
             raise typer.Exit(1)
-        case Success(sugar):
-            sugar = sugar
+        case Success(parsed_assignment):
+            pass
+        case _:
+            raise NotImplementedError()
 
     # Parse formats
     parsed_formats = {}
     for target_format_string in target_format_strings:
-        split_format = target_format_string.split(":")
-        if len(split_format) != 2:
-            typer.echo(
-                f"Format must be of the form 'target:format_string': {target_format_string}",
-                err=True,
-            )
-            raise typer.Exit(1)
-
-        target, format_string = split_format
+        match parse_named_format(target_format_string):
+            case Failure(ParseError(_) as error):
+                typer.echo(f"Failed to parse format:\n{error}", err=True)
+                raise typer.Exit(1)
+            case Failure(error):
+                typer.echo(str(error), err=True)
+                raise typer.Exit(1)
+            case Success((target, format)):
+                pass
+            case _:
+                raise NotImplementedError()
 
         if target in parsed_formats:
             typer.echo(f"Format for {target} was mentioned multiple times", err=True)
             raise typer.Exit(1)
 
-        match parse_format(format_string):
-            case Failure(error):
-                typer.echo(f"Failed to parse format:\n{error}", err=True)
-                typer.Exit(1)
-            case Success(format):
-                parsed_formats[target] = format
+        parsed_formats[target] = format
 
-    # Fill in missing formats with dense formats
-    # Use the order of variable_orders to determine the parameter order
-    formats = {}
-    for variable_name, order in sugar.variable_orders().items():
-        if variable_name in parsed_formats:
-            formats[variable_name] = parsed_formats[variable_name]
-        else:
-            formats[variable_name] = Format((Mode.dense,) * order, tuple(range(order)))
+    # Validate and standardize assignment and formats
+    match make_problem(parsed_assignment, parsed_formats):
+        case Failure(error):
+            typer.echo(str(error), err=True)
+            raise typer.Exit(1)
+        case Success(problem):
+            pass
+        case _:
+            raise NotImplementedError()
 
     # Generate code
-    try:
-        code = generate_c_code_from_parsed(sugar, formats, kernel_types)
-    except (DiagonalAccessError, NoKernelFoundError) as error:
-        typer.echo(error, err=True)
-        raise typer.Exit(1)
+    match generate_c_code(problem, kernel_types, tensor_compiler):
+        case Failure(error):
+            typer.echo(str(error), err=True)
+            raise typer.Exit(1)
+        case Success(code):
+            pass
+        case _:
+            raise NotImplementedError()
 
     if output_path is None:
         typer.echo(code)
diff --git a/src/tensora/compile.py b/src/tensora/compile.py
index 6c9f152..c4dc3b3 100644
--- a/src/tensora/compile.py
+++ b/src/tensora/compile.py
@@ -1,5 +1,5 @@
 __all__ = [
-    "taco_kernel",
+    "generate_library",
     "allocate_taco_structure",
     "taco_structure_to_cffi",
     "take_ownership_of_arrays",
@@ -8,20 +8,18 @@ __all__ = [
 ]
 
 import re
-import subprocess
 import tempfile
 import threading
-from enum import Enum, auto
 from pathlib import Path
-from typing import Any, List, Tuple
+from typing import Any
 from weakref import WeakKeyDictionary
 
 from cffi import FFI
+from returns.result import Failure, Success
 
-from .expression import deparse_to_taco
-from .expression.ast import Assignment
-from .format import Format
-from .native import generate_c_code_from_parsed
+from .generate import TensorCompiler, generate_c_code
+from .kernel_type import KernelType
+from .problem import Problem
 
 lock = threading.Lock()
 
@@ -97,69 +95,31 @@ tensor_cdefs.set_source("_main", "")
 tensor_lib = tensor_cdefs.dlopen(None)
 
 
-def format_to_taco_format(format: Format):
-    return (
-        "".join(mode.character for mode in format.modes)
-        + ":"
-        + ",".join(map(str, format.ordering))
-    )
-
-
-class TensorCompiler(Enum):
-    taco = auto()
-    tensora = auto()
+def generate_library(
+    problem: Problem, compiler: TensorCompiler = TensorCompiler.tensora
+) -> tuple[list[str], Any]:
+    """Generate source, compile it, and load it.
 
-
-def taco_kernel(
-    expression: Assignment,
-    formats: dict[str, Format],
-    compiler: TensorCompiler = TensorCompiler.tensora,
-) -> Tuple[List[str], Any]:
-    """Call taco with expression and compile resulting function.
-
-    Given an expression and a set of formats:
-    (1) call out to taco to get the source code for the evaluate function that runs that expression for those formats
+    Given a problem:
+    (1) invoke the tensor algebra compiler to generate C code for evaluate
     (2) parse the signature in the source to determine the order of arguments
     (3) compile the source with cffi
     (4) return the list of parameter names and the compiled library
 
-    Because compilation can take a non-trivial amount of time, the results of this function is cached by a
-    `functools.lru_cache`, which is configured to store the results of the 256 most recent calls to this function.
-
     Args:
-        expression: An expression that can parsed by taco.
-        formats: A frozen set of pairs of strings. It must be a frozen set because `lru_cache` requires that the
-        arguments be hashable and therefore immutable. The first element of each pair is a variable name; the second
-        element is the format in taco format (e.g. 'dd:1,0', 'dss:0,1,2'). Scalar variables must not be listed because
-        taco does not understand them having a format.
+        problem: A valid tensor algebra expression and associated tensor formats
+        compiler: The tensor algebra compiler to use to generate the C code
 
     Returns:
         A tuple where the first element is the list of variable names in the order they appear in the function
         signature, and the second element is the compiled FFILibrary which has a single method `evaluate` which expects
         cffi pointers to taco_tensor_t instances in order specified by the list of variable names.
     """
-    match compiler:
-        case TensorCompiler.taco:
-            expression_string = deparse_to_taco(expression)
-            format_strings = frozenset(
-                (parameter_name, format_to_taco_format(format))
-                for parameter_name, format in formats.items()
-                if format.order != 0  # Taco does not like formats for scalars
-            )
-            # Call taco to write the kernels to standard out
-            result = subprocess.run(
-                [taco_binary, expression_string, "-print-evaluate", "-print-nocolor"]
-                + [f"-f={name}:{format}" for name, format in format_strings],
-                capture_output=True,
-                text=True,
-            )
-
-            if result.returncode != 0:
-                raise RuntimeError(result.stderr)
-
-            source = result.stdout
-        case TensorCompiler.tensora:
-            source = generate_c_code_from_parsed(expression, formats)
+    match generate_c_code(problem, [KernelType.evaluate], compiler):
+        case Failure(error):
+            raise error
+        case Success(source):
+            pass
 
     # Determine signature
     # 1) Find function by name and capture its parameter list
@@ -197,7 +157,7 @@ def taco_kernel(
 
 
 def allocate_taco_structure(
-    mode_types: Tuple[int, ...], dimensions: Tuple[int, ...], mode_ordering: Tuple[int, ...]
+    mode_types: tuple[int, ...], dimensions: tuple[int, ...], mode_ordering: tuple[int, ...]
 ):
     """Allocate all parts of a taco tensor except growable arrays.
 
@@ -285,12 +245,12 @@ def allocate_taco_structure(
 
 
 def taco_structure_to_cffi(
-    indices: List[List[List[int]]],
-    vals: List[float],
+    indices: list[list[list[int]]],
+    vals: list[float],
     *,
-    mode_types: Tuple[int, ...],
-    dimensions: Tuple[int, ...],
-    mode_ordering: Tuple[int, ...],
+    mode_types: tuple[int, ...],
+    dimensions: tuple[int, ...],
+    mode_ordering: tuple[int, ...],
 ):
     """Build a cffi taco tensor from Python data.
 
@@ -485,5 +445,5 @@ def take_ownership_of_tensor(cffi_tensor) -> None:
     take_ownership_of_tensor_members(cffi_tensor)
 
 
-def weakly_increasing(list: List[int]):
+def weakly_increasing(list: list[int]):
     return all(x <= y for x, y in zip(list, list[1:]))
diff --git a/src/tensora/desugar/best_algorithm.py b/src/tensora/desugar/best_algorithm.py
index 3e2e3bc..caa07bc 100644
--- a/src/tensora/desugar/best_algorithm.py
+++ b/src/tensora/desugar/best_algorithm.py
@@ -1,15 +1,22 @@
 __all__ = ["best_algorithm"]
 
+from returns.result import Failure, Result, Success
+
 from ..format import Format
 from ..iteration_graph.iteration_graph import IterationGraph
 from . import ast
-from .exceptions import NoKernelFoundError
+from .exceptions import DiagonalAccessError, NoKernelFoundError
 from .to_iteration_graphs import to_iteration_graphs
 
 
-def best_algorithm(assignment: ast.Assignment, formats: dict[str, Format]) -> IterationGraph:
-    match next(to_iteration_graphs(assignment, formats), None):
-        case None:
-            raise NoKernelFoundError()
-        case graph:
-            return graph
+def best_algorithm(
+    assignment: ast.Assignment, formats: dict[str, Format | None]
+) -> Result[IterationGraph, DiagonalAccessError | NoKernelFoundError]:
+    try:
+        match next(to_iteration_graphs(assignment, formats), None):
+            case None:
+                return Failure(NoKernelFoundError())
+            case graph:
+                return Success(graph)
+    except DiagonalAccessError as e:
+        return Failure(e)
diff --git a/src/tensora/desugar/to_iteration_graphs.py b/src/tensora/desugar/to_iteration_graphs.py
index 18c1926..a98b338 100644
--- a/src/tensora/desugar/to_iteration_graphs.py
+++ b/src/tensora/desugar/to_iteration_graphs.py
@@ -249,7 +249,7 @@ def merge_assignment(
 
 
 def to_iteration_graphs(
-    assignment: ast.Assignment, formats: dict[str, Format]
+    assignment: ast.Assignment, formats: dict[str, Format | None]
 ) -> Iterator[ig.IterationGraph]:
     output_format = formats[assignment.target.name]
     output_layers = {
diff --git a/src/tensora/expression/__init__.py b/src/tensora/expression/__init__.py
index 5fe3bc9..715e3fb 100644
--- a/src/tensora/expression/__init__.py
+++ b/src/tensora/expression/__init__.py
@@ -1,4 +1,4 @@
 from . import ast
 from .deparse_to_taco import deparse_to_taco
-from .exceptions import InconsistentVariableSizeError, MutatingAssignmentError
+from .exceptions import InconsistentDimensionsError, MutatingAssignmentError
 from .parser import parse_assignment
diff --git a/src/tensora/expression/ast.py b/src/tensora/expression/ast.py
index d26c541..25152c6 100644
--- a/src/tensora/expression/ast.py
+++ b/src/tensora/expression/ast.py
@@ -203,7 +203,7 @@ class Assignment:
     expression: Expression
 
     def __post_init__(self):
-        from .exceptions import InconsistentVariableSizeError, MutatingAssignmentError
+        from .exceptions import InconsistentDimensionsError, MutatingAssignmentError
 
         target_name = self.target.name
 
@@ -215,7 +215,7 @@ class Assignment:
 
             for variable in rest:
                 if first.order != variable.order:
-                    raise InconsistentVariableSizeError(self, first, variable)
+                    raise InconsistentDimensionsError(self, first, variable)
 
             variable_orders[name] = first.order
 
diff --git a/src/tensora/expression/exceptions.py b/src/tensora/expression/exceptions.py
index c9f155b..2b97d6d 100644
--- a/src/tensora/expression/exceptions.py
+++ b/src/tensora/expression/exceptions.py
@@ -1,4 +1,4 @@
-__all__ = ["MutatingAssignmentError", "InconsistentVariableSizeError"]
+__all__ = ["MutatingAssignmentError", "InconsistentDimensionsError"]
 
 from dataclasses import dataclass
 
@@ -17,7 +17,7 @@ class MutatingAssignmentError(Exception):
 
 
 @dataclass(frozen=True, slots=True)
-class InconsistentVariableSizeError(Exception):
+class InconsistentDimensionsError(Exception):
     assignment: Assignment
     first: Variable
     second: Variable
diff --git a/src/tensora/expression/parser.py b/src/tensora/expression/parser.py
index 6087424..ea58e76 100644
--- a/src/tensora/expression/parser.py
+++ b/src/tensora/expression/parser.py
@@ -7,7 +7,7 @@ from parsita.util import splat
 from returns import result
 
 from .ast import Add, Assignment, Float, Integer, Multiply, Scalar, Subtract, Tensor
-from .exceptions import InconsistentVariableSizeError, MutatingAssignmentError
+from .exceptions import InconsistentDimensionsError, MutatingAssignmentError
 
 
 def make_expression(first, rest):
@@ -45,10 +45,8 @@ class TensorExpressionParsers(ParserContext, whitespace=r"[ ]*"):
 
 def parse_assignment(
     string: str
-) -> result.Result[
-    Assignment, ParseError | MutatingAssignmentError | InconsistentVariableSizeError
-]:
+) -> result.Result[Assignment, ParseError | MutatingAssignmentError | InconsistentDimensionsError]:
     try:
         return TensorExpressionParsers.assignment.parse(string)
-    except (MutatingAssignmentError, InconsistentVariableSizeError) as e:
+    except (MutatingAssignmentError, InconsistentDimensionsError) as e:
         return result.Failure(e)
diff --git a/src/tensora/format/__init__.py b/src/tensora/format/__init__.py
index a282305..8fdcb1a 100644
--- a/src/tensora/format/__init__.py
+++ b/src/tensora/format/__init__.py
@@ -1,3 +1,4 @@
 from .exceptions import InvalidModeOrderingError
 from .format import Format, Mode
-from .parser import parse_format
+from .format_order import format_order
+from .parser import parse_format, parse_named_format
diff --git a/src/tensora/format/format_order.py b/src/tensora/format/format_order.py
new file mode 100644
index 0000000..67ecb84
--- /dev/null
+++ b/src/tensora/format/format_order.py
@@ -0,0 +1,7 @@
+__all__ = ["format_order"]
+
+from .format import Format
+
+
+def format_order(format: Format | None) -> int | None:
+    return len(format.modes) if format is not None else None
diff --git a/src/tensora/format/parser.py b/src/tensora/format/parser.py
index a5476e6..49a49bf 100644
--- a/src/tensora/format/parser.py
+++ b/src/tensora/format/parser.py
@@ -1,4 +1,4 @@
-__all__ = ["parse_format"]
+__all__ = ["parse_format", "parse_named_format"]
 
 from parsita import ParseError, ParserContext, lit, reg, rep
 from parsita.util import constant
@@ -30,9 +30,21 @@ class FormatParsers(ParserContext):
 
     format = format_without_orderings | format_with_orderings
 
+    variable = reg(r"[a-zA-Z_][a-zA-Z0-9_]*")
+    named_format = variable << ":" & format > tuple
 
-def parse_format(format: str) -> result.Result[Format, ParseError | InvalidModeOrderingError]:
+
+def parse_format(string: str, /) -> result.Result[Format, ParseError | InvalidModeOrderingError]:
+    try:
+        return FormatParsers.format.parse(string)
+    except InvalidModeOrderingError as e:
+        return result.Failure(e)
+
+
+def parse_named_format(
+    string: str, /
+) -> result.Result[tuple[str, Format], ParseError | InvalidModeOrderingError]:
     try:
-        return FormatParsers.format.parse(format)
+        return FormatParsers.named_format.parse(string)
     except InvalidModeOrderingError as e:
         return result.Failure(e)
diff --git a/src/tensora/function.py b/src/tensora/function.py
index fef9c17..007e45f 100644
--- a/src/tensora/function.py
+++ b/src/tensora/function.py
@@ -1,19 +1,15 @@
-__all__ = [
-    "tensor_method",
-    "evaluate",
-    "evaluate_taco",
-    "evaluate_tensora",
-    "PureTensorMethod",
-    "TensorCompiler",
-]
+__all__ = ["tensor_method", "evaluate", "evaluate_taco", "evaluate_tensora", "PureTensorMethod"]
 
 from functools import lru_cache
 from inspect import Parameter, Signature
-from typing import Dict, Tuple
 
-from .compile import TensorCompiler, allocate_taco_structure, taco_kernel, take_ownership_of_arrays
-from .expression.ast import Assignment, Scalar
+from returns.result import Failure, Success
+
+from .compile import allocate_taco_structure, generate_library, take_ownership_of_arrays
+from .expression.ast import Assignment
 from .format import Format, parse_format
+from .generate import TensorCompiler
+from .problem import make_problem
 from .tensor import Tensor
 
 
@@ -23,41 +19,17 @@ class PureTensorMethod:
     def __init__(
         self,
         assignment: Assignment,
-        input_formats: Dict[str, Format],
-        output_format: Format,
+        input_formats: dict[str, Format | None],
+        output_format: Format | None,
         compiler: TensorCompiler = TensorCompiler.tensora,
     ):
-        target_name = assignment.target.name
-        variable_orders = assignment.variable_orders()
-
-        # Ensure that all parameters are defined
-        for variable_name in variable_orders.keys():
-            if variable_name != target_name and variable_name not in input_formats:
-                raise ValueError(
-                    f"Variable {variable_name} in {assignment} not listed in parameters"
-                )
-
-        # Ensure that no extraneous parameters are defined
-        for parameter_name in input_formats.keys():
-            if parameter_name not in variable_orders:
-                raise ValueError(f"Parameter {parameter_name} not in {assignment} variables")
-
-        # Verify that parameters have the correct order
-        for parameter_name, format in input_formats.items():
-            if format.order != variable_orders[parameter_name]:
-                raise ValueError(
-                    f"Parameter {parameter_name} has order {format.order}, but this variable in the "
-                    f"assignment has order {variable_orders[parameter_name]}"
-                )
-
-        if isinstance(assignment.target, Scalar):
-            raise NotImplementedError("Tensora does not support scalar outputs yet")
-
-        if output_format.order != assignment.target.order:
-            raise ValueError(
-                f"Output parameter has order {output_format.order}, but the output variable in the "
-                f"assignment has order {assignment.target.order}"
-            )
+        match make_problem(assignment, {assignment.target.name: output_format, **input_formats}):
+            case Failure(error):
+                raise error
+            case Success(problem):
+                pass
+            case _:
+                raise NotImplementedError()
 
         # Store validated attributes
         self.assignment = assignment
@@ -73,8 +45,7 @@ class PureTensorMethod:
         )
 
         # Compile taco function
-        all_formats = {self.assignment.target.name: output_format, **input_formats}
-        self.parameter_order, self.cffi_lib = taco_kernel(assignment, all_formats, compiler)
+        self.parameter_order, self.cffi_lib = generate_library(problem, compiler)
 
     def __call__(self, *args, **kwargs):
         # Handle arguments like normal Python function
@@ -152,7 +123,7 @@ class PureTensorMethod:
 
 def tensor_method(
     assignment: str,
-    input_formats: Dict[str, str],
+    input_formats: dict[str, str],
     output_format: str,
     compiler: TensorCompiler = TensorCompiler.tensora,
 ) -> PureTensorMethod:
@@ -164,7 +135,7 @@ def tensor_method(
 @lru_cache()
 def cachable_tensor_method(
     assignment: str,
-    input_formats: Tuple[Tuple[str, str], ...],
+    input_formats: tuple[tuple[str, str], ...],
     output_format: str,
     compiler: TensorCompiler,
 ) -> PureTensorMethod:
diff --git a/src/tensora/generate.py b/src/tensora/generate.py
new file mode 100644
index 0000000..e5e8bcc
--- /dev/null
+++ b/src/tensora/generate.py
@@ -0,0 +1,119 @@
+__all__ = [
+    "TensorCompiler",
+    "generate_c_code",
+    "generate_c_code_tensora",
+    "generate_c_code_taco",
+    "TacoError",
+]
+
+from dataclasses import dataclass
+from enum import Enum
+from pathlib import Path
+
+from returns.result import Failure, Result, Success
+
+from .desugar import DiagonalAccessError, NoKernelFoundError
+from .kernel_type import KernelType
+from .problem import Problem
+
+
+class TensorCompiler(str, Enum):
+    # Python 3.10 does not support StrEnum, so do it manually
+    tensora = "tensora"
+    taco = "taco"
+
+    def __str__(self) -> str:
+        return self.name
+
+
+def generate_c_code(
+    problem: Problem, kernel_types: list[KernelType], tensor_compiler: TensorCompiler
+) -> Result[str, Exception]:
+    match tensor_compiler:
+        case TensorCompiler.tensora:
+            return generate_c_code_tensora(problem, kernel_types)
+        case TensorCompiler.taco:
+            return generate_c_code_taco(problem, kernel_types)
+
+
+def generate_c_code_tensora(
+    problem: Problem, kernel_types: list[KernelType]
+) -> Result[str, DiagonalAccessError | NoKernelFoundError]:
+    from .codegen import ir_to_c
+    from .desugar import best_algorithm, desugar_assignment, index_dimensions, to_identifiable
+    from .ir import SourceBuilder, peephole
+    from .iteration_graph import Definition, generate_ir
+
+    formats = problem.formats
+
+    desugar = desugar_assignment(problem.assignment)
+
+    output_variable = to_identifiable(desugar.target, formats)
+
+    definition = Definition(output_variable, formats, index_dimensions(desugar))
+
+    match best_algorithm(desugar, formats):
+        case Failure() as result:
+            return result
+        case Success(graph):
+            pass
+        case _:
+            raise NotImplementedError()
+
+    ir = SourceBuilder()
+    for kernel_type in kernel_types:
+        ir.append(generate_ir(definition, graph, kernel_type).finalize())
+
+    return Success(ir_to_c(peephole(ir.finalize())))
+
+
+@dataclass(frozen=True, slots=True)
+class TacoError(Exception):
+    message: str
+
+    def __str__(self) -> str:
+        return self.message
+
+
+taco_binary = Path(__file__).parent.joinpath("taco/bin/taco")
+
+
+def generate_c_code_taco(
+    problem: Problem, kernel_types: list[KernelType]
+) -> Result[str, Exception]:
+    import subprocess
+
+    from .expression import deparse_to_taco
+
+    formats = problem.formats
+
+    expression_string = deparse_to_taco(problem.assignment)
+    format_string_arguments = []
+    for name, format in formats.items():
+        if format is not None and format.order != 0:  # Taco does not like formats for scalars
+            mode_string = "".join(mode.character for mode in format.modes)
+            ordering_string = ",".join(map(str, format.ordering))
+            format_string_arguments.append(f"-f={name}:{mode_string}:{ordering_string}")
+
+    kernel_type_arguments = []
+    for kernel_type in kernel_types:
+        if kernel_type == KernelType.evaluate:
+            kernel_type_arguments.append("-print-evaluate")
+        elif kernel_type == KernelType.compute:
+            kernel_type_arguments.append("-print-compute")
+        elif kernel_type == KernelType.assemble:
+            kernel_type_arguments.append("-print-assembly")
+
+    # Call taco to write the kernels to standard out
+    result = subprocess.run(
+        [taco_binary, expression_string, "-print-nocolor"]
+        + kernel_type_arguments
+        + format_string_arguments,
+        capture_output=True,
+        text=True,
+    )
+
+    if result.returncode != 0:
+        return Failure(TacoError(result.stderr))
+
+    return Success(result.stdout)
diff --git a/src/tensora/iteration_graph/definition.py b/src/tensora/iteration_graph/definition.py
index d8ac093..0293e7e 100644
--- a/src/tensora/iteration_graph/definition.py
+++ b/src/tensora/iteration_graph/definition.py
@@ -15,5 +15,5 @@ class TensorDimension:
 @dataclass(frozen=True, slots=True)
 class Definition:
     output_variable: Variable
-    formats: dict[str, Format]
+    formats: dict[str, Format | None]
     indexes: dict[str, TensorDimension]
diff --git a/src/tensora/native.py b/src/tensora/native.py
deleted file mode 100644
index 38cbb15..0000000
--- a/src/tensora/native.py
+++ /dev/null
@@ -1,29 +0,0 @@
-__all__ = ["generate_c_code_from_parsed"]
-
-from .codegen import ir_to_c
-from .desugar import best_algorithm, desugar_assignment, index_dimensions, to_identifiable
-from .expression.ast import Assignment
-from .format import Format
-from .ir import SourceBuilder, peephole
-from .iteration_graph import Definition, generate_ir
-from .kernel_type import KernelType
-
-
-def generate_c_code_from_parsed(
-    assignment: Assignment,
-    formats: dict[str, Format],
-    kernel_types: list[KernelType] = [KernelType.evaluate],
-) -> str:
-    desugar = desugar_assignment(assignment)
-
-    output_variable = to_identifiable(desugar.target, formats)
-
-    problem = Definition(output_variable, formats, index_dimensions(desugar))
-
-    graph = best_algorithm(desugar, formats)
-
-    ir = SourceBuilder()
-    for kernel_type in kernel_types:
-        ir.append(generate_ir(problem, graph, kernel_type).finalize())
-
-    return ir_to_c(peephole(ir.finalize()))
diff --git a/src/tensora/problem.py b/src/tensora/problem.py
new file mode 100644
index 0000000..0ff1cb3
--- /dev/null
+++ b/src/tensora/problem.py
@@ -0,0 +1,113 @@
+__all__ = [
+    "Problem",
+    "make_problem",
+    "IncorrectDimensionsError",
+    "UndefinedReferenceError",
+    "UnusedFormatError",
+]
+
+from dataclasses import dataclass
+
+from returns.result import Failure, Result, Success
+
+from .expression.ast import Assignment
+from .format import Format, Mode, format_order
+
+
+@dataclass(frozen=True, slots=True)
+class IncorrectDimensionsError(Exception):
+    name: str
+    actual: int | None
+    expected: int | None
+    assignment: Assignment
+
+    def __str__(self):
+        actual = self.actual if self.actual is not None else "scalar"
+        expected = self.expected if self.expected is not None else "scalar"
+
+        return (
+            f"Expected each reference in an assignment to have a number of indexes matching the "
+            f"order of the corresponding format, but variable {self.name} referenced in "
+            f"{self.assignment} indexes has order {actual} while its format has order {expected}"
+        )
+
+
+@dataclass(frozen=True, slots=True)
+class UndefinedReferenceError(Exception):
+    name: str
+    assignment: Assignment
+    formats: list[str]
+
+    def __str__(self):
+        return (
+            f"Excepted each reference in an assignment to have a corresponding format, "
+            f"but variable {self.name} referenced in {self.assignment} was not found among the "
+            f"given formats {self.formats}"
+        )
+
+
+@dataclass(frozen=True, slots=True)
+class UnusedFormatError(Exception):
+    name: str
+    assignment: Assignment
+
+    def __str__(self):
+        return (
+            f"Expected each format to be referenced in the assignment, "
+            f"but format {self.name} was not referenced in {self.assignment}"
+        )
+
+
+@dataclass(frozen=True, slots=True)
+class Problem:
+    assignment: Assignment
+    formats: dict[str, Format | None]
+
+    def __post_init__(self):
+        # This intentionally allows for names in formats that are not referenced in the assignment.
+        # The CLI and porcelain API will not allow this, but this is just as valid as defining a
+        # function with unused parameters.
+
+        tensor_orders = self.assignment.variable_orders()
+        for name, order in tensor_orders.items():
+            if name not in self.formats:
+                raise UndefinedReferenceError(name, self.assignment, list(self.formats.keys()))
+            elif order != format_order(self.formats[name]):
+                raise IncorrectDimensionsError(
+                    name, format_order(self.formats[name]), order, self.assignment
+                )
+
+
+def make_problem(
+    assignment: Assignment, formats: dict[str, Format | None]
+) -> Result[Problem, UnusedFormatError | UndefinedReferenceError | IncorrectDimensionsError]:
+    """Create a Problem while filling in default formats.
+
+    This does three things that the `Problem` constructor does not do:
+    1. It reorders the formats to match the order the variables appear in the assignment.
+    2. It fills in any missing formats with all dense modes.
+    3. It raises an exception if there are formats not referenced in the assignment.
+    """
+
+    tensor_orders = assignment.variable_orders()
+
+    for name in formats.keys():
+        if name not in tensor_orders:
+            return Failure(UnusedFormatError(name, assignment))
+
+    new_formats = {}
+    for name, order in tensor_orders.items():
+        if name not in formats:
+            if order is None:
+                new_formats[name] = None
+            else:
+                new_formats[name] = Format(tuple([Mode.dense] * order), tuple(range(order)))
+        else:
+            new_formats[name] = formats[name]
+
+    try:
+        problem = Problem(assignment, new_formats)
+    except (UndefinedReferenceError, IncorrectDimensionsError) as error:
+        return Failure(error)
+
+    return Success(problem)


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 99fae0f..47cb783 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,6 +1,7 @@
 from pathlib import Path
 from tempfile import NamedTemporaryFile
 
+import pytest
 from typer.testing import CliRunner
 
 from tensora.cli import app
@@ -23,9 +24,11 @@ def test_cli():
     assert result.stdout.startswith("int32_t compute(taco_tensor_t* restrict y,")
 
 
-def test_multiple_kernels():
+@pytest.mark.parametrize("compiler", [[], ["-c", "tensora"], ["-c", "taco"]])
+def test_multiple_kernels(compiler):
     result = runner.invoke(
-        app, ["y(i) = A(i,j) * x(j)", "-t", "compute", "-t", "evaluate", "-t", "assemble"]
+        app,
+        ["y(i) = A(i,j) * x(j)", "-t", "compute", "-t", "evaluate", "-t", "assemble"] + compiler,
     )
 
     assert result.exit_code == 0
@@ -42,3 +45,26 @@ def test_write_to_file():
         assert result.stdout == ""
 
         assert Path(f.name).read_text().startswith("int32_t compute(taco_tensor_t* restrict y,")
+
+
+@pytest.mark.parametrize("compiler", ["tensora", "taco"])
+@pytest.mark.parametrize(
+    "command",
+    [
+        ["a(i) = b(i) +"],
+        ["y(i) = A(i,j) * x(j)", "-f=ds"],
+        ["y(i) = A(i,j) * x(j)", "-f=A:d1s2"],
+        ["y(i) = A(i,j) * x(j)", "-f=A:ds", "-f=A:dd"],
+        ["y(i) = A(i,j) * x(j)", "-f=A:d"],
+        ["y(i) = A(i,j) * x(j)", "-f=B:ds"],
+        ["a(i) = A(i,i)"],
+        ["A(i,j) = B(i,j) + C(j,i)", "-f=A:ds", "-f=B:ds", "-f=C:ds"],
+    ],
+)
+def test_bad_input(compiler, command):
+    if command[0] == "a(i) = A(i,i)" and compiler == "taco":
+        pytest.xfail("Taco CLI succeeds but generates invalid code")
+
+    result = runner.invoke(app, command + ["-c", compiler], catch_exceptions=False)
+
+    assert result.exit_code == 1
diff --git a/tests/test_expression.py b/tests/test_expression.py
index e77a4c6..1e4bd4c 100644
--- a/tests/test_expression.py
+++ b/tests/test_expression.py
@@ -1,7 +1,7 @@
 import pytest
 
 from tensora.expression import (
-    InconsistentVariableSizeError,
+    InconsistentDimensionsError,
     MutatingAssignmentError,
     parse_assignment,
 )
@@ -71,7 +71,7 @@ def test_mutating_assignment():
     ],
 )
 def test_inconsistent_variable_size(assignment):
-    assert isinstance(parse_assignment(assignment).failure(), InconsistentVariableSizeError)
+    assert isinstance(parse_assignment(assignment).failure(), InconsistentDimensionsError)
 
 
 def parse(string):
diff --git a/tests/test_format.py b/tests/test_format.py
index ff24990..691dd1e 100644
--- a/tests/test_format.py
+++ b/tests/test_format.py
@@ -2,7 +2,7 @@ import pytest
 from returns.result import Failure
 
 from tensora import Format, Mode
-from tensora.format import InvalidModeOrderingError, parse_format
+from tensora.format import InvalidModeOrderingError, parse_format, parse_named_format
 
 format_strings = [
     ("", Format((), ())),
@@ -27,12 +27,28 @@ def test_deparse_format(string, format):
     assert actual == string
 
 
-@pytest.mark.parametrize("string", ["df", "1d0s", "d0s", "d0s1s1", "d1s2s3"])
+@pytest.mark.parametrize("string", ["df", "1d0s", "d0s", "d0s1s1", "d1s2s3", "d3d1d2"])
 def test_parse_bad_format(string):
     actual = parse_format(string)
     assert isinstance(actual, Failure)
 
 
+def test_parse_named_format():
+    actual = parse_named_format("A:d1s0s2").unwrap()
+    assert actual == ("A", Format((Mode.dense, Mode.compressed, Mode.compressed), (1, 0, 2)))
+
+
+def test_parse_bad_named_format():
+    actual = parse_named_format("d1s0s2s3")
+    assert isinstance(actual, Failure)
+
+
+@pytest.mark.parametrize("string", ["A:d0s", "A:d3d1d2"])
+def test_parse_bad_ordering_in_named_format(string):
+    actual = parse_named_format(string)
+    assert isinstance(actual, Failure)
+
+
 def test_format_attributes():
     format = Format((Mode.dense, Mode.compressed), (1, 0))
 
diff --git a/tests/test_function.py b/tests/test_function.py
index 25b0a9d..8cd9e00 100644
--- a/tests/test_function.py
+++ b/tests/test_function.py
@@ -3,8 +3,9 @@ from random import randrange
 
 import pytest
 
-from tensora import Tensor, TensorCompiler, evaluate_taco, evaluate_tensora, tensor_method
+from tensora import Tensor, evaluate_taco, evaluate_tensora, tensor_method
 from tensora.desugar import DiagonalAccessError, NoKernelFoundError
+from tensora.generate import TensorCompiler
 
 pytestmark = pytest.mark.parametrize(
     ("evaluate", "compiler"),
@@ -133,7 +134,7 @@ def test_diagonal_error(evaluate, compiler):
 
 def test_no_solution(evaluate, compiler):
     if compiler == TensorCompiler.taco:
-        pytest.skip("Taco currently segfaults on this")
+        pytest.xfail("Taco currently segfaults on this")
 
     with pytest.raises(NoKernelFoundError):
         tensor_method("A(i,j) = B(i,j) + C(j,i)", dict(B="ds", C="ds"), "ds", compiler)