maatml 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. maatml/__init__.py +3 -0
  2. maatml/cli.py +565 -0
  3. maatml/config.py +222 -0
  4. maatml/data/__init__.py +5 -0
  5. maatml/data/datagen.py +142 -0
  6. maatml/data/formats.py +123 -0
  7. maatml/data/gated.py +60 -0
  8. maatml/data/ingest.py +158 -0
  9. maatml/data/pipeline.py +270 -0
  10. maatml/data/preference.py +109 -0
  11. maatml/data/sanitizer.py +93 -0
  12. maatml/data/schemas.py +14 -0
  13. maatml/data/teacher.py +101 -0
  14. maatml/device.py +209 -0
  15. maatml/evaluation/__init__.py +26 -0
  16. maatml/evaluation/harness.py +487 -0
  17. maatml/evaluation/predictors.py +434 -0
  18. maatml/evaluation/runner.py +107 -0
  19. maatml/export/__init__.py +12 -0
  20. maatml/export/bundle.py +283 -0
  21. maatml/export/gguf.py +109 -0
  22. maatml/export/manifest.py +104 -0
  23. maatml/export/mlx_export.py +59 -0
  24. maatml/overrides.py +230 -0
  25. maatml/registry.py +270 -0
  26. maatml/runs.py +380 -0
  27. maatml/scaffold.py +430 -0
  28. maatml/training/__init__.py +0 -0
  29. maatml/training/builtins.py +47 -0
  30. maatml/training/guards.py +179 -0
  31. maatml/training/load.py +109 -0
  32. maatml/training/multi_head.py +714 -0
  33. maatml/training/preference.py +320 -0
  34. maatml/training/seq2seq.py +423 -0
  35. maatml/training/sft_base.py +746 -0
  36. maatml/utils/__init__.py +0 -0
  37. maatml/utils/io.py +60 -0
  38. maatml/validation/__init__.py +17 -0
  39. maatml/validation/base.py +84 -0
  40. maatml-0.4.0.dist-info/METADATA +229 -0
  41. maatml-0.4.0.dist-info/RECORD +45 -0
  42. maatml-0.4.0.dist-info/WHEEL +5 -0
  43. maatml-0.4.0.dist-info/entry_points.txt +2 -0
  44. maatml-0.4.0.dist-info/licenses/LICENSE +202 -0
  45. maatml-0.4.0.dist-info/top_level.txt +1 -0
