Skip to content
SR-Forge

Configuration Indirection: _target and ${ref:...}

This page is a focused tour of the two runtime-indirection mechanisms that compose every SR-Forge experiment: _target dispatch (turn a YAML string into a Python class) and ${ref:...} references (point one config block at another). For the wider configuration system walk-through see Configuration; for binding model inputs / outputs see IO Binding.

The mechanisms are intentionally string-based at the YAML layer so the schema stays declarative and editable without code. The cost is that static-analysis tools (linters, IDE go-to-definition, code-graph indexers) cannot follow _target: "..." into the class definition — the connection only exists once ConfigResolver runs.

To make these indirection sites discoverable from the codebase itself, SR-Forge exposes them as first-class types you can import:

Concept Canonical type Module
_target dispatch TargetSpec srforge.config.spec
${ref:...} reference Reference srforge.config.spec
Static audit of a YAML audit_config srforge.config.audit

ConfigResolver itself simply delegates to these primitives — both the runtime dispatch and the (separate) static audit tool share the same parser.


_target

A YAML block with a _target key tells ConfigResolver to instantiate a class. The string value is either a fully-qualified Python path or a short name registered in ClassRegistry.

model:
  _target: srforge.models.MISR.HighResNet      # fully-qualified
  params:
    in_channels: 1
    scale: 3
  io:
    inputs:  {lrs: lrs, alphas: alphas}
    outputs: sr

At resolve time the framework does importlib.import_module(module).getattr(class_name) and calls the class with params. The io block (when present) is applied post-construction via IOModule.set_io.

TargetSpec is the parsed-but-not-yet-instantiated view of that block:

from srforge.config.spec import TargetSpec

spec = TargetSpec.from_node(model_yaml_node)
spec.target            # "srforge.models.MISR.HighResNet"
spec.params            # {"in_channels": 1, "scale": 3}
spec.io                # {"inputs": {...}, "outputs": "sr"}

# Static lookup — no instantiation:
module, class_name = spec.resolve_class()
# ("srforge.models.MISR", "HighResNet")

# Or pull the actual class object:
cls = spec.import_class()

This is the same lookup ConfigResolver._instantiate_target performs — splitting the work into a dataclass means inspection code (and static analyzers) can follow the indirection without pulling in the rest of the resolver pipeline.


${ref:...}

A ${ref:path} interpolation tells ConfigResolver to reuse an already-resolved object from elsewhere in the same config tree. Optional zero-argument method chains are allowed.

optimizer:
  _target: torch.optim.Adam
  params:
    params: ${ref:model}.trainable_params()    # reuse model, call method
    lr: 1e-3

lr_scheduler:
  _target: torch.optim.lr_scheduler.StepLR
  params:
    optimizer: ${ref:optimizer}                # reuse optimizer

${ref:foo} is an OmegaConf custom resolver registered by SR-Forge that emits an internal %{foo} marker; ConfigResolver._resolve_reference_string recognises both forms.

Reference is the parsed-but-not-yet-resolved view of that marker:

from srforge.config.spec import Reference

Reference.parse("%{model}")
# Reference(path="model", methods=())

Reference.parse("%{model}.trainable_params()")
# Reference(path="model", methods=("trainable_params",))

The path is a dotted config-tree path (a.b.c or a.b[0].c — bracket notation is normalised at lookup time). Methods are restricted to zero-argument calls; argument-passing inside a reference is intentionally not supported.


The static graph: ConfigGraph

srforge.config.spec.ConfigGraph is the internal data structure that powers the audit. Every ConfigResolver builds one in its __init__ (so the audit runs on every script startup as part of init()'s pre-flight check), then queries it lazily as each sub-config is resolved.

Each node in the graph carries:

Field Meaning
path Dotted config path (e.g. loss.params.bands.params.b)
kind target / reference / mapping / sequence / primitive
spec The TargetSpec (for target nodes) or Reference (for reference nodes), None otherwise
children Paths of child nodes in the dependency sense — only the params subtree of a target is followed; io and _target aren't graph children

Build it directly (rare — normally you let ConfigResolver do it):

from srforge.config.spec import ConfigGraph
from omegaconf import OmegaConf

cfg = OmegaConf.load("configs/train-cfg.yaml")
graph = ConfigGraph.from_config(cfg)
edges = graph.to_edges()   # list[IndirectionEdge]

from_config raises ValueError if a ${ref:...} points at a non-existent path or if the references form a cycle. Both checks are purely structural — no class is imported, no constructor is called.

ConfigResolver.audit()

Already-instantiated resolvers expose the same data without re-walking the config:

from srforge import init

resolve = init(cfg)
edges = resolve.audit()    # list[IndirectionEdge] from the precomputed graph

This is what srforge audit <path> (see Writing Scripts § The srforge CLI) calls under the hood, with the rendering done by edges_to_text / edges_to_json / edges_to_graphml.


Static audit: audit_config

srforge.config.audit.audit_config is the file-path entry point for the same audit. It loads the YAML, instantiates a ConfigResolver (which builds the ConfigGraph and validates it), then returns the edge list. Useful for:

  • Documenting which classes a given experiment depends on.
  • Generating sidecar graphs that code-graph tools (Graphify, Sourcegraph, Neo4j) can merge into a wider knowledge graph.
  • Pre-flight checking that every _target is importable before a long training run starts.

Cycle / dangling-reference detection runs first

Because audit_config constructs a ConfigResolver, the graph's validation (cycle detection, missing-reference detection) runs before the edges are returned. A broken config raises ValueError rather than yielding a partial edge list. If you want best-effort output from a broken config, wrap the call:

try:
    edges = audit_config(path)
except ValueError as exc:
    print(f"Config is broken: {exc}")
    # No edges are returned; reach for `ConfigGraph.from_config` directly
    # if you need the structural walk to short-circuit at the first error.
from srforge.config.audit import audit_config, edges_to_json
edges = audit_config("configs/inference/hrnet_mus2.yaml")
print(edges_to_json(edges))

Or via the CLI:

srforge audit configs/inference/hrnet_mus2.yaml
srforge audit configs/inference/hrnet_mus2.yaml --format json
srforge audit configs/inference/hrnet_mus2.yaml --format graphml > hrnet.graphml

Each edge is an IndirectionEdge:

@dataclass(frozen=True)
class IndirectionEdge:
    kind: Literal["target", "reference"]
    source_path: str            # dotted config-tree path
    raw: str                    # literal string from the YAML
    resolved: str | None        # "module.Class" or referenced path

resolved is None when the audit cannot deterministically follow the indirection — e.g. an unregistered short-name _target. The raw string is preserved so tools can still surface the edge for human review.

Worked example

configs/inference/hrnet_mus2.yaml produces 22 edges, including:

source_path kind raw resolved
model target srforge.utils.checkpoint.load_weights_from_wandb srforge.utils.checkpoint.load_weights_from_wandb
model.params.module target srforge.models.MISR.HighResNet srforge.models.MISR.HighResNet
dataset.params.transforms.0 target srforge.transform.entry.CalculateTranslations srforge.transform.entry.CalculateTranslations
dataset.params.transforms.6 target srforge.transform.entry.PadAlongAxes srforge.transform.entry.PadAlongAxes

In a code-graph tool that ingests this manifest, each resolved value becomes a node, each source_path becomes a node, and the edges between them make the previously-invisible string-to-class indirection queryable alongside the rest of the codebase structure.


See also