Registration Dependencies#

In Registration, we saw that cinnamon pairs a Configuration to its component via a RegistrationKey. Moreover, Configuration instances can nest other Configuration instances to compose more sophisticated ones (see Configuration).

What remains is the question of how to organise code so that cinnamon can find and wire everything together automatically.

Code organisation#

Registration functions (either @classmethod decorators or ad-hoc @register functions) can technically be written anywhere. However, cinnamon’s Registry only scans files inside a folder named configurations. This constraint is intentional: it avoids accidentally executing unrelated code during the registration scan.

The recommended project layout is:

project_folder/
    configurations/
        data_loader.py
    components/
        data_loader.py

A components folder is not required, but pairing component and configuration files by name makes it easy to navigate the project.

For the above example the files would look like:

components/data_loader.py

class DataLoader:

    def __init__(self, folder_name: str):
        self.folder_name = folder_name

    def load(self):
        ...

configurations/data_loader.py

from pathlib import Path
from cinnamon.configuration import Configuration, Param
from cinnamon.registry import Registry, RegistrationKey, register_method

class DataLoaderConfig(Configuration):
    folder_name: str = Param('my_custom_folder', description='folder to load data from')

    @classmethod
    @register_method(name='loader', tags={'default'}, namespace='testing',
                     component='components.DataLoader')
    def default(cls) -> 'DataLoaderConfig':
        return super().default()

Note

Defining a components folder is not mandatory, but it improves readability by allowing users to quickly pair components and configurations.

Resolving dependencies#

Registering and nesting Configuration can quickly lead to dependency ordering problems. The addition of Configuration variants can further complicate this.

To avoid requiring users to manually order registrations, cinnamon builds a dependency graph automatically — independently of the registration order.

Consider the following two nested configurations:

from cinnamon.configuration import Configuration, Param
from cinnamon.registry import RegistrationKey

class NestedChild(Configuration):
    x: int = Param(42)

class ParentConfig(Configuration):
    param_1: bool = Param(True)
    param_2: bool = Param(False)
    child: RegistrationKey = Param(
        RegistrationKey(name='test', tags={'nested'}, namespace='testing')
    )

The two registration functions below produce identical dependency graphs, regardless of the order in which they register the parent and child:

from cinnamon.registry import Registry, register

@register
def custom_registration():
    Registry.register_configuration(
        config=ParentConfig.default(),
        name='test', tags={'parent'}, namespace='testing'
    )
    Registry.register_configuration(
        config=NestedChild.default(),
        name='test', tags={'nested'}, namespace='testing'
    )

@register
def custom_registration():
    # Order reversed — the result is identical
    Registry.register_configuration(
        config=NestedChild.default(),
        name='test', tags={'nested'}, namespace='testing'
    )
    Registry.register_configuration(
        config=ParentConfig.default(),
        name='test', tags={'parent'}, namespace='testing'
    )

Note

The same ordering independence applies to @register_method decorators.

This is possible because the Registry builds a directed acyclic graph (DAG) of dependencies and resolves them bottom-up — children before parents — regardless of the order they were registered.

        flowchart TD
    P["parent<br/>child: RegistrationKey"]
    C["child<br/>x: int = Param(42)"]
    P -- "declares a key" --> C
    C == "resolved first, then handed to the parent" ==> P
    

A field holding a key points down the graph, and resolution walks up it: the child becomes a Configuration instance before the parent that names it is built, so the parent never sees an unresolved key.

Registry.build() is the one call that does all of it:

        flowchart LR
    S["scan<br/>every configurations/ folder"]
    E["execute<br/>@register and @register_method"]
    G["graph<br/>one edge per key-valued field"]
    V["expand<br/>one key per variant combination"]
    R["resolve<br/>children before parents"]
    S --> E --> G --> V --> R
    

Nothing in this sequence imports a component: the binding is a string, and it is imported only when something is actually built.

To trigger registration and resolution, call Registry.build():

from pathlib import Path
from cinnamon.registry import Registry

Registry.build(directory=Path('.'))

This instructs the Registry to scan all configurations folders under the current working directory, execute every @register and @register_method decorator it finds, and then resolve the full dependency graph.

Note

Registry.build() searches recursively — nested configurations folders within subdirectories are also picked up automatically.

Varying a dependency#

A scalar dependency varies like any other field, and the parent gains one key per alternative. What distinguishes those parent keys is worth understanding, because it is derived rather than chosen.

A parent’s variant tags are the child key’s tags, prefixed by the field name. The child’s name and namespace are not part of it.

Most of the time the alternatives are produced by the child itself. A child that declares Param(1, variants=[2]) resolves to a variant key tagged sentences=2, and that tag propagates upward:

name=strategy--tags=['truncate']
name=strategy--tags=['sentences=2', 'truncate']
name=summariser--namespace=tutorial/summarisation
name=summariser--tags=['strategy.sentences=2', 'strategy.truncate']
        flowchart LR
    C1["child<br/>tags=['truncate']"]
    C2["child variant<br/>tags=['sentences=2', 'truncate']"]
    P1["parent<br/>tags=['strategy.truncate']"]
    P2["parent variant<br/>tags=['strategy.sentences=2', 'strategy.truncate']"]
    C1 -- "prefixed by the field name" --> P1
    C2 -- "prefixed by the field name" --> P2
    

strategy.sentences=2 reads as “the strategy field pointing at the child tagged sentences=2”. Every child variant carries a distinct param=value tag, so every parent key is distinct, and the hierarchy stays readable however deep the nesting goes.

