Notebook

Tracing

Series: laco tutorial notebooks
Prerequisites: 01.why-laco.ipynb through 05.loading-saving-cli.ipynb (especially 03.lazy-call-and-partial.ipynb, 04.hyperparameters-and-interpolation.ipynb); 06.nested-configs-and-containers.ipynb through 08.pipeline-configs.ipynb helpful but not required
Dependencies: laco only, no torch needed.


So far you've built configs with L.call(...)(**kw): explicitly naming the target class and passing keyword arguments. This notebook introduces an alternative that feels like writing regular Python: the tracing layer.

Instead of:

cfg = L.call(Model)(encoder=L.call(Encoder)(depth=24, width=512), dim=768)

You write:

cfg = L.trace(lambda: Model(encoder=Encoder(depth=24, width=512), dim=768))

The second form reads like an ordinary constructor call. When refactoring depthnum_layers, the trace updates automatically because it calls the real constructor and uses inspect.Signature.bind.

This notebook covers:

  • @L.configurable: dual-use decorator (normal outside trace; records a node inside)
  • L.trace(thunk): the ContextVar-based tracing scope
  • Argument tiers: node / primitive / error
  • Positional argument binding and default filling
  • Nested tracing, round-trip YAML verification
import laco
import laco.language as L
from omegaconf import DictConfig

# --- Making toy classes importable -----------------------------------------
# laco records an *importable* target path (module.Qualname) for every node so
# the config can be dumped to YAML and re-instantiated later. A class defined
# in a notebook cell lives in the ``__main__`` module and is NOT importable,
# so ``laco.dump`` would fail with "Cannot generate path for object ...".
#
# To keep the toy classes below readable while still teaching the real
# round-trip, we register each one into a small, genuinely-importable module.
# The ``@importable`` decorator below does exactly that: it rehomes a
# notebook-defined class into ``nb_tracing_demo`` so ``module.Qualname`` resolves.
import sys
import types

_demo = types.ModuleType("nb_tracing_demo")
sys.modules["nb_tracing_demo"] = _demo


def importable(cls):
    """Rehome a notebook class into an importable module so laco can dump it."""
    cls.__module__ = _demo.__name__
    setattr(_demo, cls.__qualname__, cls)
    return cls

Section 1: Two ways to build the same config

Consider two toy classes: a config node describes constructing a Model that wraps an Encoder.

# Define toy classes (no torch, no heavy deps).
# @importable rehomes them into the nb_tracing_demo module so laco can record
# an importable target path (module.Qualname) — see the imports cell.
@importable
class Encoder:
    def __init__(self, depth: int, width: int = 512):
        self.depth = depth
        self.width = width

@importable
class Model:
    def __init__(self, encoder: Encoder, dim: int = 512):
        self.encoder = encoder
        self.dim = dim

# --- Approach 1: Explicit L.call ---
cfg_explicit = L.call(Model)(
    encoder=L.call(Encoder)(depth=24, width=512),
    dim=768
)

print("=== Approach 1: explicit L.call ===")
print(laco.dump(cfg_explicit))
Output
=== Approach 1: explicit L.call ===
_convert_: all
_laco_: 1
_target_: nb_tracing_demo.Model
dim: 768
encoder: {_convert_: all, _target_: nb_tracing_demo.Encoder, depth: 24, width: 512}

# --- Approach 2: Tracing ---
# Decorate the classes with @L.configurable
TraceEncoder = L.configurable(Encoder)
TraceModel   = L.configurable(Model)

cfg_traced = L.trace(lambda: TraceModel(
    encoder=TraceEncoder(depth=24, width=512),
    dim=768
))

print("=== Approach 2: L.trace ===")
print(laco.dump(cfg_traced))
Output
=== Approach 2: L.trace ===
_convert_: all
_laco_: 1
_target_: nb_tracing_demo.Model
dim: 768
encoder: {_convert_: all, _target_: nb_tracing_demo.Encoder, depth: 24, width: 512}

# Both produce the same YAML:
assert laco.dump(cfg_explicit) == laco.dump(cfg_traced), "Should be identical!"
print("Both approaches produce identical YAML.")

# Both can be instantiated the same way:
model_from_explicit = laco.instantiate(cfg_explicit)
model_from_trace    = laco.instantiate(cfg_traced)
print(f"Instantiated from explicit: {type(model_from_explicit).__name__}, dim={model_from_explicit.dim}")  # noqa: LACO001
print(f"Instantiated from trace   : {type(model_from_trace).__name__}, dim={model_from_trace.dim}")        # noqa: LACO001
Output
Both approaches produce identical YAML.
Instantiated from explicit: Model, dim=768
Instantiated from trace   : Model, dim=768

