Roadmap · Developer UX · P3 · design exploration

Trim Registry boilerplate

The roadmap's own note on this item: "only worth it once real usage shows which parts of the pattern actually chafe." So this isn't a build-it recipe like the others — it's an honest look at whether the current add_* API is actually a problem, three concrete alternatives with working prototypes, and a recommendation.

What actually chafes?

Be specific before changing anything. Here's a real registry today:

reg = Registry()
reg.add_dataset("iris", title="Iris", kind="tabular",
                features=["a", "b", "c", "d"], targets=["species"])
reg.add_model("mlp", title="MLP", requires_kind="tabular",
              params=[Param("hidden", "hidden layers", "int_list", [64, 32])])
reg.add_hyperparameter("lr", label="learning rate", default=1e-3, min=1e-6, max=1.0)
reg.add_loss([Param("loss", "kind", "enum", "mse", choices=["mse", "cross_entropy"])])

Honestly? Not much. It's linear, greppable, and every keyword tells you what it means. The one real friction is that it's imperative Python — you can't hand a registry to a non-programmer as data, generate it from a config file, or diff two registries as text. That's a narrow but real use case, and it points at exactly one addition.

Option A — decorators

skip

@reg.dataset("iris")

@reg.dataset("iris", kind="tabular", features=[...], targets=["species"])
def iris(): ...          # the loader IS the declaration
The pitch: co-locate a dataset's declaration with its loader function. Reads nicely for datasets.

for

  • loader + metadata in one place
  • familiar (Flask/FastAPI routing)

against

  • only fits nodes that have a function — models, hyperparameters and loss don't
  • so you'd end up with decorators AND add_*, i.e. two ways to do it
  • the loader-dispatch (LOADERS[compiled.dataset]) already solves co-location without new API
Verdict: a mixed API is worse than a verbose-but-uniform one. Skip.

Option B — Registry.from_config(dict)

recommended (if anything)

declarative, data-driven

A classmethod that builds a registry from a plain dict — which can come from YAML, JSON, or a database. Purely additive: the add_* methods stay, this just calls them for you.
@classmethod
def from_config(cls, config: dict) -> "Registry":
    """Build a Registry from a declarative dict (e.g. loaded from YAML)."""
    reg = cls()
    for name, d in config.get("datasets", {}).items():
        reg.add_dataset(name, **d)
    for name, d in config.get("models", {}).items():
        params = [Param(**p) for p in d.pop("params", [])]
        reg.add_model(name, params=params, **d)
    for name, d in config.get("hyperparameters", {}).items():
        reg.add_hyperparameter(name, **d)
    if "loss" in config:
        reg.add_loss([Param(**p) for p in config["loss"]])
    for name, d in config.get("transforms", {}).items():
        reg.add_transform(name, **d)
    return reg
Usage:
reg = Registry.from_config({
    "datasets": {"iris": {"title": "Iris", "kind": "tabular",
                          "features": ["a","b","c","d"], "targets": ["species"]}},
    "models":   {"mlp": {"title": "MLP", "requires_kind": "tabular",
                         "params": [{"name":"hidden","label":"hidden layers",
                                     "kind":"int_list","default":[64,32]}]}},
    "hyperparameters": {"lr": {"label":"learning rate","default":1e-3,"min":1e-6,"max":1.0}},
    "loss": [{"name":"loss","label":"kind","kind":"enum","default":"mse",
              "choices":["mse","cross_entropy"]}],
})
Verified. The dict above and the imperative version at the top of this page produce a byte-identical catalog (json.dumps(a, sort_keys=True) == json.dumps(b, …)).

for

  • registry becomes data — YAML-able, generatable, diffable
  • additive; breaks nothing; ~15 lines
  • trivial to test (compare catalogs)

against

  • nested dicts aren't obviously shorter than the calls
  • Param-as-dict is its own small verbosity
  • loses IDE autocomplete / type-checking on the keys
Verdict: the only option that unlocks something the current API can't do (config-driven registries). Add it if you have that need — not as a replacement, as a second door.

Option C — leave it alone

the default

do nothing, on purpose

The current add_* API is explicit, uniform across all node types, greppable, and type-checked by your editor. "Verbose" and "bad" aren't the same thing — a registry read top-to-bottom is a feature. Until a real user hits a wall, adding API is pure surface area to maintain and document.
Verdict: the honest default. Revisit only when a concrete need (config files, a plugin system, a GUI that emits registries) makes the case for itself.

If you do add Option B

Drop the from_config classmethod into modulearn/registry.py, add a test that asserts a small config's catalog() equals the equivalent imperative build, document it in the README beside the "two things" section, and mark the roadmap item done:true. Keep the add_* methods exactly as they are — from_config is sugar on top, never a replacement.