The other way to vary a dependency is to name the alternatives yourself:

class TrainerConfig(Configuration):
    loader: RegistrationKey = Param(
        RegistrationKey(name='loader', tags={'csv'}, namespace='data'),
        variants=[
            RegistrationKey(name='loader', tags={'json'}, namespace='data'),
            RegistrationKey(name='loader', tags={'parquet'}, namespace='data'),
        ],
    )

This yields loader.csv, loader.json and loader.parquet — three distinct parent keys — because each alternative carries a distinct tag.

Warning

Alternatives declared this way must carry distinct, non-empty tags. Only tags are inherited, so two alternatives with the same tag set derive the same parent key, and alternatives with no tags — distinguished only by name, as in loader-csv and loader-json — derive the parent’s own key.

Both raise VariantKeyCollisionException during resolution. They were silent until 2.1.2: the second registration was skipped, the second graph edge was a no-op, and the project came out with fewer keys than it declared with nothing to say so. The untagged case also left a self-loop in the dependency graph, which check_registration_graph could not see because it ran before expansion.

Tags are what makes a key addressable, so tagging alternatives is worth doing on its own merits: loader.parquet says what the run used, while a key distinguished only by the child’s name would say nothing.

Depending on many registrations#

A dependency field can hold a list of keys, or a dict of them keyed by string. Use a list when order is what matters — a pipeline of stages, a set of loss terms — and a dict when the members need names:

from cinnamon.configuration import Configuration, Param
from cinnamon.registry import RegistrationKey, Registry

def key(name):
    return RegistrationKey(name=name, namespace='nlp')

class ModelConfig(Configuration):
    losses: list[RegistrationKey] = Param([key('cross_entropy'), key('sparsity')])
    metrics: dict[str, RegistrationKey] = Param({'accuracy': key('accuracy')})

Every member becomes an edge in the dependency graph, so a typo in any one of them is reported by cmn-check rather than surfacing when you try to build.

The component receives the container of keys, exactly as declared. from_keys() builds the whole thing while keeping its shape:

class Model:

    def __init__(self, losses, metrics):
        self.losses = Registry.from_keys(losses)     # list -> list, in order
        self.metrics = Registry.from_keys(metrics)   # dict -> dict, same labels

It accepts a single key too, so a field typed RegistrationKey | list[RegistrationKey] needs no branch, and passes None through so an optional dependency left unset stays unset. It builds eagerly — keep an explicit loop when a child should only be built under some condition.

Only one level of nesting is supported. list[list[RegistrationKey]] and dict[str, list[RegistrationKey]] raise TypeError when the dependency is inspected, with a message saying so.

Varying a container#

A container varies as a whole container. Each variant is a complete replacement for the field’s value, and lists and dicts behave the same way:

class ModelConfig(Configuration):
    losses: list[RegistrationKey] = Param(
        [CE],
        variants=[[CE, SPARSITY], []],          # add one, or drop them all
    )
    metrics: dict[str, RegistrationKey] = Param(
        {'acc': ACCURACY},
        variants=[{'acc': ACCURACY, 'f1': F1}],  # labels vary too
    )

Everything that applies to an ordinary variant applies here. A container variant combines with the other varying fields, so the sweep is still the full product; a member that appears only inside a variant is a dependency like any other, checked and resolved; and a variant identical to the default is rejected, because it changes nothing.

Note

A container does not multiply its members’ variants into the parent, while a scalar dependency does. Three losses with three variants each would otherwise be twenty-seven parent keys from a single field. Those member variants are still registered and usable on their own – they simply do not compose upward. To vary a container, vary the whole thing.

Note

Container variants are tagged by index – losses=variant-1 – because the contents of a list or dict do not reduce to a short, stable label the way a scalar value does. The index follows declaration order, so keys stay the same across runs and machines, but the tag does not tell you what is in the variant. cmn-check prints an Indexed Variants section saying what each one holds.

External dependencies#

Cinnamon is designed to be a community framework. You may need to import configurations and components written by others and build on top of them.

The Registry supports loading registrations from directories outside your own project. You can also define Configuration fields that point to externally registered keys.

For example, suppose a DataLoaderConfig variant depends on an external preprocessor:

from cinnamon.configuration import Configuration, Param
from cinnamon.registry import RegistrationKey, register_method

class DataLoaderConfig(Configuration):
    folder_name: str = Param('my_custom_folder')

    @classmethod
    @register_method(name='loader', tags={'default'}, namespace='testing',
                     component='components.DataLoader')
    def default(cls) -> 'DataLoaderConfig':
        return super().default()

    @classmethod
    @register_method(name='loader', tags={'external'}, namespace='testing',
                     component='components.DataLoader')
    def external_variant(cls) -> 'DataLoaderConfig':
        config = cls()
        # processor is defined in an external project
        config = config.model_copy(update={
            'processor': RegistrationKey(name='processor', namespace='external')
        })
        return config

Note

To use model_copy to add a new field, the field must already be declared on the class. If processor is not declared in DataLoaderConfig, add it as an optional field:

from typing import Optional

class DataLoaderConfig(Configuration):
    folder_name: str = Param('my_custom_folder')
    processor: Optional[RegistrationKey] = Param(None)

To avoid a NamespaceNotFoundException when the external key is resolved, inform the Registry where that namespace was declared by passing external_directories to Registry.build():

Registry.build(
    directory=Path('.'),
    external_directories=[Path('path/to/external/project')]
)

The Registry will scan the external project’s configurations folder, register its keys, and make them available for dependency resolution alongside your own.