The refactoring advantage

Imagine you rename Encoder.__init__'s parameter depthnum_layers. With explicit L.call, you must find and update every L.call(Encoder)(depth=...) call in your config files. With tracing, you update the constructor signature and the trace captures the new name automatically, because it actually calls the constructor and uses inspect.Signature.bind.

Section 2: @L.configurable, the dual-use decorator

@L.configurable makes a class or function context-aware: it behaves differently depending on whether it is called inside a tracing scope or not.

@L.configurable
@importable
class SimpleEncoder:
    def __init__(self, depth: int, width: int = 512):
        self.depth = depth
        self.width = width
    def __repr__(self):
        return f"SimpleEncoder(depth={self.depth}, width={self.width})"

# --- OUTSIDE a trace: executes normally, returns a real object ---
enc_outside = SimpleEncoder(depth=8)
print("Outside trace:")
print(f"  type : {type(enc_outside).__name__}")
print(f"  repr : {enc_outside}")
print(f"  depth: {enc_outside.depth}")  # noqa: LACO001
Output
Outside trace:
  type : SimpleEncoder
  repr : SimpleEncoder(depth=8, width=512)
  depth: 8
# --- INSIDE a trace: intercepts the call, returns a DictConfig ---
cfg_inside = L.trace(lambda: SimpleEncoder(depth=8))
print("Inside trace:")
print(f"  type    : {type(cfg_inside).__name__}")
print(f"  depth   : {cfg_inside.depth}")
# noqa: LACO001  -- intentional demo of config key access
print(f"  width   : {cfg_inside.width}")
# noqa: LACO001
print(f"  _target_: {cfg_inside._target_}")
# noqa: LACO001
print()
print(laco.dump(cfg_inside))
Output
Inside trace:
  type    : DictConfig
  depth   : 8
  width   : 512
  _target_: nb_tracing_demo.SimpleEncoder

{_convert_: all, _laco_: 1, _target_: nb_tracing_demo.SimpleEncoder, depth: 8, width: 512}

# Factory form: @L.configurable(strict=False) — for targets with unintrospectable signatures
@L.configurable(strict=False)
class FlexibleLayer:
    def __init__(self, units: int, activation: str = "relu"):
        self.units = units
        self.activation = activation

# Outside trace — works normally:
layer_live = FlexibleLayer(units=64)
print(f"Outside trace: {type(layer_live).__name__}, units={layer_live.units}")  # noqa: LACO001

# Inside trace — records a node:
cfg_layer = L.trace(lambda: FlexibleLayer(units=64, activation="gelu"))
print(f"Inside trace: {type(cfg_layer).__name__}, units={cfg_layer.units}")  # noqa: LACO001
Output
Outside trace: FlexibleLayer, units=64
Inside trace: DictConfig, units=64

Section 3: How tracing works, the _TRACING ContextVar

The mechanism relies on a single primitive: laco uses a contextvars.ContextVar[bool] to signal that code is currently inside a trace. A ContextVar is safe for nested traces and async code because each set()/reset() pair operates on a token, not a global flag.

from laco.language import _TRACING

print("_TRACING type   :", type(_TRACING).__name__)
print("default (outside):", _TRACING.get())  # False outside any trace
Output
_TRACING type   : ContextVar
default (outside): False
# Illustrate the token-based set/reset mechanism:
print("Before trace:", _TRACING.get())

# What L.trace(thunk) does internally:
def demo_trace_mechanism(thunk):
    token = _TRACING.set(True)   # set to True, save token
    try:
        print("  Inside trace:", _TRACING.get())
        result = thunk()
        return result
    finally:
        _TRACING.reset(token)    # always reset, even on exception
        print("  After finally:", _TRACING.get())

demo_trace_mechanism(lambda: None)
print("After trace:", _TRACING.get())
Output
Before trace: False
  Inside trace: True
  After finally: False
After trace: False
# The ContextVar resets even when the thunk raises:
print("Before exception trace:", _TRACING.get())

try:
    L.trace(lambda: (_ for _ in ()).throw(RuntimeError("boom")))
except (RuntimeError, TypeError):
    pass  # expected

print("After exception trace:", _TRACING.get())  # still False — no leak!
Output
Before exception trace: False
After exception trace: False

