Typed-Group Variants
Typed-Group Variants
Tier 0–1 Typed: each foundation example has a sibling in
examples/typed/that replaces@L.paramswith the typed-group API:L.Group,@L.config,L.slot,L.bind,L.chosen, andL.ref. The model topology is identical; only the schema declaration changes.
All source files live under sources/laco/examples/typed/.
Why typed groups?
@L.params is a flat interpolation namespace. It works well for scalar hyperparameters but cannot express swappable sub-configs (e.g. "this field can be one of several optimizer variants"). The typed-group API adds:
L.Group[T]: an enumeration of named config variants, all of typeT@L.config: a structured dataclass schema with real types visible to static analyzersL.slot(Group): a field that holds a chosen group variantL.Defaults(...): declares which variant is selected by defaultL.bind(field, variant): pairs a schema slot with a specific group memberL.chosen(Group): references whichever variant the defaults/caller selectedL.ref("${path}"): interpolation reference to a schema field (replaces directhps.attraccess)
1. typed/mlp.py: Swappable activations with L.Group
Rewrites the @L.params MLP using a typed activation group so the caller can switch between ReLU, GELU, and SiLU with a single override.
Full source
import laco.language as L
from torch import nn
class ActivationGroup(L.Group[nn.Module]):
"""Swappable activation functions."""
relu = L.call(nn.ReLU)()
gelu = L.call(nn.GELU)()
silu = L.call(nn.SiLU)()
@L.config
class MLPSchema:
"""Typed schema for the MLP example."""
dim_in: int = 128
dim_out: int = 128
dim_hidden: int = 256
num_layers: int = 3
activation: nn.Module = L.slot(ActivationGroup)
schema = MLPSchema
defaults = L.Defaults(
L.self_,
L.bind(MLPSchema.activation, ActivationGroup.relu),
)
model = L.call(nn.Sequential, root=True)(
L.OrderedDict(
(
"input",
L.call(nn.Sequential)(
L.call(nn.Linear)(
in_features=L.ref("${schema.dim_in}"),
out_features=L.ref("${schema.dim_hidden}"),
),
L.chosen(ActivationGroup),
),
),
(
"hidden",
L.call(nn.Sequential, expand_args=True)(
L.repeat(
L.ref("${schema.num_layers}"),
L.call(nn.Sequential)(
L.call(nn.Linear)(
in_features=L.ref("${schema.dim_hidden}"),
out_features=L.ref("${schema.dim_hidden}"),
),
L.chosen(ActivationGroup),
),
),
),
),
(
"output",
L.call(nn.Sequential)(
L.call(nn.Linear)(
in_features=L.ref("${schema.dim_hidden}"),
out_features=L.ref("${schema.dim_out}"),
)
),
),
)
)
Annotated walkthrough
class ActivationGroup(L.Group[nn.Module])
Each class attribute is a named variant. The type parameter [nn.Module] constrains every member to be instantiable as an nn.Module. Adding a new activation is one line; the override key is the attribute name.
@L.config class MLPSchema
Declares a structured config dataclass. Static analyzers see MLPSchema.dim_in: int, MLPSchema.activation: nn.Module. The L.slot(ActivationGroup) default tells laco this field must be populated from ActivationGroup.
defaults = L.Defaults(L.self_, L.bind(MLPSchema.activation, ActivationGroup.relu))L.Defaults is the laco defaults list. L.self_ means "this config file is the primary config". L.bind(field, variant) selects the default group member for a slot.
L.chosen(ActivationGroup)
A reference that resolves to whichever variant is currently selected. Used inline in the model body wherever the activation should appear.
L.ref("${schema.dim_in}")
Interpolation reference to the typed schema. In the @L.params variant these were bare hps.dim_in attribute accesses; here the schema lives at the schema key and must be referenced via an interpolation string.
Side-by-side: @L.params vs typed group
| Aspect | @L.params (mlp.py) | @L.config + L.Group (typed/mlp.py) |
|---|---|---|
| Schema declaration | @L.params class hps | @L.config class MLPSchema |
| Field access in model | hps.dim_in | L.ref("${schema.dim_in}") |
| Activation field type | activation: type[nn.Module] = nn.ReLU | activation: nn.Module = L.slot(ActivationGroup) |
| Swappable variants | No, must override the class path | Yes, named members in ActivationGroup |
| Static type of field | type[nn.Module] (class, not instance) | nn.Module (typed as the final instance) |
| Override syntax | hps.activation=torch.nn.GELU | activation=gelu |
Load and override
import laco
# Default (ReLU)
cfg = laco.load("configs://examples/typed/mlp.py#model")
model = laco.instantiate(cfg)
# Switch to GELU
cfg = laco.load(
"configs://examples/typed/mlp.py",
"activation=gelu",
)
model = laco.instantiate(cfg.model)
# Change dimensions
cfg = laco.load(
"configs://examples/typed/mlp.py?schema.dim_in=64&schema.dim_hidden=512#model"
)
What this demonstrates
L.Group[T]: named variant enumeration with a shared type bound@L.config: structured dataclass schema with real type annotationsL.slot(Group): schema field populated from a groupL.Defaults+L.bind: default group selectionL.chosen(Group): inline reference to the currently selected variantL.ref("${path}"): typed interpolation to schema fields
2. typed/linear_regression.py: Swappable optimizers
Rewrites the linear regression example with an OptimGroup so the caller can switch between SGD and Adam at config time.
Full source
import laco.language as L
from torch import nn, optim
class OptimGroup(L.Group[optim.Optimizer]):
"""Swappable optimizer variants."""
sgd = L.partial(optim.SGD)(lr=1e-2, momentum=0.9)
adam = L.partial(optim.Adam)(lr=1e-3)
@L.config
class LinearRegressionSchema:
"""Typed schema for the linear-regression example."""
in_features: int = 8
out_features: int = 1
bias: bool = True
optimizer: optim.Optimizer = L.slot(OptimGroup)
schema = LinearRegressionSchema
defaults = L.Defaults(
L.self_,
L.bind(LinearRegressionSchema.optimizer, OptimGroup.sgd),
)
model = L.call(nn.Linear, root=True)(
in_features=L.ref("${schema.in_features}"),
out_features=L.ref("${schema.out_features}"),
bias=L.ref("${schema.bias}"),
)
optimizer = L.chosen(OptimGroup)
Annotated walkthrough
OptimGroup(L.Group[optim.Optimizer])
Each member uses L.partial (not L.call) because optimizers are deferred factories. The learning rate and other hyperparameters are baked in per-variant; individual hps can still be overridden per-key.
L.slot(OptimGroup) in the schema
Declares that optimizer is a slot populated from OptimGroup. The static type annotation optimizer: optim.Optimizer is accurate: after instantiation it will be a partial that, when called with model.parameters(), returns an Optimizer.
optimizer = L.chosen(OptimGroup) (module-level)
Unlike the @L.params variant where optimizer = L.partial(optim.SGD)(...) is hardcoded, this resolves to whichever optimizer the caller selects. The optimizer variable is exported in __all__ and can be loaded as laco.load("...#optimizer").
L.ref("${schema.in_features}") in the model
The model body does not reference hps.* directly; it uses L.ref interpolation into the typed schema. This is the key difference from @L.params: the schema is a separate structured node, not an ambient namespace.
Load and override
import laco
# Default (SGD)
cfg = laco.load("configs://examples/typed/linear_regression.py")
model = laco.instantiate(cfg.model)
opt_factory = laco.instantiate(cfg.optimizer)
opt = opt_factory(model.parameters())
# Switch to Adam
cfg = laco.load(
"configs://examples/typed/linear_regression.py",
"optimizer=adam",
)
# Override Adam's learning rate
cfg = laco.load(
"configs://examples/typed/linear_regression.py",
"optimizer=adam",
"optimizer.lr=5e-4",
)
What this demonstrates
L.Group[optim.Optimizer]withL.partialmembers: swappable deferred factories- Schema slot typed as
optim.Optimizerfor correct static analysis - Module-level
optimizer = L.chosen(OptimGroup): the exported optimizer node resolves dynamically - Per-variant hyperparameter overrides (
optimizer.lr=5e-4after selectingadam)
3. typed/text_classifier.py: @L.config with L.required
Rewrites the text classifier with a @L.config schema that preserves the L.required[int]() mandatory sentinel, demonstrating that required fields work identically in both APIs, but with better static typing in the typed variant.
Full source
import laco.language as L
from laco.examples.layers.mean_pool import MeanPool
from torch import nn
@L.config
class TextClassifierSchema:
"""Typed schema; ``vocab_size`` is required (no default)."""
vocab_size: int = L.required[int]()
embed_dim: int = 64
num_classes: int = 2
padding_idx: int | None = 0
schema = TextClassifierSchema
model = L.call(nn.Sequential, root=True)(
L.OrderedDict(
(
"embed",
L.call(nn.Embedding)(
num_embeddings=L.ref("${schema.vocab_size}"),
embedding_dim=L.ref("${schema.embed_dim}"),
padding_idx=L.ref("${schema.padding_idx}"),
),
),
("pool", L.call(MeanPool)()),
(
"head",
L.call(nn.Linear)(
in_features=L.ref("${schema.embed_dim}"),
out_features=L.ref("${schema.num_classes}"),
),
),
)
)
Annotated walkthrough
@L.config class TextClassifierSchema
A structured dataclass schema. @L.config accepts L.required[T]() as a field default: the type parameter flows through to the dataclass annotation so pyright reports vocab_size: int (not vocab_size: Any as with a plain sentinel).
vocab_size: int = L.required[int]()
Identical semantics to the @L.params variant: laco raises MissingMandatoryValue if the field is not overridden before instantiation. The difference is static visibility: the @L.config form makes the field type explicit to type checkers without extra stubs.
L.ref("${schema.vocab_size}")
All model references go through the typed schema. There is no hps.* namespace; the schema node is exported at module level as schema = TextClassifierSchema and referenced via "${schema.*}" interpolation strings.
No L.Group or L.Defaults
This example has no swappable variants, so there is no defaults list. @L.config alone (without L.Group) is appropriate when the goal is structured typing of a flat schema rather than variant selection.
Load with the required field supplied
import laco
# Via query-string
cfg = laco.load(
"configs://examples/typed/text_classifier.py?schema.vocab_size=1000#model"
)
model = laco.instantiate(cfg)
# Via positional override
cfg = laco.load(
"configs://examples/typed/text_classifier.py",
"schema.vocab_size=30000",
"schema.embed_dim=256",
)
model = laco.instantiate(cfg.model)
Note: the override key is schema.vocab_size (not hps.vocab_size) because the schema is declared with @L.config and exported as schema.
What this demonstrates
@L.config+L.required[T](): mandatory field with accurate static type annotation- Schema-based field access with
L.ref("${schema.*}")instead ofhps.* - The
@L.configform gives pyright/mypy the correct field type without extra stubs
Comparison: @L.params vs Typed groups
| Property | @L.params | @L.config + L.Group |
|---|---|---|
| Schema type | Interpolation namespace | Structured dataclass |
| Swappable variants | No (must override class path) | Yes, named L.Group members |
| Static type of fields | Inferred from default value | Declared annotation, accurate for required fields |
| Pyright sees required field as | Any (sentinel default) | int (type parameter of L.required[int]()) |
| Override key for activation | hps.activation=torch.nn.GELU | activation=gelu |
| Override key for schema field | hps.vocab_size=1000 | schema.vocab_size=1000 |
| Interpolation in model body | hps.dim_in (direct attribute) | L.ref("${schema.dim_in}") |
| When to use | Flat scalar hps, no variants needed | Variants, structured typing, or shared schemas |