Development workflow
Conventions and invariants for working in this repo. Read once before opening a PR.
Toolchain
- Python 3.12 (
>=3.12,<3.13) β pinned; matchesrocm/primus:v26.2. - uv β single project (no workspace).
uv syncinstalls the base deps;uv sync --extra <group>adds optional groups. - ruff β replaces black/isort/flake8/pyupgrade. Config in
pyproject.toml. - mypy --strict β only on
mindxtrain/configandmindxtrain/provenance(the schemas + manifest paths). Training / eval code is exempt. - pytest +
pytest-asyncioβ fast unit tests; GPU tests are manual on the MI300X. - Foundry β Solidity contracts in
contracts/. Installed on the MI300X droplet for the on-chain anchoring path.
Optional dependency groups
pyproject.toml defines six [project.optional-dependencies] groups:
| Group | Adds |
|---|---|
ml |
trl, transformers, peft, accelerate, datasets |
eval |
lm-eval, lighteval, inspect-ai, jinja2 |
data |
datasketch, sentence-transformers, faiss-cpu, pyarrow |
serve |
vllm |
chain |
web3, py-algorand-sdk, huggingface-hub |
obs |
opentelemetry-sdk, prometheus-client, psutil |
Plus all which pulls everything except amd-quark (which ships in the
rocm/primus container, see HANDOFF.md Β§3).
The base install (no extras) is enough for: the CLI, the Coach UI, the autotune dry-run, manifest verify, the operator FastAPI app, and every in-process Python utility (registry, hot-swap, agent loop, ContextManager, data filter, sequence packing). See actualization_status.md for the per-module map.
Lazy-import pattern
Every module that wants an optional dep guards the import inside the function that needs it:
def run_lm_eval(model_dir: Path, tasks: list[str]) -> Path:
if not _lm_eval_available():
msg = "lm-eval not installed; run `uv sync --extra eval`."
raise RuntimeError(msg)
... # subprocess wrap that uses the dep
Two implications:
import mindxtrain.eval.harnessalways succeeds even without--extra eval.- The error message includes the exact
uv sync --extra <group>to run.
This is the canonical pattern; new modules that take optional deps must follow it.
The standard local cycle
uv sync # base install
uv run ruff check --fix . # lint + auto-fix
uv run mypy mindxtrain/config mindxtrain/provenance # types where strict
uv run pytest -q # β 564 passed in ~5s
CI runs the same four commands on Ubuntu 24.04 / Python 3.12 (CPU-only).
See .github/workflows/ci.yml.
Repository layout
.
βββ pyproject.toml # single project; optional-dep groups
βββ README.md # entry doc (the only root .md besides CLAUDE/AGENTS)
βββ CLAUDE.md, AGENTS.md # agent-tooling entrypoints (required at root)
βββ NOTICE, LICENSE-* # legal
βββ Containerfile, compose.yaml # podman entry points
βββ docs/ # all documentation (index: docs/NAV.md)
β βββ NAV.md # docs index
β βββ HANDOFF.md # operator checklist
β βββ dcoach.md # the proof loop + decentralized fit
β βββ CHANGELOG.md
β βββ β¦ # architecture, coach, governance, decentralized, reference
βββ mindxtrain/ # the package β 12 subpackages, ~99 modules
β βββ cli/ # typer CLI (9 verbs)
β βββ config/ # 10-section Pydantic schema + JSON defaults
β βββ data/ # curate β dedupe β filter β tokenize β pack β synth β verify
β βββ models/ # registry + chat templates + 5 base presets
β βββ train/ # sft, dpo, grpo, rlhf, tool_use, distributed, callbacks, recipes/
β βββ eval/ # lighteval / inspect_ai / bfcl / persona / agenda / card
β βββ autotune/ # 60s AOT probe β the differentiator
β βββ operator/ # FastAPI app, Coach UI, ml-intern patterns
β βββ storage/ # local_fs / hf_hub / lighthouse / ipfs
β βββ provenance/ # manifest, hashing, verify, erc8004, algorand, x402
β βββ deploy/ # registry, hot_swap, ab_test, vllm_launcher, quark
β βββ budget/ # ResourceBudget + cloud-provider stubs
βββ contracts/ # Foundry workspace (ERC-8004 attestation)
βββ ops/ # containerfiles, compose, k8s, vmm, gensyn
βββ examples/ # demo YAMLs
βββ tests/ # pytest β 566 tests (CPU-only smoke)
βββ docs/
βββ *.md # current state (this directory)
βββ blueprints/ # source design briefs (frozen)
Reuse boundaries
- From
/home/hacker/mindX/(production codebase): Codephreak persona JSON loaded at runtime viaMINDXTRAIN_PERSONA_PATH. Do not copy file bytes β load via env var. - Not from
/home/hacker/aglm/β broken per its own README. Use only for reference to legacy class names mindxtrain2.md flagged as needing refactor.
Invariants
These are non-negotiable; violating them is a deployment bug, not a style preference.
- AOT-only. No
torch.compile(mode="max-autotune")in production paths. No JIT autotune in vLLM serving (setVLLM_USE_TRITON_FLASH_ATTN=0if needed). Theautotune.policy: aot_onlyfield in the YAML is the contract; tested attests/test_config_schema.py::test_qwen3_8b_sft_lora_validates. hardware.gpus: 1 | 8only. 2/4-GPU MI300X FSDP groups hit asymmetric xGMI; the schema rejects them at parse time. Tested attests/test_config_schema.py::test_xgmi_2gpu_rejectedandtests/test_distributed.py.- Seven MI300X env vars in
train.env(defaults, can be overridden by the autotune plan but never removed):HSA_NO_SCRATCH_RECLAIM=1,NVTE_CK_USES_BWD_V3=1,NVTE_CK_IS_V3_ATOMIC_FP32=1,PRIMUS_TURBO_ATTN_V3_ATOMIC_FP32=1,NCCL_MIN_NCHANNELS=112,HIP_FORCE_DEV_KERNARG=1,PYTORCH_ROCM_ARCH=gfx942. extra: forbidon every Pydantic model. Unknown YAML keys raiseValidationError. Tested attests/test_config_schema.py::test_extra_field_forbidden.- Configs are immutable once loaded (
frozen: true). - Solidity contracts: no proxies, no
Ownable, no admin keys, no setters.mindxtrain_registry.solis write-once. Rotating any parameter requires a fresh deploy. - Lazy imports for optional deps β see the pattern above.
Training lanes (CPU / local-GPU / MI300X)
Three ways to actually run a fine-tune, selected by train.backend:
| Lane | Backend | Device | When |
|---|---|---|---|
| CPU | trl_cpu |
CPU, float32 (in-process TRL) | mindX self-training, smoke runs, no GPU |
| Local GPU | trl_local |
auto: CUDA/ROCm GPU (bf16/fp16) else CPU fallback | consumer Radeon RX / NVIDIA RTX, or a laptop |
| MI300X | axolotl/unsloth/torchtune/primus |
gfx942 subprocess + 7 env vars | the AOT MI300X target |
trl_local is the device-aware in-process lane (backend_trl_cpu.py::run_trl_local):
it picks the GPU when torch.cuda.is_available() (ROCm surfaces through the same API),
else logs no accelerator detected β CPU fallback and runs on CPU. The same recipe
(mindx_fallback_qwen3_1_5b_local) therefore runs unchanged on a gaming GPU or a laptop.
trl_cpu is run_trl_local(..., force_cpu=True); MINDXTRAIN_FORCE_CPU=1 forces the
fallback anywhere. The in-process lanes never inject the seven MI300X env vars.
Confirm which device a box will use:
uv run python -c "import torch; print(torch.cuda.is_available(), torch.version.hip)"
Unsupported: integrated Vega/RDNA APUs (e.g. Ryzen "Raven"/gfx90c) are not ROCm
targets and fall back to CPU. A discrete RX 6800/7900 (gfx1030/gfx1100) or any NVIDIA
RTX is the intended consumer GPU.
Adding a new recipe
- Drop a YAML at
mindxtrain/train/recipes/<name>.yaml. Validate locally:uv run python -c "from mindxtrain.config.loader import load_config; load_config('mindxtrain/train/recipes/<name>.yaml')" - The
tests/test_config_schema.py::test_all_recipes_validatetest will pick it up automatically β re-run pytest. - Add a row to docs/yaml_schema.md only if the recipe exercises a previously-unused field.
Adding a new training backend
- Add
mindxtrain/train/backend_<name>.pyexposing arun_<name>(cfg, plan, out_dir) -> Pathfunction (or for in-process TRL trainers, arun_<name>(cfg, out_dir) -> Pathfunction). - Wire it into
mindxtrain/train/dispatch.py'sif backend == ...ladder. - Add
<name>to theTrainingBackendliteral inmindxtrain/config/schema.py. - Update docs/cli.md "Where the verbs live" table.
Adding a new model backend (operator)
- Add
mindxtrain/operator/backends/<name>.pywith aBackendsubclass decorated@register_backend("<name>"). - Side-effect import it from
mindxtrain/models/registry.pyso registration runs on package import. - Add a runtime branch in
mindxtrain/operator/app.py::chat_completionsfor the env-var-driven kwargs.
Adding a new training method
- Define a
_MethodBasesubclass inmindxtrain/config/schema.pywithkind: Literal["<name>"] = "<name>"and the method-specific fields. - Add it to the
TrainMethoddiscriminated union. - Add a
mindxtrain/train/<name>.pyrunner (TRL wrap or subprocess). - Update the dispatch path so a YAML with
train.method.kind == "<name>"reaches the runner. - Add a recipe under
mindxtrain/train/recipes/exercising it. - Update
docs/yaml_schema.md"train.method" table.
Adding a new optional-dep group
- Add the entry to
[project.optional-dependencies]inpyproject.toml. - Add a row to the table in actualization_status.md.
- Update development.md and quickstart.md.
Adding a new doc
- Write
docs/<name>.md. - Add a one-line entry to
docs/NAV.mdunder the appropriate section.
Live training UI
The Coach UI's "Train" step (#step-train in
coach/static/index.html)
launches a training run and streams loss / lr / log lines back into the
browser over Server-Sent Events. Architecture:
- Registry:
mindxtrain.operator.runs.RunRegistryis an in-memory singleton (one per uvicorn process) keyed byrun_id. Snapshots are immutableRunrecords (frozen Pydantic); state changes produce new snapshots viamodel_copy. - Event schema:
TrainEventis a tagged union overStatusEvent,StepEvent,EvalEvent,LogEvent,EnergyEventβ all withextra="forbid", frozen=True. Wire format:event: <kind>\ndata: <event.model_dump_json()>\n\n. - Two ingestion paths, deduped by
(run_id, step)inRunRegistry.publish:- Subprocess stdout regex (
parse_trainer_log_line) β works on the base install, parses HF Trainer's'loss': β¦ 'learning_rate': β¦log lines. - In-process
mindxtrain.train.callbacks.StreamCallbackβ POSTs to/coach/api/runs/{id}/ingest(loopback only). Requires--extra ml.
- Subprocess stdout regex (
- Subprocess orchestration:
spawn_subprocess_streamingusessubprocess.Popen(stdout=PIPE, bufsize=1, text=True)and tees lines to bothtrain.log(the durable on-disk artifact) andRunRegistry.publish_threadsafefrom a daemon thread. We usePopen(notasyncio.create_subprocess_exec, notBackgroundTasks) so the child outlives the launch HTTP request andSIGINT-then-SIGTERMcancellation matches the CLI Ctrl-C path.
Routes
All under /coach/api/runs:
| Verb | Path | Purpose |
|---|---|---|
| POST | /launch |
Spawn a run; returns Run immediately. 503 if accelerate is missing. |
| GET | / |
List active + last 20 runs. |
| GET | /{id} |
Run snapshot. |
| GET | /{id}/events |
SSE β all event kinds. Replays last 200 buffered on connect. |
| GET | /{id}/logs |
SSE β kind="log" only. |
| POST | /{id}/cancel |
SIGINT then SIGTERM after grace. |
| POST | /{id}/ingest |
Loopback-only β used by StreamCallback. |
SSE responses set Cache-Control: no-cache, X-Accel-Buffering: no,
Connection: keep-alive so reverse proxies don't buffer the stream.
Invariants
import mindxtrain.operator.runssucceeds without--extra ml. The in-processStreamCallbackrequirestransformers; the subprocess-stdout path does not. UI degrades gracefully.Runand every*Eventarefrozen=True, extra="forbid".- The subprocess line reader runs in a daemon thread; events reach the
asyncio loop via
loop.call_soon_threadsafe(registry.publish, β¦).
Frontend
Vanilla JS, no build step. Live view uses the browser-native EventSource:
const es = new EventSource(`/coach/api/runs/${id}/events`);
es.addEventListener("step", e => pushPoint(JSON.parse(e.data)));
es.addEventListener("log", e => appendLog(JSON.parse(e.data)));
es.addEventListener("status", e => updateBadge(JSON.parse(e.data)));
Chart.js is vendored locally at coach/static/vendor/chart.umd.min.js
(pinned to v4.4.0; SHA256 in coach/static/vendor/VERSIONS.md). No CDN
dependency at demo time. If the vendored bundle is missing, the page
degrades to a metrics table β coach.js checks typeof Chart === "undefined"
and shows the table-only fallback.
Why not Selenium / WebSocket / Streamlit
- Selenium is a browser-test framework, not a UI library β it can't push live data into a browser. (It might appear later as CI smoke for the dashboard; that's E2E testing, not UI.)
- WebSocket is bidirectional; we don't need browserβserver streaming. Held in reserve for v2 "edit hyperparam mid-run."
- Streamlit / Gradio each spin up their own ASGI server on a separate
port, which breaks the single-URL operator demo and the lazy-import
invariant. SSE on the existing
:8080is the right shape.
Common debugging
| Symptom | Cause |
|---|---|
ModuleNotFoundError: No module named 'mindxtrain' |
Forgot uv sync. Fixed by uv sync. |
RuntimeError: ... not installed; run uv sync --extra <group> |
Optional dep gating β install the named group. |
pydantic.ValidationError: extra keys not permitted |
YAML has a typo or stale field name. Compare to yaml_schema.md. |
ValueError: MI300X xGMI permits only 1 or 8 GPUs |
hardware.gpus is 2 or 4. Use 1 or 8. |
Failed to download due to network timeout (uv) |
UV_HTTP_TIMEOUT=120 uv sync. |
| First-iteration training is 30s slow on MI300X | Cold AITER / MIOpen / Triton caches. Volume-mount ~/.cache/miopen, AITER_JIT_DIR, TORCH_EXTENSIONS_DIR. |
vllm serve stalls on first batch |
Triton autotune cold-start. Set VLLM_USE_TRITON_FLASH_ATTN=0 or warm-up batch in mindxtrain serve. |
What not to commit
*.safetensors,*.bin,*.pt,*.onnx(large model weights).out/,runs/,checkpoints/(run outputs)..env(use.env.example).contracts/lib/(Foundry submodules β pulled withforge install)..venv/,.uv-cache/,.cache/.
All of the above are in .gitignore.