Step-by-step: what happens inside @L.configurable during a trace

  1. L.trace(thunk) calls _TRACING.set(True) → saves token
  2. thunk() is called
  3. Inside the thunk, SimpleEncoder(depth=8) is called
  4. The @L.configurable wrapper checks _TRACING.get()True
  5. sig.bind(depth=8) + apply_defaults() → fills in width=512
  6. Each argument is classified by tier (see Section 4)
  7. L.call(SimpleEncoder)(depth=8, width=512)DictConfig node returned
  8. The thunk returns the DictConfig (not a live SimpleEncoder!)
  9. _TRACING.reset(token): scope closed, _TRACING is False again

Section 4: The three argument tiers

When @L.configurable intercepts a call inside a trace, it must classify each argument value. There are exactly three tiers:

@L.configurable
@importable
class TieredContainer:
    def __init__(self, node_arg, int_arg: int, str_arg: str):
        pass

# --- Tier 1: DictConfig / ListConfig — spliced in as-is ---
existing_node = L.call(SimpleEncoder)(depth=4, width=128)
print("Tier 1: existing_node type:", type(existing_node).__name__)

cfg = L.trace(lambda: TieredContainer(node_arg=existing_node, int_arg=42, str_arg="hello"))
print("Result type:", type(cfg).__name__)
print("node_arg type inside cfg:", type(cfg.node_arg).__name__)  # noqa: LACO001  DictConfig
print("int_arg  inside cfg:", cfg.int_arg)    # noqa: LACO001
print("str_arg  inside cfg:", cfg.str_arg)    # noqa: LACO001
Output
Tier 1: existing_node type: DictConfig
Result type: DictConfig
node_arg type inside cfg: DictConfig
int_arg  inside cfg: 42
str_arg  inside cfg: hello
# --- Tier 2: OmegaConf primitives — int, float, str, bool, None ---
@L.configurable
@importable
class PrimitiveHolder:
    def __init__(self, i: int, f: float, s: str, b: bool, n):
        pass

cfg_prim = L.trace(lambda: PrimitiveHolder(i=7, f=3.14, s="hi", b=True, n=None))
print("Primitive tier demo:")
print(f"  i={cfg_prim.i}, f={cfg_prim.f}, s={cfg_prim.s!r}, b={cfg_prim.b}, n={cfg_prim.n}")  # noqa: LACO001
Output
Primitive tier demo:
  i=7, f=3.14, s='hi', b=True, n=None
# --- Tier 3: arbitrary Python object — raises TypeError ---
@L.configurable
@importable
class WrapperClass:
    def __init__(self, inner):
        pass

live_encoder = Encoder(depth=1)  # real object, NOT a DictConfig

try:
    L.trace(lambda: WrapperClass(inner=live_encoder))
except TypeError as e:
    print("TypeError (Tier 3):")
    # Trim to first 200 chars for readability
    print(str(e)[:200])
Output
TypeError (Tier 3):
L.configurable: argument value <nb_tracing_demo.Encoder object at 0x7ffeb2e94690> (type 'Encoder') is not a config node or OmegaConf primitive and cannot be recorded in a trace. Decorate its callable 

Tier summary

TierConditionOutcome
1isinstance(value, DictConfig | ListConfig)Spliced into the node as-is
2isinstance(value, int | float | str | bool | None | MISSING)Kept as a plain value
3Anything else (live Python object)TypeError: "not a config node or OmegaConf primitive"

The fix for Tier 3: decorate the argument's callable with @L.configurable, or build it explicitly with L.call(...)(...).

Section 5: Positional argument binding and default filling

One of the nicest features of tracing: you can pass positional arguments exactly as you would in regular Python. The @L.configurable wrapper uses inspect.Signature.bind to map them to their parameter names, then apply_defaults() to fill in any omitted defaults.

@L.configurable
@importable
class DenseLayer:
    def __init__(self, in_features: int, out_features: int, bias: bool = True):
        pass

# Pass positional args — no keyword names needed:
cfg_pos = L.trace(lambda: DenseLayer(128, 64))

print("Positional args bound to names:")
print(f"  in_features  = {cfg_pos.in_features}")   # noqa: LACO001  128
print(f"  out_features = {cfg_pos.out_features}")  # noqa: LACO001  64
print(f"  bias         = {cfg_pos.bias}")           # noqa: LACO001  True (filled by apply_defaults)
Output
Positional args bound to names:
  in_features  = 128
  out_features = 64
  bias         = True
# apply_defaults() means omitted defaults APPEAR in the node.
# This is important for reproducibility: the saved YAML is fully self-contained.

