autoforge-trainer 1.0.2__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 (58) hide show
  1. autoforge/__init__.py +3 -0
  2. autoforge/agents/__init__.py +0 -0
  3. autoforge/agents/loop.py +168 -0
  4. autoforge/agents/providers.py +272 -0
  5. autoforge/api.py +330 -0
  6. autoforge/cli/__init__.py +0 -0
  7. autoforge/cli/agent.py +102 -0
  8. autoforge/cli/data.py +42 -0
  9. autoforge/cli/hpo.py +98 -0
  10. autoforge/cli/main.py +112 -0
  11. autoforge/cli/models.py +172 -0
  12. autoforge/cli/output.py +40 -0
  13. autoforge/cli/packs.py +185 -0
  14. autoforge/cli/quickstart.py +61 -0
  15. autoforge/cli/runs.py +233 -0
  16. autoforge/config.py +31 -0
  17. autoforge/data/__init__.py +0 -0
  18. autoforge/data/profile.py +99 -0
  19. autoforge/data/registry.py +47 -0
  20. autoforge/engine/__init__.py +0 -0
  21. autoforge/engine/context.py +125 -0
  22. autoforge/engine/diagnose.py +134 -0
  23. autoforge/engine/hpo.py +355 -0
  24. autoforge/engine/runner.py +380 -0
  25. autoforge/engine/store.py +207 -0
  26. autoforge/engine/validate.py +172 -0
  27. autoforge/engine/worker.py +68 -0
  28. autoforge/errors.py +17 -0
  29. autoforge/examples/__init__.py +0 -0
  30. autoforge/examples/churn.csv +601 -0
  31. autoforge/gui/__init__.py +0 -0
  32. autoforge/gui/app.py +243 -0
  33. autoforge/gui/static/app.js +546 -0
  34. autoforge/gui/static/index.html +26 -0
  35. autoforge/gui/static/style.css +83 -0
  36. autoforge/hardware/__init__.py +0 -0
  37. autoforge/hardware/detect.py +161 -0
  38. autoforge/packs/__init__.py +0 -0
  39. autoforge/packs/authoring.py +203 -0
  40. autoforge/packs/errors.py +42 -0
  41. autoforge/packs/index.py +149 -0
  42. autoforge/packs/installer.py +241 -0
  43. autoforge/packs/manifest.py +192 -0
  44. autoforge/policy/__init__.py +0 -0
  45. autoforge/policy/privacy.py +68 -0
  46. autoforge/registry/__init__.py +0 -0
  47. autoforge/registry/models.py +350 -0
  48. autoforge/schemas/__init__.py +0 -0
  49. autoforge/schemas/core.py +137 -0
  50. autoforge/serve/__init__.py +0 -0
  51. autoforge/serve/mcp_server.py +293 -0
  52. autoforge/serve/model_mcp.py +98 -0
  53. autoforge/serve/rest.py +87 -0
  54. autoforge_trainer-1.0.2.dist-info/METADATA +216 -0
  55. autoforge_trainer-1.0.2.dist-info/RECORD +58 -0
  56. autoforge_trainer-1.0.2.dist-info/WHEEL +4 -0
  57. autoforge_trainer-1.0.2.dist-info/entry_points.txt +2 -0
  58. autoforge_trainer-1.0.2.dist-info/licenses/LICENSE +21 -0
