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.