@L.configurable
@importable
class ConvLayer:
    def __init__(self, in_ch: int, out_ch: int, kernel: int = 3, stride: int = 1, padding: int = 1):
        pass

# Only specify the required args:
cfg_conv = L.trace(lambda: ConvLayer(16, 32))

print("DictConfig with all defaults filled in:")
print(laco.dump(cfg_conv))
Output
DictConfig with all defaults filled in:
{_convert_: all, _laco_: 1, _target_: nb_tracing_demo.ConvLayer, in_ch: 16, kernel: 3,
  out_ch: 32, padding: 1, stride: 1}

Why are defaults included? Because a config node is meant to be a complete, reproducible description of how to build an object. If bias=True is included, anyone loading the YAML and calling laco.instantiate will get exactly the same result, even if the class's default changes in a future version of the library.

Section 6: Nested tracing

Tracing handles nested constructors naturally. Inner @L.configurable calls produce DictConfig nodes (Tier 1), which are spliced into the outer node.

@L.configurable
@importable
class InnerEncoder:
    def __init__(self, depth: int, width: int = 256): pass

@L.configurable
@importable
class OuterModel:
    def __init__(self, encoder: InnerEncoder, dim: int = 256): pass

cfg_nested = L.trace(lambda: OuterModel(
    encoder=InnerEncoder(depth=8, width=512),
    dim=1024
))

print("Root type  :", type(cfg_nested).__name__)         # DictConfig
print("encoder type:", type(cfg_nested.encoder).__name__) # noqa: LACO001  DictConfig
print("encoder.depth:", cfg_nested.encoder.depth)         # noqa: LACO001  8
print("encoder.width:", cfg_nested.encoder.width)         # noqa: LACO001  512
print("dim          :", cfg_nested.dim)                    # noqa: LACO001  1024
print()
print("Full YAML:")
print(laco.dump(cfg_nested))
Output
Root type  : DictConfig
encoder type: DictConfig
encoder.depth: 8
encoder.width: 512
dim          : 1024

Full YAML:
_convert_: all
_laco_: 1
_target_: nb_tracing_demo.OuterModel
dim: 1024
encoder: {_convert_: all, _target_: nb_tracing_demo.InnerEncoder, depth: 8, width: 512}

# The nested config can be instantiated. These classes have real bodies (they
# store their attributes) and are @importable so instantiate can resolve them:
@importable
class InnerEncoderReal:
    def __init__(self, depth, width=256):
        self.depth = depth
        self.width = width

@importable
class OuterModelReal:
    def __init__(self, encoder, dim=256):
        self.encoder = encoder
        self.dim = dim

# Re-create the trace pointing to the real classes:
CfgEncoder = L.configurable(InnerEncoderReal)
CfgModel   = L.configurable(OuterModelReal)
cfg_rt = L.trace(lambda: CfgModel(encoder=CfgEncoder(depth=4, width=64), dim=128))

obj = laco.instantiate(cfg_rt)
print(f"Instantiated: {type(obj).__name__}, dim={obj.dim}")              # noqa: LACO001
print(f"  encoder   : {type(obj.encoder).__name__}, depth={obj.encoder.depth}")  # noqa: LACO001
Output
Instantiated: OuterModelReal, dim=128
  encoder   : InnerEncoderReal, depth=4

Section 7: Mixing trace and explicit L.call

Tracing and explicit L.call compose freely. An undecorated class inside a thunk can use L.call(...) to produce a node that satisfies the Tier-1 check.

# UndecoratedModel is NOT @L.configurable.
# We use L.call(...) explicitly inside the thunk. It still needs an importable
# target path, so we mark it @importable (but deliberately not @L.configurable):
@importable
class UndecoratedModel:
    def __init__(self, encoder, scale: float = 1.0): pass

@L.configurable
@importable
class DecoEncoder:
    def __init__(self, depth: int): pass

cfg_mixed = L.trace(
    lambda: L.call(UndecoratedModel)(
        encoder=DecoEncoder(depth=4),   # @L.configurable → DictConfig (Tier 1)
        scale=0.5                        # float → Tier 2 primitive
    )
)

print("Mixed trace result:")
print(laco.dump(cfg_mixed))
Output
Mixed trace result:
_convert_: all
_laco_: 1
_target_: nb_tracing_demo.UndecoratedModel
encoder: {_convert_: all, _target_: nb_tracing_demo.DecoEncoder, depth: 4}
scale: 0.5

# What happens if the root is undecorated and not wrapped in L.call?
class RootUndecorated:
    pass

try:
    L.trace(lambda: RootUndecorated())