autoforge/api.py ADDED
@@ -0,0 +1,330 @@
1
+ """Core API shared by the MCP server (and later REST / GUI).
2
+
3
+ Functions return JSON-serializable data and raise AutoForgeError subclasses;
4
+ transports wrap them in the Envelope shape. `actor` says who acts (for the
5
+ audit log); `brain` decides what data the caller may see (privacy guard).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import re
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from autoforge import config
16
+ from autoforge.data import registry
17
+ from autoforge.data.profile import profile
18
+ from autoforge.engine import runner
19
+ from autoforge.engine.store import RunStore, list_runs
20
+ from autoforge.engine.validate import check_plan, parse_plan
21
+ from autoforge.errors import AutoForgeError
22
+ from autoforge.hardware.detect import detect_hardware
23
+ from autoforge.packs import installer
24
+ from autoforge.packs.index import load_index, search
25
+ from autoforge.policy.privacy import require_access
26
+ from autoforge.schemas.core import BrainKind
27
+
28
+ TRAINER_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}\.py$")
29
+
30
+
31
+ class BadRequest(AutoForgeError):
32
+ code = "BAD_REQUEST"
33
+
34
+
35
+ # ---------- hardware / schema ----------
36
+
37
+
38
+ def hardware() -> dict:
39
+ hw = detect_hardware()
40
+ return hw.model_dump() | {"best_device": hw.best_device}
41
+
42
+
43
+ # ---------- packs ----------
44
+
45
+
46
+ def pack_search(query: str | None = None, task: str | None = None, fit: bool = True) -> dict:
47
+ hw = detect_hardware() if fit else None
48
+ index, notes = load_index()
49
+ lock = installer.read_lock()
50
+ hits = [
51
+ h.model_dump(mode="json") | {"installed": h.entry.name in lock}
52
+ for h in search(index, task=task, query=query, hardware=hw)
53
+ ]
54
+ return {"hits": hits, "notes": notes}
55
+
56
+
57
+ def pack_install(source: str, force: bool = False) -> dict:
58
+ return installer.install(source, force=force).model_dump()
59
+
60
+
61
+ def pack_info(name: str, knowledge: bool = True) -> dict:
62
+ rec, pack = installer.get_installed(name)
63
+ data = {
64
+ "installed": rec.model_dump(),
65
+ "manifest": pack.manifest.model_dump(),
66
+ "recipes": [r.model_dump() for r in pack.recipes],
67
+ "missing_dependencies": installer.missing_dependencies(pack),
68
+ }
69
+ if knowledge:
70
+ data["knowledge"] = pack.knowledge()
71
+ return data
72
+
73
+
74
+ def pack_list() -> list[dict]:
75
+ return [r.model_dump() for r in installer.read_lock().values()]
76
+
77
+
78
+ # ---------- data ----------
79
+
80
+
81
+ def data_profile(path: str, brain: BrainKind) -> dict:
82
+ p = Path(path).expanduser()
83
+ if not p.exists():
84
+ raise BadRequest(f"path not found: {path}")
85
+ sens = registry.sensitivity_of(p)
86
+ access = require_access(brain, sens, str(p.resolve()))
87
+ return profile(p, access) | {"sensitivity": sens.value}
88
+
89
+
90
+ # ---------- trainers written by AI ----------
91
+
92
+
93
+ def trainers_dir() -> Path:
94
+ return config.home() / "workspace" / "trainers"
95
+
96
+
97
+ def trainer_write(filename: str, source: str) -> dict:
98
+ """Save AI-written trainer code to the workspace and report its top-level functions."""
99
+ if not TRAINER_NAME_RE.fullmatch(filename):
100
+ raise BadRequest("filename must look like 'my_trainer.py' (letters, digits, '_')")
101
+ try:
102
+ tree = ast.parse(source)
103
+ except SyntaxError as e:
104
+ raise BadRequest(f"syntax error: {e}", hint="Fix the code and write it again.")
105
+ path = trainers_dir() / filename
106
+ path.parent.mkdir(parents=True, exist_ok=True)
107
+ path.write_text(source, encoding="utf-8")
108
+ funcs = [n.name for n in tree.body if isinstance(n, ast.FunctionDef)]
109
+ return {
110
+ "path": str(path),
111
+ "functions": funcs,
112
+ "use_in_plan": f"custom_code_path: {path}:{'train' if 'train' in funcs else (funcs[0] if funcs else 'train')}",
113
+ }
114
+
115
+
116
+ # ---------- plans / runs ----------
117
+
118
+
119
+ def _plan(plan: dict[str, Any], base: Path | None):
120
+ return parse_plan(plan, base or Path.cwd())
121
+
122
+
123
+ def plan_validate(plan: dict[str, Any], actor: str, brain: BrainKind, base: Path | None = None) -> dict:
124
+ p = _plan(plan, base)
125
+ runner.require_data_access(p, actor, brain)
126
+ return check_plan(p).model_dump() | {"resolved_data_path": p.data_path}
127
+
128
+
129
+ def run_start(plan: dict[str, Any], actor: str, brain: BrainKind, base: Path | None = None) -> dict:
130
+ store = runner.create_run(_plan(plan, base), actor, brain=brain)
131
+ runner.start(store, actor)
132
+ return runner.status(store).model_dump(mode="json")
133
+
134
+
135
+ def run_status(run_id: str) -> dict:
136
+ return runner.status(RunStore.open(run_id)).model_dump(mode="json")
137
+
138
+
139
+ def run_wait(run_id: str, timeout_seconds: float = 30) -> dict:
140
+ timeout = max(0.0, min(timeout_seconds, 120.0))
141
+ return runner.wait(RunStore.open(run_id), timeout).model_dump(mode="json")
142
+
143
+
144
+ def run_list(limit: int = 20) -> list[dict]:
145
+ return [runner.status(s).model_dump(mode="json") for s in list_runs()[:limit]]
146
+
147
+
148
+ def run_metrics(run_id: str, last: int = 5) -> dict:
149
+ return runner.metrics_summary(RunStore.open(run_id), last)
150
+
151
+
152
+ def run_logs(run_id: str, tail: int = 50) -> dict:
153
+ return {"lines": RunStore.open(run_id).logs_tail(max(1, min(tail, 500)))}
154
+
155
+
156
+ def run_audit(run_id: str) -> list[dict]:
157
+ return RunStore.open(run_id).audit_log()
158
+
159
+
160
+ def run_pause(run_id: str, actor: str) -> dict:
161
+ return runner.pause(RunStore.open(run_id), actor).model_dump(mode="json")
162
+
163
+
164
+ def run_resume(run_id: str, actor: str) -> dict:
165
+ return runner.resume(RunStore.open(run_id), actor).model_dump(mode="json")
166
+
167
+
168
+ def run_stop(run_id: str, actor: str, grace_seconds: float = 10.0) -> dict:
169
+ return runner.stop(RunStore.open(run_id), actor, grace_seconds).model_dump(mode="json")
170
+
171
+
172
+ def run_retry(run_id: str, actor: str, brain: BrainKind) -> dict:
173
+ store = runner.retry(RunStore.open(run_id), actor, brain=brain)
174
+ runner.start(store, actor)
175
+ return runner.status(store).model_dump(mode="json")
176
+
177
+
178
+ # ---------- P5: diagnosis, live adjustment, comparison, hyperparameter search ----------
179
+
180
+
181
+ def run_diagnose(run_id: str) -> dict:
182
+ from autoforge.engine.diagnose import diagnose
183
+
184
+ return diagnose(RunStore.open(run_id))
185
+
186
+
187
+ def run_adjust(run_id: str, actor: str, changes: dict[str, Any]) -> dict:
188
+ return runner.adjust(RunStore.open(run_id), actor, changes)
189
+
190
+
191
+ def run_compare(run_ids: list[str], metric: str | None = None) -> dict:
192
+ if not run_ids:
193
+ raise BadRequest("give at least one run_id")
194
+ return runner.compare(run_ids, metric)
195
+
196
+
197
+ def hpo_start(
198
+ plan: dict[str, Any], metric: str, n_trials: int, actor: str, brain: BrainKind,
199
+ direction: str | None = None, search_space: dict[str, Any] | None = None,
200
+ timeout_minutes: float | None = None, base: Path | None = None,
201
+ ) -> dict:
202
+ from autoforge.engine import hpo
203
+
204
+ if direction not in (None, "maximize", "minimize"):
205
+ raise BadRequest("direction must be 'maximize' or 'minimize'")
206
+ store = hpo.create_study(_plan(plan, base), metric, n_trials, actor, brain, direction, search_space, timeout_minutes)
207
+ hpo.start_study(store)
208
+ return hpo.status(store).model_dump(mode="json")
209
+
210
+
211
+ def hpo_status(study_id: str) -> dict:
212
+ from autoforge.engine import hpo
213
+
214
+ return hpo.status(hpo.StudyStore.open(study_id)).model_dump(mode="json")
215
+
216
+
217
+ def hpo_wait(study_id: str, timeout_seconds: float = 60) -> dict:
218
+ from autoforge.engine import hpo
219
+
220
+ return hpo.wait(hpo.StudyStore.open(study_id), max(0.0, min(timeout_seconds, 120.0))).model_dump(mode="json")
221
+
222
+
223
+ def hpo_stop(study_id: str, actor: str) -> dict:
224
+ from autoforge.engine import hpo
225
+
226
+ return hpo.stop(hpo.StudyStore.open(study_id), actor).model_dump(mode="json")
227
+
228
+
229
+ def hpo_list(limit: int = 20) -> list[dict]:
230
+ from autoforge.engine import hpo
231
+
232
+ out = []
233
+ for s in hpo.list_studies()[:limit]:
234
+ v = hpo.status(s).model_dump(mode="json")
235
+ v.pop("trials", None)
236
+ out.append(v)
237
+ return out
238
+
239
+
240
+ # ---------- P6: model registry and serving ----------
241
+
242
+
243
+ def model_register(run_id: str, name: str, actor: str, description: str = "") -> dict:
244
+ from autoforge.registry import models as reg
245
+
246
+ return reg.register(run_id, name, actor, description).model_dump(mode="json")
247
+
248
+
249
+ def model_list() -> list[dict]:
250
+ from autoforge.registry import models as reg
251
+
252
+ return [{"model": c.ref, "task": c.task.task_type, "target": c.task.target, "metrics": c.metrics,
253
+ "servable": c.servable, "run_id": c.run_id, "versions": reg.versions(c.name)} for c in reg.list_models()]
254
+
255
+
256
+ def model_info(ref: str) -> dict:
257
+ from autoforge.registry import models as reg
258
+
259
+ card = reg.get_card(ref)
260
+ return card.model_dump(mode="json") | {"versions": reg.versions(card.name),
261
+ "export_formats": ["bundle", *card.exporters]}
262
+
263
+
264
+ def model_predict(ref: str, rows: list[dict[str, Any]]) -> dict:
265
+ from autoforge.registry import models as reg
266
+
267
+ return reg.predict(ref, rows)
268
+
269
+
270
+ def model_export(ref: str, fmt: str, out: str | None = None) -> dict:
271
+ from autoforge.registry import models as reg
272
+
273
+ card = reg.get_card(ref)
274
+ suffix = {"bundle": "zip"}.get(fmt, fmt)
275
+ path = Path(out) if out else config.home() / "exports" / f"{card.name}-v{card.version}.{suffix}"
276
+ return reg.export(ref, fmt, path)
277
+
278
+
279
+ # ---------- P7: pack authoring ----------
280
+
281
+
282
+ def pack_create(name: str, files: dict[str, str], overwrite: bool = False) -> dict:
283
+ from autoforge.packs.authoring import create_from_files
284
+
285
+ return create_from_files(name, files, overwrite)
286
+
287
+
288
+ def pack_test(target: str) -> dict:
289
+ from autoforge.packs.authoring import test_pack
290
+
291
+ path = target if Path(target).is_dir() else installer.get_installed(target)[0].path
292
+ return test_pack(path)
293
+
294
+
295
+ # ---------- P8: helpers for the GUI ----------
296
+
297
+
298
+ def run_series(run_id: str, max_points: int = 500) -> dict[str, list[list[float]]]:
299
+ """Per-step metric curves {name: [[step, value], ...]} for charts. Final metrics (logged without a
300
+ step) are not curves and are left out; so are series with a single point."""
301
+ series: dict[str, list[list[float]]] = {}
302
+ for e in RunStore.open(run_id).events():
303
+ if e.get("type") == "metric" and e.get("step") is not None:
304
+ series.setdefault(e["name"], []).append([e["step"], e["value"]])
305
+ series = {k: v for k, v in series.items() if len(v) >= 2}
306
+ for name, pts in series.items():
307
+ if len(pts) > max_points: # keep the shape, drop points evenly; always keep the last
308
+ stride = len(pts) / max_points
309
+ series[name] = [pts[int(k * stride)] for k in range(max_points - 1)] + [pts[-1]]
310
+ return series
311
+
312
+
313
+ def task_catalog() -> list[dict]:
314
+ """Task types available from installed packs and the index, with the packs offering each."""
315
+ tasks: dict[str, set[str]] = {}
316
+ for rec in installer.read_lock().values():
317
+ try:
318
+ _, pack = installer.get_installed(rec.name)
319
+ except AutoForgeError:
320
+ continue
321
+ for t in pack.manifest.tasks:
322
+ tasks.setdefault(t, set()).add(pack.manifest.name)
323
+ try:
324
+ index, _ = load_index()
325
+ for e in index.packs:
326
+ for t in e.tasks:
327
+ tasks.setdefault(t, set()).add(e.name)
328
+ except AutoForgeError:
329
+ pass
330
+ return [{"task": t, "packs": sorted(p)} for t, p in sorted(tasks.items())]
File without changes
autoforge/cli/agent.py ADDED
@@ -0,0 +1,102 @@
1
+ """`autoforge train "<goal>"`: the built-in agent plans and trains from one sentence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from pathlib import Path
8
+
9
+ import typer
10
+
11
+ from autoforge.agents.loop import AgentEvent, run_agent
12
+ from autoforge.agents.providers import ToolCall, make_provider, parse_brain
13
+ from autoforge.cli.output import emit, errors_as_envelope, fail
14
+
15
+ _SHORT = {"hardware_profile", "pack_list", "run_status", "run_wait", "run_metrics", "run_list"}
16
+
17
+
18
+ def _describe_plan(call: ToolCall) -> str:
19
+ if call.name == "run_retry":
20
+ return f"retry run {call.arguments.get('run_id')}"
21
+ if call.name == "hpo_start":
22
+ a = call.arguments
23
+ head = (f" hyperparameter search: {a.get('n_trials', 10)} trials, optimise {a.get('metric')}"
24
+ f" ({a.get('direction') or 'auto direction'})\n")
25
+ return head + _describe_plan(ToolCall(call.id, "run_start", {"plan": a.get("plan")}))
26
+ plan = call.arguments.get("plan") or {}
27
+ task = plan.get("task") or {}
28
+ method = (f"{plan.get('pack')} / {plan.get('recipe')}" if plan.get("pack")
29
+ else f"custom code {plan.get('custom_code_path')}")
30
+ lines = [
31
+ f" task: {task.get('task_type')} (target: {task.get('target')}, metric: {task.get('metric')})",
32
+ f" data: {plan.get('data_path')}",
33
+ f" method: {method}",
34
+ f" device: {plan.get('device', 'auto')}",
35
+ f" hyperparameters: {json.dumps(plan.get('hyperparameters') or {}, ensure_ascii=False)}",
36
+ ]
37
+ return "\n".join(lines)
38
+
39
+
40
+ async def _ask(call: ToolCall) -> tuple[bool, str]:
41
+ typer.echo("\n" + typer.style("The agent wants to start training:", bold=True))
42
+ typer.echo(_describe_plan(call))
43
+ answer = typer.prompt("Start? [y = yes / n = no / or type feedback for the agent]", default="n").strip()
44
+ if answer.lower() in ("y", "yes"):
45
+ return True, ""
46
+ return False, "" if answer.lower() in ("n", "no") else answer
47
+
48
+
49
+ def _printer(event: AgentEvent) -> None:
50
+ d = event.data
51
+ if event.kind == "text":
52
+ typer.echo("\n" + d["text"])
53
+ elif event.kind == "tool_call":
54
+ args = "" if event.data["name"] in _SHORT else json.dumps(d["arguments"], ensure_ascii=False)[:160]
55
+ typer.echo(typer.style(f" → {d['name']} {args}", dim=True))
56
+ elif event.kind == "tool_result" and not d["ok"]:
57
+ typer.echo(typer.style(f" ✗ {d['name']}: {d['error']}", fg="yellow"))
58
+ elif event.kind == "info":
59
+ typer.echo(typer.style(d["message"], fg="yellow"))
60
+
61
+
62
+ def train(
63
+ goal: str = typer.Argument(help="What you want, in plain words. e.g. 'predict churned from this CSV'"),
64
+ data: Path = typer.Option(None, "--data", "-d", help="Dataset file or directory."),
65
+ brain: str = typer.Option(
66
+ None, "--brain", "-b",
67
+ help="provider:model. anthropic:claude-opus-5-5 (default), openai:<model>, ollama:<model>, local:<model>. "
68
+ "Default from $AUTOFORGE_BRAIN.",
69
+ ),
70
+ base_url: str = typer.Option(None, "--base-url", help="OpenAI-compatible endpoint for openai:/local:/ollama:."),
71
+ api_key_env: str = typer.Option(None, "--api-key-env", help="Env var holding the API key for openai: (default OPENAI_API_KEY)."),
72
+ yes: bool = typer.Option(False, "--yes", "-y", help="Do not ask before starting training."),
73
+ max_steps: int = typer.Option(40, "--max-steps", help="Maximum model turns."),
74
+ as_json: bool = typer.Option(False, "--json", help="Machine-readable result (requires --yes)."),
75
+ ) -> None:
76
+ """Let an AI analyse your data and hardware, choose a method, train and report — from one sentence."""
77
+ if as_json and not yes:
78
+ fail(True, "BAD_REQUEST", "--json needs --yes (no interactive approval possible)")
79
+ with errors_as_envelope(as_json):
80
+ spec = parse_brain(brain, base_url, api_key_env)
81
+ provider = make_provider(spec)
82
+ if not as_json:
83
+ where = "local model" if spec.is_local else "cloud model"
84
+ typer.echo(typer.style(f"Brain: {spec.provider}:{spec.model} ({where}) — actor {spec.actor}", dim=True))
85
+ with errors_as_envelope(as_json):
86
+ result = asyncio.run(run_agent(
87
+ goal,
88
+ provider,
89
+ actor=spec.actor,
90
+ brain=spec.brain_kind,
91
+ approve=None if yes else _ask,
92
+ on_event=None if as_json else _printer,
93
+ max_steps=max_steps,
94
+ data_path=str(data) if data else None,
95
+ ))
96
+ data_out = {"stop": result.stop, "steps": result.steps, "runs": result.runs,
97
+ "final_text": result.final_text, "transcript": result.transcript}
98
+ human = (f"\n— {result.stop} after {result.steps} step(s); runs: {', '.join(result.runs) or 'none'}"
99
+ f"\n transcript: {result.transcript}")
100
+ emit(as_json, data_out, human)
101
+ if result.stop not in ("end",):
102
+ raise typer.Exit(1)
autoforge/cli/data.py ADDED
@@ -0,0 +1,42 @@
1
+ """`autoforge data ...`: mark dataset sensitivity (humans only) and profile data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from autoforge.cli.output import JsonOpt, emit, errors_as_envelope
10
+ from autoforge.data import registry
11
+
12
+ data_app = typer.Typer(help="Dataset sensitivity and profiling.", no_args_is_help=True)
13
+
14
+
15
+ @data_app.command("mark")
16
+ def mark(
17
+ path: Path,
18
+ sensitivity: registry.Sensitivity = typer.Option(..., "--sensitivity", "-s",
19
+ help="public / internal / sensitive"),
20
+ as_json: bool = JsonOpt,
21
+ ) -> None:
22
+ """Mark a file or directory. Sensitive data can only be used by a human or a local-model AI."""
23
+ key = registry.mark(path, sensitivity)
24
+ emit(as_json, {"path": key, "sensitivity": sensitivity.value}, f"{key}: {sensitivity.value}")
25
+
26
+
27
+ @data_app.command("list")
28
+ def list_marks(as_json: bool = JsonOpt) -> None:
29
+ """All marked paths (unmarked data is 'internal')."""
30
+ marks = registry.all_marks()
31
+ emit(as_json, marks, "\n".join(f"{v:<10} {k}" for k, v in marks.items()) or "(nothing marked; default: internal)")
32
+
33
+
34
+ @data_app.command("profile")
35
+ def profile(path: Path, as_json: bool = JsonOpt) -> None:
36
+ """Profile a dataset (as a human: full access, including sample rows)."""
37
+ from autoforge import api
38
+ from autoforge.schemas.core import BrainKind
39
+
40
+ with errors_as_envelope(as_json):
41
+ data = api.data_profile(str(path), BrainKind.human)
42
+ emit(as_json, data)
autoforge/cli/hpo.py ADDED
@@ -0,0 +1,98 @@
1
+ """`autoforge hpo ...`: hyperparameter search."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ import yaml
10
+
11
+ from autoforge.cli.output import JsonOpt, emit, errors_as_envelope
12
+ from autoforge.cli.runs import ActorOpt, _actor
13
+
14
+ hpo_app = typer.Typer(help="Hyperparameter search (each trial is a normal run).", no_args_is_help=True)
15
+
16
+
17
+ def _line(v: dict) -> str:
18
+ best = "-" if v["best_value"] is None else f"{v['best_value']:.4g}"
19
+ base = "-" if v["baseline_value"] is None else f"{v['baseline_value']:.4g}"
20
+ imp = "" if v["improvement"] is None else f" ({v['improvement']:+.4g} vs baseline)"
21
+ return (f"{v['study_id']:<34} {v['status']:<10} {v['completed']}/{v['n_trials']} trials, "
22
+ f"{v['metric']} best={best} baseline={base}{imp}")
23
+
24
+
25
+ @hpo_app.command("start")
26
+ def start(
27
+ plan_file: Path,
28
+ metric: str = typer.Option(..., "--metric", "-m", help="Run metric to optimise, e.g. f1_macro or rmse."),
29
+ trials: int = typer.Option(20, "--trials", "-n"),
30
+ direction: str = typer.Option(None, "--direction", help="maximize / minimize (default: inferred from the metric)."),
31
+ space_file: Path = typer.Option(None, "--space", help="YAML search space (default: the recipe's search_space)."),
32
+ timeout_min: float = typer.Option(None, "--timeout-min", help="Stop starting new trials after N minutes."),
33
+ wait: bool = typer.Option(False, "--wait", help="Block and print each finished trial."),
34
+ actor: str = ActorOpt,
35
+ as_json: bool = JsonOpt,
36
+ ) -> None:
37
+ """Start a search. Trial 0 is the plan's own hyperparameters, the baseline."""
38
+ from autoforge import api
39
+ from autoforge.policy.privacy import brain_from_actor
40
+
41
+ space = yaml.safe_load(space_file.read_text(encoding="utf-8")) if space_file else None
42
+ who = _actor(actor)
43
+ with errors_as_envelope(as_json):
44
+ plan = yaml.safe_load(plan_file.read_text(encoding="utf-8"))
45
+ view = api.hpo_start(plan, metric, trials, who, brain_from_actor(who), direction, space, timeout_min,
46
+ base=plan_file.resolve().parent)
47
+ sid = view["study_id"]
48
+ if not wait:
49
+ emit(as_json, view, f"Started {sid}\nPoll with: autoforge hpo status {sid}")
50
+ return
51
+ if not as_json:
52
+ typer.echo(f"Started {sid}")
53
+ seen = 0
54
+ while True:
55
+ view = api.hpo_status(sid)
56
+ if not as_json:
57
+ for t in view["trials"][seen:]:
58
+ tag = " (baseline)" if t.get("baseline") else ""
59
+ res = f"{metric}={t['value']:.4g}" if "value" in t else f"failed: {t.get('error')}"
60
+ typer.echo(f" trial {t['number']}{tag}: {res} {t['params']}")
61
+ seen = len(view["trials"])
62
+ if view["status"] not in ("pending", "running"):
63
+ break
64
+ time.sleep(1)
65
+ emit(as_json, view, _line(view) + (f"\nbest params: {view['best_params']} run: {view['best_run_id']}"
66
+ if view["best_params"] is not None else ""))
67
+
68
+
69
+ @hpo_app.command("status")
70
+ def status(study_id: str, as_json: bool = JsonOpt) -> None:
71
+ """Progress, best result and every trial."""
72
+ from autoforge import api
73
+
74
+ with errors_as_envelope(as_json):
75
+ v = api.hpo_status(study_id)
76
+ lines = [_line(v)] + [
77
+ f" trial {t['number']}{' (baseline)' if t.get('baseline') else ''}: "
78
+ f"{t.get('value', 'failed')} {t['params']}" for t in v["trials"]]
79
+ emit(as_json, v, "\n".join(lines))
80
+
81
+
82
+ @hpo_app.command("list")
83
+ def list_cmd(as_json: bool = JsonOpt) -> None:
84
+ """Recent searches."""
85
+ from autoforge import api
86
+
87
+ rows = api.hpo_list()
88
+ emit(as_json, rows, "\n".join(map(_line, rows)) or "(no searches)")
89
+
90
+
91
+ @hpo_app.command("stop")
92
+ def stop(study_id: str, actor: str = ActorOpt, as_json: bool = JsonOpt) -> None:
93
+ """Stop a search and its current trial; finished trials are kept."""
94
+ from autoforge import api
95
+
96
+ with errors_as_envelope(as_json):
97
+ v = api.hpo_stop(study_id, _actor(actor))
98
+ emit(as_json, v, _line(v))
autoforge/cli/main.py ADDED
@@ -0,0 +1,112 @@
1
+ """AutoForge CLI. Every command supports --json and emits the Envelope shape,
2
+ so AI agents can call it as reliably as the MCP server."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import typer
7
+
8
+ from autoforge import __version__
9
+ from autoforge.cli.output import JsonOpt, emit, fail
10
+ from autoforge.cli.packs import index_app, pack_app
11
+ from autoforge.cli.agent import train
12
+ from autoforge.cli.data import data_app
13
+ from autoforge.cli.hpo import hpo_app
14
+ from autoforge.cli.models import gui, model_app, serve, serve_mcp
15
+ from autoforge.cli.quickstart import quickstart
16
+ from autoforge.cli.runs import plan_app, run_app
17
+ from autoforge.hardware.detect import detect_hardware
18
+ from autoforge.engine.runner import RunView
19
+ from autoforge.registry.models import ModelCard
20
+ from autoforge.packs.index import IndexEntry, PackIndex
21
+ from autoforge.packs.manifest import PackManifest, RecipeSpec
22
+ from autoforge.schemas.core import SCHEMAS
23
+
24
+ app = typer.Typer(help="AutoForge: AI that trains AI.", no_args_is_help=True)
25
+ app.add_typer(pack_app, name="pack")
26
+ app.add_typer(index_app, name="index")
27
+ app.add_typer(plan_app, name="plan")
28
+ app.add_typer(run_app, name="run")
29
+ app.add_typer(data_app, name="data")
30
+ app.add_typer(hpo_app, name="hpo")
31
+ app.add_typer(model_app, name="model")
32
+ app.command()(serve)
33
+ app.command("serve-mcp")(serve_mcp)
34
+ app.command()(train)
35
+ app.command()(gui)
36
+ app.command()(quickstart)
37
+
38
+ ALL_SCHEMAS = SCHEMAS | {
39
+ "PackManifest": PackManifest,
40
+ "RecipeSpec": RecipeSpec,
41
+ "PackIndex": PackIndex,
42
+ "IndexEntry": IndexEntry,
43
+ "RunView": RunView,
44
+ "ModelCard": ModelCard,
45
+ }
46
+
47
+
48
+ @app.command()
49
+ def version(as_json: bool = JsonOpt) -> None:
50
+ """Show the AutoForge version."""
51
+ emit(as_json, {"version": __version__}, f"autoforge {__version__}")
52
+
53
+
54
+ @app.command()
55
+ def doctor(as_json: bool = JsonOpt) -> None:
56
+ """Detect CPU / RAM / NVIDIA GPU / CUDA and report the training device."""
57
+ hw = detect_hardware()
58
+ data = hw.model_dump() | {"best_device": hw.best_device}
59
+ if as_json:
60
+ emit(True, data)
61
+ return
62
+ lines = [
63
+ f"OS: {hw.os} (Python {hw.python_version})",
64
+ f"CPU: {hw.cpu.model} ({hw.cpu.physical_cores}C/{hw.cpu.logical_cores}T)",
65
+ f"RAM: {hw.ram_available_gb} / {hw.ram_total_gb} GB available",
66
+ f"Disk: {hw.disk_free_gb} GB free",
67
+ ]
68
+ if hw.gpus:
69
+ for g in hw.gpus:
70
+ lines.append(
71
+ f"GPU {g.index}: {g.name}, {g.vram_total_gb} GB VRAM, "
72
+ f"CC {g.compute_capability}, driver {g.driver_version}"
73
+ )
74
+ lines.append(f"CUDA: {hw.cuda_version}")
75
+ else:
76
+ lines.append("GPU: none (NVIDIA)")
77
+ lines.append(f"Torch: {hw.torch_version or 'not installed'} (CUDA usable: {hw.torch_cuda_available})")
78
+ lines.append(f"Device: {hw.best_device}")
79
+ for n in hw.detection_notes:
80
+ lines.append(f" note: {n}")
81
+ emit(False, human="\n".join(lines))
82
+
83
+
84
+ @app.command()
85
+ def mcp(
86
+ actor: str = typer.Option(
87
+ "ai:mcp", "--actor",
88
+ help="Recorded in audit logs. Use 'ai:local:<model>' only if the MCP client is driven by a local model.",
89
+ ),
90
+ ) -> None:
91
+ """Run the MCP server over stdio (for Claude Desktop, Claude Code and other MCP clients)."""
92
+ from autoforge.serve.mcp_server import main as serve
93
+
94
+ serve(actor)
95
+
96
+
97
+ @app.command()
98
+ def schema(
99
+ name: str = typer.Argument(None, help="Schema name; omit to list all."),
100
+ as_json: bool = JsonOpt,
101
+ ) -> None:
102
+ """Print the JSON Schema of a core data structure."""
103
+ if name is None:
104
+ emit(as_json, sorted(ALL_SCHEMAS), "\n".join(sorted(ALL_SCHEMAS)))
105
+ return
106
+ if name not in ALL_SCHEMAS:
107
+ fail(as_json, "UNKNOWN_SCHEMA", f"No schema named '{name}'", f"Choose from: {', '.join(sorted(ALL_SCHEMAS))}")
108
+ emit(as_json, ALL_SCHEMAS[name].model_json_schema())
109
+
110
+
111
+ if __name__ == "__main__":
112
+ app()