EXAMPLES

Typed-Group Variants

Typed-Group Variants

Tier 0–1 Typed: each foundation example has a sibling in examples/typed/ that replaces @L.params with the typed-group API: L.Group, @L.config, L.slot, L.bind, L.chosen, and L.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 type T
  • @L.config: a structured dataclass schema with real types visible to static analyzers
  • L.slot(Group): a field that holds a chosen group variant
  • L.Defaults(...): declares which variant is selected by default
  • L.bind(field, variant): pairs a schema slot with a specific group member
  • L.chosen(Group): references whichever variant the defaults/caller selected
  • L.ref("${path}"): interpolation reference to a schema field (replaces direct hps.attr access)

1. typed/mlp.py: Swappable activations with L.Group

Source: sources/laco/examples/typed/mlp.py

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 modelhps.dim_inL.ref("${schema.dim_in}")
Activation field typeactivation: type[nn.Module] = nn.ReLUactivation: nn.Module = L.slot(ActivationGroup)
Swappable variantsNo, must override the class pathYes, named members in ActivationGroup
Static type of fieldtype[nn.Module] (class, not instance)nn.Module (typed as the final instance)
Override syntaxhps.activation=torch.nn.GELUactivation=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 annotations
  • L.slot(Group): schema field populated from a group
  • L.Defaults + L.bind: default group selection
  • L.chosen(Group): inline reference to the currently selected variant
  • L.ref("${path}"): typed interpolation to schema fields

2. typed/linear_regression.py: Swappable optimizers

Source: sources/laco/examples/typed/linear_regression.py

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] with L.partial members: swappable deferred factories
  • Schema slot typed as optim.Optimizer for correct static analysis
  • Module-level optimizer = L.chosen(OptimGroup): the exported optimizer node resolves dynamically
  • Per-variant hyperparameter overrides (optimizer.lr=5e-4 after selecting adam)

3. typed/text_classifier.py: @L.config with L.required

Source: sources/laco/examples/typed/text_classifier.py

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 of hps.*
  • The @L.config form gives pyright/mypy the correct field type without extra stubs

Comparison: @L.params vs Typed groups

Property@L.params@L.config + L.Group
Schema typeInterpolation namespaceStructured dataclass
Swappable variantsNo (must override class path)Yes, named L.Group members
Static type of fieldsInferred from default valueDeclared annotation, accurate for required fields
Pyright sees required field asAny (sentinel default)int (type parameter of L.required[int]())
Override key for activationhps.activation=torch.nn.GELUactivation=gelu
Override key for schema fieldhps.vocab_size=1000schema.vocab_size=1000
Interpolation in model bodyhps.dim_in (direct attribute)L.ref("${schema.dim_in}")
When to useFlat scalar hps, no variants neededVariants, structured typing, or shared schemas