except TypeError as e:
    print("TypeError (undecorated root):")
    print(str(e)[:200])
Output
TypeError (undecorated root):
L.trace: root is not a config node — the thunk returned <__main__.RootUndecorated object at 0x7ffebd79c2f0> (type 'RootUndecorated'). The root callable must be decorated with @L.configurable or use L.
# With strict=True the error message is even clearer:
try:
    L.trace(lambda: RootUndecorated(), strict=True)
except TypeError as e:
    print("TypeError (strict=True):")
    print(str(e)[:200])
Output
TypeError (strict=True):
L.trace: root is not configurable — the thunk returned <__main__.RootUndecorated object at 0x7ffebc2b6990> (type 'RootUndecorated'). The root callable must be decorated with @L.configurable or use L.c

Section 8: Round-trip verification

A traced config is a proper DictConfig and can be serialized to YAML, reloaded, and instantiated, completing the full lifecycle.

import pathlib
import tempfile

@L.configurable
@importable
class SimpleNet:
    def __init__(self, layers: int = 3, hidden: int = 256):
        self.layers = layers
        self.hidden = hidden

# Step 1: capture a config via tracing
cfg_original = L.trace(lambda: SimpleNet(layers=5, hidden=128))

print("=== Captured config ===")
yaml_str = laco.dump(cfg_original)
print(yaml_str)
Output
=== Captured config ===
{_convert_: all, _laco_: 1, _target_: nb_tracing_demo.SimpleNet, hidden: 128, layers: 5}

# Step 2: save to a temp file
with tempfile.NamedTemporaryFile(suffix=".yaml", mode="w", delete=False) as fh:
    fh.write(yaml_str)
    tmp_path = pathlib.Path(fh.name)

print(f"Saved to: {tmp_path}")

# Step 3: reload
cfg_reloaded = laco.load(tmp_path)

# Step 4: verify equality
assert laco.dump(cfg_original) == laco.dump(cfg_reloaded)
print("Round-trip YAML is identical.")
Output
Saved to: /tmp/nix-shell.xMBXyU/tmpw1gvl0h_.yaml
Round-trip YAML is identical.
# Step 5: instantiate from the reloaded config
obj = laco.instantiate(cfg_reloaded)
print(f"Type    : {type(obj).__name__}")  # SimpleNet
print(f"layers  : {obj.layers}")           # noqa: LACO001  5
print(f"hidden  : {obj.hidden}")           # noqa: LACO001  128

# Cleanup
tmp_path.unlink()
Output
Type    : SimpleNet
layers  : 5
hidden  : 128

Section 9: Trace flow, normal vs. tracing scope

Outside a trace, @L.configurable is a no-op: fn(*args, **kwargs) runs normally and returns a real object. Inside L.trace(...), the same call is intercepted by the wrapper (sig.bindapply_defaults → classify each argument by tier → L.call(fn)(**node_kw)), which returns a DictConfig instead — see the step-by-step breakdown in Section 3.

Section 10: When to use tracing vs explicit L.call

Both approaches produce identical configs. The choice depends on your workflow:

SituationUseWhy
Code you also run directly (research, scripts)@L.configurable + L.traceNormal Python outside trace; IDE-friendly; refactor-safe
Authoring config-first library codeL.call explicitlyExplicit, no decorator burden on user classes
Need full YAML round-trip safety for all objectsL.call explicitlyAll fields typed by explicit kwarg list
Renaming constructor parameters often (research)@L.configurable + L.tracesig.bind() updates name automatically
Wrapping third-party classes you can't modifyL.call explicitlyNo need to wrap the class; just use L.call(ExternalCls)
Deeply nested constructor calls@L.configurable + L.traceReads like plain Python; nesting is invisible

Recap

ConceptKey fact
@L.configurableDual-use: normal outside trace; emits DictConfig inside trace
L.trace(thunk)Sets _TRACING=True via ContextVar; calls thunk; resets token in finally
_TRACINGContextVar[bool]: safe for nesting and async; never leaks
Tier 1 (DictConfig)Spliced as-is: enables nested @L.configurable calls
Tier 2 (primitives)int, float, str, bool, None: kept as plain values
Tier 3 (live object)TypeError; must decorate with @L.configurable or use L.call
apply_defaults()All constructor defaults appear in the node: self-contained YAML
Round-tripTraced config serializes to YAML and reloads without loss

Next: 11.production-patterns.ipynb covers production patterns: reproducibility, LACO_STRICT_NODES, the migrate_target hook, laco.compat, and a full production config example.