# FlexLock Usage Guide A practical, end-to-end tour of the library. Each section answers one question: *what does this give me, and how do I get it?* ## Table of Contents 1. [Overview](#1-overview) 2. [Installation & first run](#2-installation--first-run) 3. [Configuration model](#3-configuration-model) 4. [Submitting work — three flavors](#4-submitting-work--three-flavors) 5. [`py2cfg` — build configs from Python](#5-py2cfg--build-configs-from-python) 6. [Sweeps](#6-sweeps) 7. [Snapshots & reproducibility](#7-snapshots--reproducibility) 8. [Smart run / caching](#8-smart-run--caching) 9. [HPC backends](#9-hpc-backends) 10. [MLflow integration](#10-mlflow-integration) 11. [Debugging](#11-debugging) 12. [Exceptions](#12-exceptions) 13. [Environment variables](#13-environment-variables) 14. [Common recipes](#14-common-recipes) 15. [FAQ / gotchas](#15-faq--gotchas) --- ## 1. Overview FlexLock is a thin orchestration layer around OmegaConf and Python's `multiprocessing` / Slurm / PBS. It runs the same function you'd write anyway, and gives you three things in return: 1. **A reproducible `save_dir`.** Every submission resolves `save_dir` eagerly (so resolvers like `${vinc:}` advance exactly once) and writes `run.lock` before the function runs. 2. **A snapshot of the environment.** Git tree hash for tracked repos, xxh64 of tracked data files, the resolved config — all in `run.lock`. 3. **A cache.** When you submit the same fingerprint again, FlexLock returns the cached result instead of re-executing. It sits between Hydra (config composition) and a job scheduler (Slurm, PBS, local multiprocessing): you write the function, FlexLock handles config merging, save-dir lifecycle, snapshotting, parallel dispatch, and cache lookup. --- ## 2. Installation & first run ```bash pip install flexlock # or conda install -c quentinf00 flexlock ``` Minimal "hello world": ```python # train.py from flexlock import flexcli @flexcli def train(lr: float = 0.01, epochs: int = 10, save_dir: str = 'outputs/train'): """Train a model.""" print(f"lr={lr}, epochs={epochs}, save_dir={save_dir}") return {"accuracy": 0.95} if __name__ == "__main__": train() ``` ```bash python train.py -o lr=0.1 ``` After the run, `outputs/train/` contains: ``` outputs/train/ ├── run.lock # config + git tree hash + data hashes, written before exec ├── run.complete # written after the function returns; cache-hit marker ├── results.json # the function's return value (if dict-shaped) └── snapshot/ # filtered git tree if you snapshot repos / data ``` `run.lock` is the canonical record. `run.complete` is what `smart_run` looks for to decide a run is a real cache hit (a run with only `run.lock` was interrupted). --- ## 3. Configuration model FlexLock configs are OmegaConf `DictConfig`s. A config that should *run* something carries a `_target_` key whose value is a dotted import path: ```yaml # config.yaml _target_: myproject.train.train lr: 0.01 epochs: 100 save_dir: outputs/train ``` At submit time, FlexLock recursively instantiates any nested dict that has `_target_`, then calls the top-level target with the remaining keys as keyword arguments. ### Defaults files A "defaults" file exports a dict (or `DictConfig`) under a named variable. Three forms are accepted by `-d` and `Project(defaults=...)`: ```bash # Dotted module path — variable is the last segment flexlock-run -d myproject.config.defaults # File path with explicit variable flexlock-run -d configs/defaults.py:defaults # Bare file path — defaults to the `defaults` variable flexlock-run -d configs/defaults.py ``` The file-based forms register the loaded file in `sys.modules` under its file stem, so any `_target_` strings captured by `py2cfg` inside the file remain importable when workers re-import them later. `flexlock-run` also inserts the current working directory into `sys.path`, so dotted imports of project-root packages work without `PYTHONPATH=.`. ### Overrides and selection The merge order for `flexlock-run` is: ``` defaults + -c yaml + -m merge_file + -o root.overrides → -s select_node + -M post-select merge + -O post-select overrides → execute / sweep ``` `-O` and `-M` apply *after* selection, so you can override the selected node without disturbing the root. ### Swapping a subtree: `key=@name` A value starting with `@` names a config instead of giving one, and **replaces** the node (the way Hydra config groups do), rather than merging into it: ```bash flexlock-run -d my.xps.train_x -s main -o main.lit_module.model=@big_model flexlock-run -d my.xps.train_x -s main -O lit_module.model=@big_model # after select flexlock-run -d my.xps.train_x -s main -o main.model=@other.module.huge_model flexlock-run -d my.xps.train_x -s main --sweep "@small_model,@big_model" \ --sweep-target main.lit_module.model ``` - `@name` is an attribute of the `-d` module (`my.xps` above), so building blocks defined next to your presets (`small_model = py2cfg(...)`) can be swapped by name. `@pkg.module.attr` reaches a config defined elsewhere. - Replacement matters: merging `big_model` into `small_model` would keep the keys only `small_model` has. With `@`, the node is exactly `big_model`. - Overrides apply in order, so `-o main.model=@big_model main.model.width=16` swaps, then tweaks the new model. - `@@x` is the literal string `@x`. An unknown name is an error listing the module's config attributes. - The override is recorded as typed in `_preset_`; the expanded subtree is in `config`, so provenance is complete. The same works from Python: `Project("my.xps.train_x").submit("main", overrides={"model": "@big_model"})` and in sweep items (`{model: "@big_model"}` in a sweep file). This replaces registry resolvers such as `${sel_model:small}` over a `_MODELS` dict. ### Relative interpolations Within a node, prefer relative refs over root-scope refs. They're the only kind of cross-key reference that survives `proj.get()` *and* follows per-item sweep overrides: ```python # myproject/configs.py from flexlock import py2cfg from myproject.train import train defaults = dict( train=py2cfg( train, save_dir='outputs/train', log_dir='${.save_dir}/logs', # sibling key — relative ckpt_dir='${.save_dir}/checkpoints', # sibling key — relative ), ) ``` `${.x}` resolves against the current node, `${..x}` against its parent, `${...x}` against its grandparent. These are preserved by `proj.get()` and by sweep merging, so per-item `save_dir` overrides correctly propagate into `log_dir` and `ckpt_dir`. Root-scope refs (`${root_anchor}`, `${main.save_dir}`) are *frozen to their concrete value* at `proj.get()` time, which keeps the returned sub-tree pickleable for HPC dispatch. ### `save_dir_policy` — collision guard (replaces `${vinc:}` / `${now:}`) Keep `save_dir` a **stable** path in configs; `save_dir_policy` decides what happens when it's already occupied (default `"raise"`; `"increment"` versions it the way `${vinc:}` used to, `"timestamp"` replaces `${now:}`). See [`resolvers.md`](resolvers.md#save_dir_policy-replaces-vinc--now) for the full policy table and migration guide. `${vinc:}`/`${now:}` still work for one deprecation cycle but emit a `DeprecationWarning`. ```python save_dir='outputs/train/run' proj.submit(cfg, save_dir_policy="increment") # → outputs/train/run_0000, run_0001, ... ``` Other resolvers: `${latest:glob_pattern}` (newest match), `${run_lock:,}` (read a value from an upstream `run.lock`). --- ## 4. Submitting work — three flavors ### 4a. CLI via `@flexcli` + `flexlock-run` For scripts you'll mostly run from the shell: ```python # train.py from flexlock import flexcli @flexcli def train(lr: float = 0.01, save_dir: str = 'outputs/train'): """Train a model.""" return {"accuracy": 0.95} if __name__ == "__main__": train() ``` ```bash python train.py -o lr=0.1 flexlock-run -d train.train -O lr=0.1 # equivalent flexlock-run -d train.train --note "baseline before lr sweep" # record intent flexlock-run --help # prints argparse help + compiled config ``` You get an `ExecutionResult.result` back (the dict), `run.lock`, `run.complete`, and `results.json` on disk. Pass `--note` (CLI) or `note="…"` (`proj.submit(cfg, note=…)`) to record a free-text intent in `run.lock`. It never affects caching and shows up in `flexlock show`/`graph`/`report`. ### 4b. Python `Project().submit(cfg)` For multi-stage scripts and notebooks where you want pipeline plumbing (`proj.get`, `proj.defaults`, `proj.run_stage`, ...): ```python from flexlock import Project proj = Project(defaults='myproject.configs.defaults') cfg = proj.get('train') cfg.lr = 0.1 result = proj.submit(cfg) print(result.save_dir) # 'outputs/train' print(result.status) # 'SUCCESS' or 'CACHED' print(result['accuracy']) # dict-like access to result.result ``` `proj.get(key)` returns a self-contained sub-tree: root-scope refs are frozen, resolver calls and intra-node refs are preserved. ### 4c. `from flexlock import submit` — one-shot For when you already have a `DictConfig` and don't need a Project: ```python from flexlock import submit, py2cfg from myproject.train import train cfg = py2cfg(train, lr=0.01, save_dir='outputs/train') result = submit(cfg, slurm_config='configs/slurm_gpu.yaml') ``` `submit` is `Project().submit` with all the same kwargs (`sweep`, `smart_run`, `overrides`, `force`, `isolated`, ...). Use it for ad-hoc one-offs and library code that takes a config and runs it. --- ## 5. `py2cfg` — build configs from Python `py2cfg` reads a function/class signature and produces a config dict keyed by `_target_`: ```python from flexlock import py2cfg from myproject.train import train from myproject.models import Transformer cfg = py2cfg( train, lr=0.01, model=py2cfg(Transformer, layers=12, heads=16), save_dir='outputs/train', ) # { # '_target_': 'myproject.train.train', # 'lr': 0.01, # 'epochs': 10, # captured from signature default # 'model': {'_target_': 'myproject.models.Transformer', 'layers': 12, 'heads': 16}, # 'save_dir': 'outputs/train', # } ``` Use it when: - you want to refactor a function and have its config follow (signature changes propagate automatically), - you want nested instantiation (`model=py2cfg(...)`), - you want IDE autocomplete and type-checking on the keyword names. Prefer YAML when configs are written by non-Python users, or when you want a declarative record that lives outside the codebase. **Gotchas:** - The target must be re-importable on workers. `py2cfg(train)` inside a notebook produces `_target_: __main__.train`, which won't import in a spawned subprocess. Move definitions to a module. - Every default in the signature ends up in the config, including `None` defaults. Drop them via overrides if you don't want them in `run.lock`. - Decorated functions are unwrapped automatically (`py2cfg` looks at `_original_fn`). --- ## 6. Sweeps A sweep is a list of override dicts merged into the base config one at a time. Sources: ```python proj.submit(cfg, sweep=[{'lr': 0.001}, {'lr': 0.01}]) # inline proj.submit(cfg, sweep_file='configs/grid.yaml') # not directly — see below flexlock-run -d ... --sweep-file configs/grid.yaml flexlock-run -d ... --sweep "0.001,0.01,0.1" --sweep-target lr flexlock-run -d ... --sweep-key param_grid # list lives in defaults ``` From Python, use `load_sweep` to read a file: ```python from flexlock import load_sweep, Project proj = Project(defaults='myproject.configs.defaults') sweep = load_sweep(sweep_file='configs/grid.yaml') if __name__ == "__main__": results = proj.submit('train', sweep=sweep, n_jobs=4) for r in results: print(r.save_dir, r['accuracy']) ``` ### `sweep_target` Without `sweep_target`, each item is merged at the root of the base config. With `sweep_target='optimizer.lr'`, scalar items are placed at that dotted path. ### `sweep_dir_suffix` When `True`, each item's `save_dir` becomes `/sweep_{i:04d}/` — items are **nested under** the sweep root, not siblings to it. This keeps the tasks DB and lineage markers inside the same tree: ```python proj.submit(cfg, sweep=sweep, sweep_dir_suffix=True) # outputs/train/sweep_0000/ # outputs/train/sweep_0001/ # outputs/train/run.lock.tasks.db ``` Containment is validated up-front: if a sweep item's `save_dir` falls outside the sweep root, you get a `FlexLockValidationError` listing the offenders before any work is queued. ### Enqueue a sweep for later (`--sweep` + `--enqueue`) `--enqueue queue.yaml` normally appends the single compiled config to a queue file. Combined with a sweep source it appends **one entry per item**, each already merged at `--sweep-target` and frozen (deferred resolvers preserved): ```bash flexlock-run -d ... --sweep "0.001,0.01,0.1" --sweep-target lr --enqueue q.yaml # q.yaml now has 3 entries; run them later (locally or on HPC) with: flexlock-run -d ... --sweep-file q.yaml --n_jobs 3 ``` This is the single-stage counterpart of the multi-stage `--enqueue` batching in §14g — there each item is a composite `{_stages_: [...]}` pipeline task instead of one plain config. ### Per-item interpolations Because relative refs (`${.x}`, `${..x}`) survive node selection and sweep merging, a sweep that overrides `save_dir` automatically updates sibling keys that point to it: ```python from flexlock import py2cfg, Project from myproject.train import train cfg = py2cfg( train, save_dir='outputs/train', log_dir='${.save_dir}/logs', # follows per-item save_dir ckpt_dir='${.save_dir}/ckpts', ) if __name__ == "__main__": proj = Project() proj.submit(cfg, sweep=[ {'save_dir': 'outputs/sweep/a'}, # log_dir → outputs/sweep/a/logs {'save_dir': 'outputs/sweep/b'}, # log_dir → outputs/sweep/b/logs ], n_jobs=2) ``` ### `n_jobs`, `isolated`, and the `__main__` guard - `n_jobs=1` (default, no `isolated`): runs in-process. No guard needed. - `n_jobs > 1` or `isolated=True`: uses `multiprocessing` with the `spawn` start method (avoids CUDA fork hazards). Spawn re-imports the launching script in each child, so **any module-scope `proj.submit(...)` call must sit under `if __name__ == "__main__":`** or the children will recursively re-launch. - Notebooks and REPL contexts are fine. After a parallel or `isolated=True` run, workers write `results.json` into each per-task `save_dir`. The driver loads them back and populates `ExecutionResult.result`, so the return value is available downstream just like in serial execution. --- ## 7. Snapshots & reproducibility Every submit with a `save_dir` writes `run.lock` with three sections: ```yaml # outputs/train/run.lock timestamp: "2026-06-01T14:30:00" repos: myproject: commit: "abc123…" tree: "def456…" # this is what smart_run compares is_dirty: false path: "/abs/path/to/repo" data: train_data: "xxh64_789xyz…" config: _target_: myproject.train.train lr: 0.01 save_dir: outputs/train ``` ### Declaring what to track ```python from flexlock import py2cfg from myproject.train import train cfg = py2cfg( train, input_path='data/train.csv', save_dir='outputs/train', _snapshot_=dict( repos={ 'myproject': '.', # string shorthand → {'path': '.'} 'mylib': {'path': 'libs/mylib'}, # explicit 'pkg': {'module': 'pkg'}, # resolved via importlib 'filtered': { # filtered comparison 'path': '.', 'include': ['src/model/**'], 'exclude': ['tests/**'], }, }, data={'train': '${...input_path}'}, prevs=['${...upstream_run_dir}'], # link to upstream runs ), ) ``` **Relative refs inside `_snapshot_` need three dots.** They resolve from their own position in the tree, and `data` values and `prevs` items both sit two levels below the stage node (`stage._snapshot_.data.train`), so climbing back to a sibling of `_snapshot_` takes `${...key}`: `data.train` → `data` → `_snapshot_` → stage. A single-dot `${.input_path}` would look for `input_path` *inside* the `data` mapping and raise `InterpolationKeyError`. When a config has `_target_`, FlexLock auto-tracks the git repo of the target's source file — you don't need to spell `repos` for the main codebase. Add explicit entries for sibling libraries, vendored dependencies, or filtered comparisons. ### `snapshot()` directly You can also write a snapshot outside the `submit` flow: ```python from flexlock import snapshot snapshot(cfg, repos={'main': '.'}, data={'train': 'data/train.csv'}) ``` ### Re-running from a snapshot `run.lock` is YAML — pull the `config` block out and resubmit it: ```python from omegaconf import OmegaConf from flexlock import submit cfg = OmegaConf.load('outputs/train/run.lock').config submit(cfg, force=True) # re-execute, overwriting in place ``` `force=True` invalidates `run.complete` (so `smart_run` doesn't short- circuit), preserves outputs and `run.lock`, and re-runs the function. ### Presets: which config produced a run A **preset** is what you pass on the command line: the `-d` target (a Python attribute holding a config) plus the `-s` key. You don't register anything: every run made through `flexlock-run -d/-s`, or through `Project("pkg.module.attr").get(key)`, records its preset in the config as a reserved `_preset_` key, along with the overrides you typed: ```yaml # run.lock config: _target_: sst_ml_mapping.starter.train.train _preset_: defaults: sst_ml_mapping.starter.xps_glob:train_small_cloud_gap_compact select: main overrides: ["main.lit_module.lr=1e-4"] # -o, as typed overrides_after_select: [] # -O merges: [] # -m / -M ... ``` `_preset_` is provenance only: it never enters the fingerprint (so caching is unaffected), `RunDiff` ignores it, and `instantiate` strips it before calling your function. Only runnable nodes (`_target_` + `save_dir`) get one. When a run completes, FlexLock adds a symlink under the project's `.flexlock/presets//