maatml/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """MaatML — machine learning models framework from experimentation to production."""
2
+
3
+ __all__: list[str] = []
maatml/cli.py ADDED
@@ -0,0 +1,565 @@
1
+ """maatml CLI.
2
+
3
+ Each command takes a model folder (containing ``model.yml``) and dispatches via
4
+ the plugin registry (``architecture`` / ``dataset.format`` / ``evaluation``).
5
+ Outputs land under ``<model-dir>/output/`` (gitignored).
6
+
7
+ maatml prepare <model-dir>
8
+ maatml train <model-dir> [--smoke] [--resume auto|PATH] [--set K=V]
9
+ maatml sweep <model-dir> --param K=a,b [--metric NAME] [--smoke]
10
+ maatml evaluate <model-dir> [--checkpoint X] [--split test] [--gate]
11
+ maatml export <model-dir> [--checkpoint X] [--format gguf|mlx|safetensors]
12
+ maatml verify <export-dir-or-manifest>
13
+ maatml datagen <model-dir> [--target N] [--teacher]
14
+ maatml ingest <model-dir> --input PATH [--map field=col] [--sanitize tag]
15
+ maatml runs <model-dir>
16
+ maatml scaffold <dir> --architecture causal_sft [--name my-model]
17
+ maatml validate <model-dir>
18
+ maatml plan <model-dir>
19
+ maatml plugins
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ from pathlib import Path
25
+ from typing import Any, Optional
26
+
27
+ import typer
28
+ from rich.console import Console
29
+
30
+ from .config import get_dataset_cfg, load_model_def
31
+ from .overrides import (
32
+ apply_overrides,
33
+ expand_param_grid,
34
+ overrides_from_mapping,
35
+ pick_metric,
36
+ )
37
+ from .registry import (
38
+ FORMATS,
39
+ PREDICTORS,
40
+ TRAINERS,
41
+ discover_plugins,
42
+ list_all_plugins,
43
+ load_model_plugins,
44
+ )
45
+ from .runs import list_runs, resolve_checkpoint
46
+ from .scaffold import normalize_architecture, scaffold_model, validate_model_dir
47
+
48
+ # MPS unsupported-op fallback to CPU; harmless on non-Mac.
49
+ os.environ.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1")
50
+
51
+
52
+ app = typer.Typer(no_args_is_help=True, add_completion=False, help="maatml CLI")
53
+ console = Console()
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Helpers
58
+ # ---------------------------------------------------------------------------
59
+
60
+
61
+ def _boot_plugins(md) -> None:
62
+ discover_plugins()
63
+ if md.plugins:
64
+ load_model_plugins(md.model_dir, md.plugins)
65
+
66
+
67
+ def _default_eval_keys(md) -> tuple[Optional[str], Optional[str], Optional[str]]:
68
+ """Infer predictor/validator/metrics from evaluation: or architecture/task."""
69
+ ev = md.evaluation or {}
70
+ predictor = ev.get("predictor")
71
+ validator = ev.get("validator")
72
+ metrics = ev.get("metrics")
73
+ if isinstance(metrics, list):
74
+ metrics = metrics[0] if metrics else None
75
+
76
+ arch = normalize_architecture(md.architecture)
77
+ if predictor is None:
78
+ if arch in PREDICTORS.names() or PREDICTORS.get(md.architecture):
79
+ predictor = md.architecture if PREDICTORS.get(md.architecture) else arch
80
+ elif arch == "multi_head_classifier":
81
+ predictor = "multi_head_classifier"
82
+ elif arch == "seq2seq":
83
+ predictor = "seq2seq"
84
+ elif arch == "causal_sft":
85
+ predictor = "causal_sft"
86
+
87
+ # validator / metrics come from evaluation: (or model plugins); no
88
+ # hardcoded task-name fallbacks in core.
89
+ return predictor, validator, metrics
90
+
91
+
92
+ def _clone_model_def(md):
93
+ """Deep-copy nested dict sections so sweep trials don't share state."""
94
+ clone = md.model_copy(deep=True)
95
+ object.__setattr__(clone, "model_dir", md.model_dir)
96
+ return clone
97
+
98
+
99
+ def _parse_field_maps(maps: Optional[list[str]]) -> dict[str, str]:
100
+ """Parse ``dest=src`` field maps for ingest."""
101
+ out: dict[str, str] = {}
102
+ for item in maps or []:
103
+ if "=" not in item:
104
+ raise typer.BadParameter(f"Expected field=col mapping, got {item!r}")
105
+ dest, src = item.split("=", 1)
106
+ dest, src = dest.strip(), src.strip()
107
+ if not dest or not src:
108
+ raise typer.BadParameter(f"Empty field map entry: {item!r}")
109
+ out[dest] = src
110
+ return out
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # Commands
115
+ # ---------------------------------------------------------------------------
116
+
117
+
118
+ @app.command("prepare")
119
+ def cmd_prepare(
120
+ model_dir: Path = typer.Argument(
121
+ ...,
122
+ exists=True,
123
+ file_okay=False,
124
+ help="Path to a model folder containing model.yml",
125
+ ),
126
+ ) -> None:
127
+ """Build train/val/test splits under <model-dir>/output/prepared/."""
128
+ md = load_model_def(model_dir)
129
+ _boot_plugins(md)
130
+ fmt = get_dataset_cfg(md).get("format", "jsonl_seed")
131
+ prepare_fn = FORMATS.require(str(fmt))
132
+ prepare_fn(md)
133
+
134
+
135
+ @app.command("train")
136
+ def cmd_train(
137
+ model_dir: Path = typer.Argument(..., exists=True, file_okay=False),
138
+ smoke: bool = typer.Option(False, "--smoke", help="Use the `smoke:` overrides in model.yml"),
139
+ limit: Optional[int] = typer.Option(None, "--limit", help="Cap number of train rows for ad-hoc smoke"),
140
+ device: str = typer.Option("auto", "--device", help="auto|mps|cpu|cuda"),
141
+ seed: Optional[int] = typer.Option(None, "--seed"),
142
+ resume: Optional[str] = typer.Option(
143
+ None,
144
+ "--resume",
145
+ help="Resume training: 'auto' (latest incomplete run) or checkpoint path/run_id",
146
+ ),
147
+ set_overrides: Optional[list[str]] = typer.Option(
148
+ None,
149
+ "--set",
150
+ help="Override model.yml after load (repeatable), e.g. --set training.learning_rate=1e-4",
151
+ ),
152
+ ) -> None:
153
+ """Fine-tune the model declared by <model-dir>/model.yml."""
154
+ md = load_model_def(model_dir)
155
+ if set_overrides:
156
+ apply_overrides(md, set_overrides)
157
+ _boot_plugins(md)
158
+ arch = normalize_architecture(md.architecture)
159
+ trainer = TRAINERS.get(md.architecture) or TRAINERS.require(arch)
160
+ result = trainer(
161
+ md, smoke=smoke, limit=limit, device=device, seed=seed, resume=resume
162
+ )
163
+ console.print(f"[green]done[/] out_dir={result.out_dir} metrics={result.metrics}")
164
+
165
+
166
+ @app.command("sweep")
167
+ def cmd_sweep(
168
+ model_dir: Path = typer.Argument(..., exists=True, file_okay=False),
169
+ param: Optional[list[str]] = typer.Option(
170
+ None,
171
+ "--param",
172
+ help="Grid axis KEY=v1,v2 (repeatable); cartesian product of values",
173
+ ),
174
+ set_overrides: Optional[list[str]] = typer.Option(
175
+ None,
176
+ "--set",
177
+ help="Base overrides applied to every trial (repeatable)",
178
+ ),
179
+ metric: Optional[str] = typer.Option(
180
+ None,
181
+ "--metric",
182
+ help="Metric key to rank trials (default: first numeric metric)",
183
+ ),
184
+ smoke: bool = typer.Option(False, "--smoke"),
185
+ limit: Optional[int] = typer.Option(None, "--limit"),
186
+ device: str = typer.Option("auto", "--device"),
187
+ seed: Optional[int] = typer.Option(None, "--seed"),
188
+ max_trials: Optional[int] = typer.Option(
189
+ None, "--max-trials", help="Cap number of grid combinations"
190
+ ),
191
+ ) -> None:
192
+ """Offline cartesian HPO over ``--param`` axes (no Optuna required)."""
193
+ base = load_model_def(model_dir)
194
+ _boot_plugins(base)
195
+ grid = expand_param_grid(param, max_trials=max_trials)
196
+ if not grid:
197
+ console.print("[yellow]empty grid — nothing to run[/]")
198
+ raise typer.Exit(code=0)
199
+
200
+ arch = normalize_architecture(base.architecture)
201
+ trainer = TRAINERS.get(base.architecture) or TRAINERS.require(arch)
202
+ ranking: list[tuple[Optional[float], dict, Any]] = []
203
+
204
+ for i, trial_map in enumerate(grid):
205
+ md = _clone_model_def(base)
206
+ if set_overrides:
207
+ apply_overrides(md, set_overrides)
208
+ apply_overrides(md, overrides_from_mapping(trial_map))
209
+ trial_meta = {"index": i, "params": trial_map}
210
+ console.print(
211
+ f"[cyan]sweep[/] trial {i + 1}/{len(grid)} params={trial_map}"
212
+ )
213
+ result = trainer(
214
+ md,
215
+ smoke=smoke,
216
+ limit=limit,
217
+ device=device,
218
+ seed=seed,
219
+ trial=trial_meta,
220
+ )
221
+ key, val = pick_metric(result.metrics, metric)
222
+ ranking.append((val, trial_map, result))
223
+ console.print(
224
+ f" -> run={Path(result.out_dir).name} "
225
+ f"{key or 'metric'}={val if val is not None else 'n/a'}"
226
+ )
227
+
228
+ scored: list[tuple[float, bool, dict, Any, Optional[str]]] = []
229
+ for val, trial_map, result in ranking:
230
+ key, _ = pick_metric(result.metrics, metric)
231
+ minimize = key is not None and "loss" in key.lower()
232
+ if val is None:
233
+ continue
234
+ scored.append((val, minimize, trial_map, result, key))
235
+
236
+ if scored:
237
+ minimize = scored[0][1]
238
+ scored.sort(key=lambda t: t[0], reverse=not minimize)
239
+ console.print("[bold]sweep ranking[/]")
240
+ for rank, (val, _, trial_map, result, key) in enumerate(scored, start=1):
241
+ console.print(
242
+ f" {rank}. {key}={val:.6g} params={trial_map} "
243
+ f"out={Path(result.out_dir).name}"
244
+ )
245
+ else:
246
+ console.print("[yellow]sweep finished with no numeric metrics to rank[/]")
247
+
248
+
249
+ @app.command("evaluate")
250
+ def cmd_evaluate(
251
+ model_dir: Path = typer.Argument(..., exists=True, file_okay=False),
252
+ checkpoint: Optional[str] = typer.Option(
253
+ None,
254
+ "--checkpoint",
255
+ help="run_id or checkpoint dir; defaults to latest completed run",
256
+ ),
257
+ split: str = typer.Option("test", "--split"),
258
+ device: str = typer.Option("auto", "--device"),
259
+ baseline: Optional[Path] = typer.Option(None, "--baseline"),
260
+ max_input_tokens: int = typer.Option(2048, "--max-input-tokens"),
261
+ limit: Optional[int] = typer.Option(None, "--limit"),
262
+ gate: bool = typer.Option(
263
+ False,
264
+ "--gate",
265
+ help="Enforce evaluation.gates minima; exit non-zero on failure",
266
+ ),
267
+ ) -> None:
268
+ """Evaluate a checkpoint and write report.{json,md} under output/eval/."""
269
+ md = load_model_def(model_dir)
270
+ _boot_plugins(md)
271
+ # Ensure predictor registrations are loaded.
272
+ from .evaluation import predictors as _predictors # noqa: F401
273
+ from .evaluation.harness import run_evaluation
274
+ from .evaluation.runner import write_markdown_summary
275
+ from .runs import get_run, update_run_gates
276
+
277
+ ckpt = resolve_checkpoint(md, checkpoint)
278
+ md.eval_dir.mkdir(parents=True, exist_ok=True)
279
+ out_path = md.eval_dir / f"{ckpt.name}.json"
280
+
281
+ predictor, validator, metrics = _default_eval_keys(md)
282
+ if predictor is None:
283
+ raise typer.BadParameter(
284
+ f"No predictor for architecture={md.architecture!r}; "
285
+ "set evaluation.predictor in model.yml"
286
+ )
287
+
288
+ report = run_evaluation(
289
+ checkpoint_dir=ckpt,
290
+ dataset_dir=md.prepared_dir,
291
+ out_path=out_path,
292
+ model_def=md,
293
+ predictor=predictor,
294
+ validator=validator,
295
+ metrics_fn=metrics,
296
+ device=device,
297
+ split=split,
298
+ max_input_tokens=max_input_tokens,
299
+ baseline_path=baseline,
300
+ limit=limit,
301
+ task=md.task,
302
+ enforce_gates=gate,
303
+ )
304
+ write_markdown_summary(report, out_path.with_suffix(".md"))
305
+
306
+ # Record gate results on the run registry when checkpoint is a known run_id.
307
+ run_rec = get_run(md, ckpt.name)
308
+ if run_rec is not None and report.gates is not None:
309
+ update_run_gates(
310
+ md,
311
+ run_rec.run_id,
312
+ report.gates,
313
+ metrics=report.metrics,
314
+ )
315
+
316
+ console.print(f"[green]done[/] report={out_path}")
317
+ if gate and report.passed is False:
318
+ console.print("[red]eval gates failed[/]")
319
+ raise typer.Exit(code=1)
320
+
321
+
322
+ @app.command("export")
323
+ def cmd_export(
324
+ model_dir: Path = typer.Argument(..., exists=True, file_okay=False),
325
+ checkpoint: Optional[str] = typer.Option(
326
+ None,
327
+ "--checkpoint",
328
+ help="run_id or checkpoint dir; defaults to latest completed run",
329
+ ),
330
+ format: Optional[str] = typer.Option(
331
+ None,
332
+ "--format",
333
+ help="safetensors (default) | gguf | mlx",
334
+ ),
335
+ out: Optional[Path] = typer.Option(
336
+ None,
337
+ "--out",
338
+ help="Export directory (default: <model-dir>/output/export/<run_id>)",
339
+ ),
340
+ parity: bool = typer.Option(
341
+ False,
342
+ "--parity",
343
+ help="Re-run evaluation.gates on dataset.benchmark_samples after export",
344
+ ),
345
+ device: str = typer.Option("auto", "--device"),
346
+ ) -> None:
347
+ """Export a checkpoint as a deployable bundle with manifest.json."""
348
+ md = load_model_def(model_dir)
349
+ _boot_plugins(md)
350
+ from .export.bundle import export_model, run_parity_check
351
+ from .runs import get_run
352
+
353
+ ckpt = resolve_checkpoint(md, checkpoint)
354
+ run_rec = get_run(md, ckpt.name)
355
+ run_id = run_rec.run_id if run_rec else ckpt.name
356
+ out_dir = out if out is not None else (md.output_dir / "export" / run_id)
357
+ try:
358
+ export_model(md, ckpt, out_dir, format=format, run_id=run_id)
359
+ except (ImportError, RuntimeError, ValueError, KeyError, FileNotFoundError) as exc:
360
+ console.print(f"[red]export failed[/] {exc}")
361
+ raise typer.Exit(code=1) from exc
362
+ console.print(f"[green]exported[/] {out_dir}")
363
+
364
+ if parity:
365
+ result = run_parity_check(md, out_dir, device=device)
366
+ if result.get("skipped"):
367
+ console.print(f"[yellow]parity skipped[/] {result.get('reason')}")
368
+ elif not result.get("passed"):
369
+ console.print(f"[red]parity gates failed[/] {result.get('gates')}")
370
+ raise typer.Exit(code=1)
371
+ else:
372
+ console.print("[green]parity ok[/]")
373
+
374
+
375
+ @app.command("verify")
376
+ def cmd_verify(
377
+ path: Path = typer.Argument(
378
+ ...,
379
+ help="Export directory or path to manifest.json",
380
+ ),
381
+ ) -> None:
382
+ """Recompute sha256 of files listed in an export manifest; exit 1 on mismatch."""
383
+ from .export.manifest import verify_manifest
384
+
385
+ errors = verify_manifest(path)
386
+ if errors:
387
+ console.print("[red]verify failed[/]")
388
+ for err in errors:
389
+ console.print(f" - {err}")
390
+ raise typer.Exit(code=1)
391
+ console.print(f"[green]OK[/] {path}")
392
+
393
+
394
+ @app.command("datagen")
395
+ def cmd_datagen(
396
+ model_dir: Path = typer.Argument(..., exists=True, file_okay=False),
397
+ target: int = typer.Option(100, "--target", help="Number of accepted samples"),
398
+ seed: int = typer.Option(0, "--seed"),
399
+ out: Optional[Path] = typer.Option(
400
+ None, "--out", help="Override seed JSONL path"
401
+ ),
402
+ teacher: bool = typer.Option(
403
+ False,
404
+ "--teacher",
405
+ help="Use OpenAI-compatible teacher (MAATML_TEACHER_* env); requires [teacher]",
406
+ ),
407
+ append: bool = typer.Option(True, "--append/--no-append"),
408
+ ) -> None:
409
+ """Generate validator-gated seed rows via a registered generator or teacher."""
410
+ md = load_model_def(model_dir)
411
+ _boot_plugins(md)
412
+ from .data.datagen import run_datagen
413
+
414
+ try:
415
+ result = run_datagen(
416
+ md,
417
+ target=target,
418
+ seed=seed,
419
+ out_path=out,
420
+ use_teacher=teacher,
421
+ append=append,
422
+ )
423
+ except (KeyError, ImportError) as exc:
424
+ console.print(f"[red]datagen failed[/] {exc}")
425
+ raise typer.Exit(code=1) from exc
426
+ console.print(
427
+ f"[green]datagen[/] generator={result['generator']} "
428
+ f"accepted={result['accepted']} rejected={result['rejected']} "
429
+ f"out={result['out_path']}"
430
+ )
431
+
432
+
433
+ @app.command("ingest")
434
+ def cmd_ingest(
435
+ model_dir: Path = typer.Argument(..., exists=True, file_okay=False),
436
+ input_path: Path = typer.Option(
437
+ ...,
438
+ "--input",
439
+ exists=True,
440
+ help="JSON or JSONL file to ingest",
441
+ ),
442
+ field_map: Optional[list[str]] = typer.Option(
443
+ None,
444
+ "--map",
445
+ help="Map dest=src fields (repeatable), e.g. --map request=text",
446
+ ),
447
+ sanitize: Optional[str] = typer.Option(
448
+ None, "--sanitize", help="Sanitizer registry tag (e.g. jcl, spool)"
449
+ ),
450
+ append: bool = typer.Option(True, "--append/--no-append"),
451
+ out: Optional[Path] = typer.Option(None, "--out", help="Override seed JSONL path"),
452
+ ) -> None:
453
+ """Ingest external samples into seed_samples with optional validation."""
454
+ md = load_model_def(model_dir)
455
+ _boot_plugins(md)
456
+ from .data.ingest import ingest_samples
457
+
458
+ try:
459
+ result = ingest_samples(
460
+ md,
461
+ input_path,
462
+ field_map=_parse_field_maps(field_map),
463
+ sanitize_tag=sanitize,
464
+ append=append,
465
+ out_path=out,
466
+ )
467
+ except (KeyError, ValueError, FileNotFoundError) as exc:
468
+ console.print(f"[red]ingest failed[/] {exc}")
469
+ raise typer.Exit(code=1) from exc
470
+ console.print(
471
+ f"[green]ingest[/] accepted={result['accepted']} "
472
+ f"rejected={result['rejected']} seeds={result['seeds_path']}"
473
+ )
474
+
475
+
476
+ @app.command("runs")
477
+ def cmd_runs(
478
+ model_dir: Path = typer.Argument(..., exists=True, file_okay=False),
479
+ ) -> None:
480
+ """List training runs recorded in <model-dir>/output/runs.jsonl."""
481
+ md = load_model_def(model_dir)
482
+ records = list_runs(md)
483
+ if not records:
484
+ console.print(f"[dim]no runs yet under {md.output_dir}[/]")
485
+ return
486
+ for rec in records:
487
+ metrics = ""
488
+ if rec.metrics:
489
+ top = list(rec.metrics.items())[:3]
490
+ metrics = " " + " ".join(f"{k}={v:.4g}" for k, v in top)
491
+ console.print(
492
+ f"{rec.run_id} [{rec.status}] smoke={rec.smoke} "
493
+ f"device={rec.device or '-'} {rec.out_dir}{metrics}"
494
+ )
495
+
496
+
497
+ @app.command("scaffold")
498
+ def cmd_scaffold(
499
+ target_dir: Path = typer.Argument(..., help="Directory to create (model folder)"),
500
+ architecture: str = typer.Option(
501
+ ...,
502
+ "--architecture",
503
+ "-a",
504
+ help="Registered trainer architecture (e.g. causal_sft, seq2seq, classifier, dpo)",
505
+ ),
506
+ name: Optional[str] = typer.Option(None, "--name", help="Model name (default: folder name)"),
507
+ ) -> None:
508
+ """Create a new model folder with model.yml, datasets, and README."""
509
+ discover_plugins()
510
+ path = scaffold_model(target_dir, architecture=architecture, name=name)
511
+ console.print(f"[green]scaffolded[/] {path}")
512
+
513
+
514
+ @app.command("validate")
515
+ def cmd_validate(
516
+ model_dir: Path = typer.Argument(..., exists=True, file_okay=False),
517
+ ) -> None:
518
+ """Validate model.yml paths, architecture registration, and dataset.format."""
519
+ errors = validate_model_dir(model_dir)
520
+ if errors:
521
+ console.print("[red]validate failed[/]")
522
+ for err in errors:
523
+ console.print(f" - {err}")
524
+ raise typer.Exit(code=1)
525
+ md = load_model_def(model_dir)
526
+ console.print(f"[green]OK[/] {md.identity} ({md.architecture})")
527
+
528
+
529
+ @app.command("plan")
530
+ def cmd_plan(
531
+ model_dir: Path = typer.Argument(..., exists=True, file_okay=False),
532
+ ) -> None:
533
+ """Print the lifecycle command plan for a model folder."""
534
+ md = load_model_def(model_dir)
535
+ console.print(f"[bold]{md.identity}[/] ({md.task} / {md.architecture})")
536
+ console.print(f"1. maatml prepare {md.model_dir}")
537
+ console.print(f"2. maatml train {md.model_dir} --smoke")
538
+ console.print(f"3. maatml train {md.model_dir}")
539
+ console.print(f"4. maatml evaluate {md.model_dir}")
540
+ console.print(f"5. maatml export {md.model_dir}")
541
+ console.print(f"6. maatml verify {md.model_dir}/output/export/<run_id>")
542
+
543
+
544
+ @app.command("plugins")
545
+ def cmd_plugins() -> None:
546
+ """List registered trainers, validators, metrics, formats, and predictors."""
547
+ discover_plugins()
548
+ # Import submodule directly so listing plugins does not require torch.
549
+ from .evaluation import predictors as _predictors # noqa: F401
550
+ from .export import bundle as _bundle # noqa: F401
551
+ from .export import gguf as _gguf # noqa: F401
552
+ from .export import mlx_export as _mlx # noqa: F401
553
+
554
+ for kind, entries in list_all_plugins().items():
555
+ console.print(f"[bold]{kind}[/] ({len(entries)})")
556
+ for entry in entries:
557
+ console.print(f" {entry.name} [dim]{entry.source}[/]")
558
+
559
+
560
+ def main() -> None:
561
+ app()
562
+
563
+
564
+ if __name__ == "__main__":
565
+ main()