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
@@ -0,0 +1,172 @@
1
+ """`autoforge model ...`, `autoforge serve` and `autoforge serve-mcp`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+
9
+ import typer
10
+
11
+ from autoforge.cli.output import JsonOpt, emit, errors_as_envelope, fail
12
+ from autoforge.cli.runs import ActorOpt, _actor
13
+
14
+ model_app = typer.Typer(help="Registered models: register, inspect, predict, export.", no_args_is_help=True)
15
+
16
+
17
+ @model_app.command("register")
18
+ def register(
19
+ run_id: str,
20
+ name: str = typer.Option(..., "--name", "-n", help="Model name (lowercase, digits, '-', '_')."),
21
+ description: str = typer.Option("", "--description", "-d"),
22
+ actor: str = ActorOpt,
23
+ as_json: bool = JsonOpt,
24
+ ) -> None:
25
+ """Turn a succeeded run into the next version of a model."""
26
+ from autoforge import api
27
+
28
+ with errors_as_envelope(as_json):
29
+ card = api.model_register(run_id, name, _actor(actor), description)
30
+ note = "" if card["servable"] else "\n(not servable: the recipe has no predictor)"
31
+ emit(as_json, card, f"Registered {card['name']}:v{card['version']} from {run_id}{note}")
32
+
33
+
34
+ @model_app.command("list")
35
+ def list_cmd(as_json: bool = JsonOpt) -> None:
36
+ """Registered models (latest version of each)."""
37
+ from autoforge import api
38
+
39
+ rows = api.model_list()
40
+ human = "\n".join(
41
+ f"{r['model']:<28} {r['task']:<24} " + ", ".join(f"{k}={v:.4g}" for k, v in r["metrics"].items())
42
+ for r in rows) or "(no models)"
43
+ emit(as_json, rows, human)
44
+
45
+
46
+ @model_app.command("info")
47
+ def info(model: str, as_json: bool = JsonOpt) -> None:
48
+ """Model card: features, labels, metrics, source run."""
49
+ from autoforge import api
50
+
51
+ with errors_as_envelope(as_json):
52
+ emit(as_json, api.model_info(model))
53
+
54
+
55
+ @model_app.command("predict")
56
+ def predict(
57
+ model: str,
58
+ rows_json: str = typer.Option(None, "--rows", help='JSON list of objects, e.g. \'[{"age": 30}]\''),
59
+ rows_file: Path = typer.Option(None, "--file", "-f", help="JSON file, or CSV with a header row."),
60
+ as_json: bool = JsonOpt,
61
+ ) -> None:
62
+ """Predict with a registered model."""
63
+ import csv
64
+
65
+ from autoforge import api
66
+
67
+ if rows_file:
68
+ if rows_file.suffix.lower() == ".csv":
69
+ with rows_file.open(newline="", encoding="utf-8") as f:
70
+ rows = [{k: v for k, v in r.items()} for r in csv.DictReader(f)]
71
+ else:
72
+ rows = json.loads(rows_file.read_text(encoding="utf-8"))
73
+ elif rows_json:
74
+ rows = json.loads(rows_json)
75
+ else:
76
+ fail(as_json, "BAD_REQUEST", "give --rows or --file")
77
+ with errors_as_envelope(as_json):
78
+ out = api.model_predict(model, rows)
79
+ emit(as_json, out)
80
+
81
+
82
+ @model_app.command("export")
83
+ def export(
84
+ model: str,
85
+ fmt: str = typer.Option("bundle", "--format", help="bundle (zip) or a format the model supports, e.g. onnx."),
86
+ out: Path = typer.Option(None, "--out", "-o"),
87
+ as_json: bool = JsonOpt,
88
+ ) -> None:
89
+ """Export a model as a portable bundle or another format (e.g. ONNX)."""
90
+ from autoforge import api
91
+
92
+ with errors_as_envelope(as_json):
93
+ res = api.model_export(model, fmt, str(out) if out else None)
94
+ emit(as_json, res, f"Exported {res['model']} ({fmt}) → {res['path']} sha256 {res['sha256'][:16]}…")
95
+
96
+
97
+ @model_app.command("import")
98
+ def import_cmd(bundle: Path, name: str = typer.Option(None, "--name"), actor: str = ActorOpt,
99
+ as_json: bool = JsonOpt) -> None:
100
+ """Import a bundle exported elsewhere as a new version."""
101
+ from autoforge.registry import models as reg
102
+
103
+ with errors_as_envelope(as_json):
104
+ card = reg.import_bundle(bundle, name, _actor(actor))
105
+ emit(as_json, card.model_dump(mode="json"), f"Imported {card.ref}")
106
+
107
+
108
+ @model_app.command("remove")
109
+ def remove(model: str, as_json: bool = JsonOpt) -> None:
110
+ """Delete a model version (name:vN) or all versions (name)."""
111
+ from autoforge.registry import models as reg
112
+
113
+ with errors_as_envelope(as_json):
114
+ gone = reg.remove(model)
115
+ emit(as_json, gone, "Removed " + ", ".join(gone))
116
+
117
+
118
+ def serve(
119
+ model: list[str] = typer.Option(None, "--model", "-m", help="Only serve these model names (repeatable)."),
120
+ host: str = typer.Option("127.0.0.1", "--host", help="Use 0.0.0.0 to accept connections from other machines."),
121
+ port: int = typer.Option(8000, "--port", "-p"),
122
+ api_key_env: str = typer.Option(None, "--api-key-env", help="Env var holding an API key clients must send."),
123
+ ) -> None:
124
+ """Serve registered models over HTTP (REST)."""
125
+ import uvicorn
126
+
127
+ from autoforge.serve.rest import create_app
128
+
129
+ key = None
130
+ if api_key_env:
131
+ key = os.environ.get(api_key_env)
132
+ if not key:
133
+ fail(False, "BAD_REQUEST", f"{api_key_env} is not set")
134
+ if host not in ("127.0.0.1", "localhost") and not key:
135
+ typer.echo(typer.style("warning: serving on a network interface without an API key", fg="yellow"), err=True)
136
+ typer.echo(f"Serving {'all models' if not model else ', '.join(model)} on http://{host}:{port} (docs: docs/DEPLOY.md)")
137
+ uvicorn.run(create_app(model or None, key), host=host, port=port, log_level="info")
138
+
139
+
140
+ def serve_mcp(
141
+ model: list[str] = typer.Option(None, "--model", "-m", help="Only expose these model names (repeatable)."),
142
+ ) -> None:
143
+ """Expose registered models as MCP tools (predict_<name>) over stdio — prediction only, no training."""
144
+ from autoforge.serve.model_mcp import main
145
+
146
+ main(model or None)
147
+
148
+
149
+ def gui(
150
+ port: int = typer.Option(8800, "--port", "-p"),
151
+ host: str = typer.Option("127.0.0.1", "--host",
152
+ help="Bind address. Use 0.0.0.0 only inside Docker with -p 127.0.0.1:8800:8800."),
153
+ open_browser: bool = typer.Option(True, "--open/--no-open", help="Open the browser automatically."),
154
+ ) -> None:
155
+ """Open the web interface (local only: 127.0.0.1, protected by a one-time token)."""
156
+ import secrets
157
+ import threading
158
+ import webbrowser
159
+
160
+ import uvicorn
161
+
162
+ from autoforge.gui.app import create_app
163
+
164
+ token = secrets.token_urlsafe(24)
165
+ url = f"http://127.0.0.1:{port}/"
166
+ typer.echo(f"AutoForge GUI: {url} (Ctrl+C to stop)")
167
+ if host not in ("127.0.0.1", "localhost"):
168
+ typer.echo(typer.style(f"warning: binding {host}; the GUI still only answers requests addressed to "
169
+ "localhost/127.0.0.1, so publish the port only on 127.0.0.1", fg="yellow"), err=True)
170
+ if open_browser:
171
+ threading.Timer(1.0, lambda: webbrowser.open(url)).start()
172
+ uvicorn.run(create_app(token), host=host, port=port, log_level="warning")
@@ -0,0 +1,40 @@
1
+ """Shared output helpers: every command emits the Envelope shape with --json."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from contextlib import contextmanager
7
+ from typing import Any, Iterator
8
+
9
+ import typer
10
+
11
+ from autoforge.errors import AutoForgeError
12
+ from autoforge.schemas.core import Envelope, ErrorInfo
13
+
14
+ JsonOpt = typer.Option(False, "--json", help="Machine-readable JSON output.")
15
+
16
+
17
+ def emit(as_json: bool, data: Any = None, human: str | None = None) -> None:
18
+ if as_json:
19
+ typer.echo(Envelope(ok=True, data=data).model_dump_json(indent=2))
20
+ else:
21
+ typer.echo(human if human is not None else json.dumps(data, indent=2, ensure_ascii=False))
22
+
23
+
24
+ def fail(as_json: bool, code: str, message: str, hint: str | None = None, details: Any = None) -> None:
25
+ if as_json:
26
+ err = ErrorInfo(code=code, message=message, hint=hint)
27
+ env = Envelope(ok=False, error=err, data=details)
28
+ typer.echo(env.model_dump_json(indent=2))
29
+ else:
30
+ typer.echo(f"Error [{code}]: {message}" + (f"\nHint: {hint}" if hint else ""), err=True)
31
+ raise typer.Exit(1)
32
+
33
+
34
+ @contextmanager
35
+ def errors_as_envelope(as_json: bool) -> Iterator[None]:
36
+ """Turn any AutoForgeError into an error envelope carrying its code and details."""
37
+ try:
38
+ yield
39
+ except AutoForgeError as e:
40
+ fail(as_json, e.code, str(e), e.hint, e.details)
autoforge/cli/packs.py ADDED
@@ -0,0 +1,185 @@
1
+ """`autoforge pack ...` and `autoforge index ...` commands."""
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, fail
10
+ from autoforge.packs import installer
11
+ from autoforge.packs.index import PackIndex, dump_index, entry_from_pack, load_index, parse_index, search
12
+ from autoforge.packs.manifest import collect_problems, load_pack
13
+
14
+ pack_app = typer.Typer(help="Install, inspect and search method packs.", no_args_is_help=True)
15
+ index_app = typer.Typer(help="Maintain a pack index (index.json).", no_args_is_help=True)
16
+
17
+
18
+ @pack_app.command("validate")
19
+ def validate(path: Path, as_json: bool = JsonOpt) -> None:
20
+ """Check a pack directory without executing any of its code."""
21
+ pack, problems = collect_problems(path)
22
+ if pack is None:
23
+ fail(as_json, "PACK_INVALID", f"{len(problems)} problem(s)", None, {"problems": problems})
24
+ emit(as_json, {"name": pack.manifest.name, "version": pack.manifest.version,
25
+ "recipes": [r.id for r in pack.recipes]},
26
+ f"OK: {pack.manifest.name} {pack.manifest.version} ({len(pack.recipes)} recipes)")
27
+
28
+
29
+ @pack_app.command("install")
30
+ def install(
31
+ source: str = typer.Argument(help="index name, ./local/path, or github:owner/repo[/subdir][@ref]"),
32
+ force: bool = typer.Option(False, "--force", help="Reinstall if already installed."),
33
+ as_json: bool = JsonOpt,
34
+ ) -> None:
35
+ """Install a pack and pin its commit in the lock file."""
36
+ with errors_as_envelope(as_json):
37
+ rec = installer.install(source, force=force)
38
+ emit(as_json, rec.model_dump(), f"Installed {rec.name} {rec.version} from {rec.source}")
39
+
40
+
41
+ @pack_app.command("remove")
42
+ def remove(name: str, as_json: bool = JsonOpt) -> None:
43
+ """Uninstall a pack."""
44
+ with errors_as_envelope(as_json):
45
+ rec = installer.remove(name)
46
+ emit(as_json, rec.model_dump(), f"Removed {rec.name}")
47
+
48
+
49
+ @pack_app.command("list")
50
+ def list_installed(as_json: bool = JsonOpt) -> None:
51
+ """List installed packs."""
52
+ lock = installer.read_lock()
53
+ rows = [r.model_dump() for r in lock.values()]
54
+ human = "\n".join(f"{r.name:<28} {r.version:<10} {r.source}" for r in lock.values()) or "(no packs installed)"
55
+ emit(as_json, rows, human)
56
+
57
+
58
+ @pack_app.command("info")
59
+ def info(
60
+ name: str,
61
+ knowledge: bool = typer.Option(True, "--knowledge/--no-knowledge", help="Include KNOWLEDGE.md text."),
62
+ as_json: bool = JsonOpt,
63
+ ) -> None:
64
+ """Show an installed pack's manifest, recipes, missing deps and KNOWLEDGE.md (for AI reading)."""
65
+ with errors_as_envelope(as_json):
66
+ rec, pack = installer.get_installed(name)
67
+ data = {
68
+ "installed": rec.model_dump(),
69
+ "manifest": pack.manifest.model_dump(),
70
+ "recipes": [r.model_dump() for r in pack.recipes],
71
+ "missing_dependencies": installer.missing_dependencies(pack),
72
+ }
73
+ if knowledge:
74
+ data["knowledge"] = pack.knowledge()
75
+ emit(as_json, data)
76
+
77
+
78
+ @pack_app.command("search")
79
+ def search_cmd(
80
+ query: str = typer.Argument(None, help="Free-text keywords."),
81
+ task: str = typer.Option(None, "--task", help="Exact task type, e.g. tabular-classification."),
82
+ fit: bool = typer.Option(False, "--fit", help="Only recipes this machine's hardware can run."),
83
+ offline: bool = typer.Option(False, "--offline", help="Use the cached index only."),
84
+ as_json: bool = JsonOpt,
85
+ ) -> None:
86
+ """Search the pack index."""
87
+ hw = None
88
+ if fit:
89
+ from autoforge.hardware.detect import detect_hardware
90
+
91
+ hw = detect_hardware()
92
+ with errors_as_envelope(as_json):
93
+ index, notes = load_index(refresh=not offline)
94
+ hits = search(index, task=task, query=query, hardware=hw)
95
+ lock = installer.read_lock()
96
+ rows = [
97
+ h.model_dump(mode="json") | {"installed": h.entry.name in lock, "install_with": h.entry.name}
98
+ for h in hits
99
+ ]
100
+ human = "\n".join(
101
+ f"{h.entry.name:<28} {h.entry.version:<8} {', '.join(h.runnable_recipes):<30} {h.entry.summary}"
102
+ for h in hits
103
+ ) or "(no matches)"
104
+ if as_json:
105
+ emit(True, {"hits": rows, "notes": notes})
106
+ else:
107
+ emit(False, human="\n".join([*(f"note: {n}" for n in notes), human]))
108
+
109
+
110
+ @index_app.command("entry")
111
+ def index_entry(
112
+ path: Path,
113
+ source: str = typer.Option(..., help="github:owner/repo[/subdir] where the pack lives"),
114
+ commit: str = typer.Option(..., help="40-char commit SHA to pin"),
115
+ tag: list[str] = typer.Option([], "--tag", help="Search tag; repeatable."),
116
+ as_json: bool = JsonOpt,
117
+ ) -> None:
118
+ """Print the index.json entry for a local pack checkout."""
119
+ with errors_as_envelope(as_json):
120
+ pack = load_pack(path)
121
+ entry = entry_from_pack(pack, source, commit, tag)
122
+ emit(as_json, entry.model_dump(mode="json"))
123
+
124
+
125
+ @index_app.command("add")
126
+ def index_add(
127
+ index_file: Path,
128
+ path: Path,
129
+ source: str = typer.Option(...),
130
+ commit: str = typer.Option(...),
131
+ tag: list[str] = typer.Option([], "--tag"),
132
+ as_json: bool = JsonOpt,
133
+ ) -> None:
134
+ """Add or replace a pack's entry in an index.json file."""
135
+ with errors_as_envelope(as_json):
136
+ pack = load_pack(path)
137
+ index = parse_index(index_file.read_text(encoding="utf-8")) if index_file.exists() else PackIndex()
138
+ entry = entry_from_pack(pack, source, commit, tag)
139
+ index.packs = sorted([p for p in index.packs if p.name != entry.name] + [entry], key=lambda p: p.name)
140
+ index_file.write_text(dump_index(index), encoding="utf-8")
141
+ emit(as_json, entry.model_dump(mode="json"), f"Wrote {entry.name} {entry.version} to {index_file}")
142
+
143
+
144
+ @index_app.command("validate")
145
+ def index_validate(index_file: Path, as_json: bool = JsonOpt) -> None:
146
+ """Validate an index.json file (structure, SHAs, duplicate names)."""
147
+ with errors_as_envelope(as_json):
148
+ index = parse_index(index_file.read_text(encoding="utf-8"))
149
+ emit(as_json, {"packs": len(index.packs)}, f"OK: {len(index.packs)} pack(s)")
150
+
151
+
152
+ @pack_app.command("new")
153
+ def new(
154
+ name: str,
155
+ task: str = typer.Option(..., "--task", help="Task type, e.g. text-classification."),
156
+ dest: Path = typer.Option(Path("."), "--dest", help="Parent directory."),
157
+ as_json: bool = JsonOpt,
158
+ ) -> None:
159
+ """Create a valid pack skeleton (pack.yaml, KNOWLEDGE.md, recipe, trainer, smoke test)."""
160
+ from autoforge.packs.authoring import scaffold
161
+
162
+ with errors_as_envelope(as_json):
163
+ res = scaffold(name, task, dest)
164
+ emit(as_json, res, f"Created {res['path']}\nNext: edit trainers/train.py and KNOWLEDGE.md, then "
165
+ f"`autoforge pack test {res['path']}`")
166
+
167
+
168
+ @pack_app.command("test")
169
+ def test(
170
+ target: str = typer.Argument(help="Installed pack name or a pack directory."),
171
+ timeout: float = typer.Option(600, "--timeout"),
172
+ as_json: bool = JsonOpt,
173
+ ) -> None:
174
+ """Run a pack's smoke tests (tests/) with pytest."""
175
+ from autoforge.packs.authoring import test_pack
176
+
177
+ path = target
178
+ if not Path(target).is_dir():
179
+ with errors_as_envelope(as_json):
180
+ path = installer.get_installed(target)[0].path
181
+ with errors_as_envelope(as_json):
182
+ res = test_pack(path, timeout)
183
+ if not res["ok"]:
184
+ fail(as_json, "PACK_TEST_FAILED", res["summary"] or "tests failed", "read output_tail", res)
185
+ emit(as_json, res, f"OK: {res['summary']} ({res['seconds']} s)")
@@ -0,0 +1,61 @@
1
+ """`autoforge quickstart`: the whole loop in one command, on bundled example data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from importlib import resources
7
+
8
+ import typer
9
+
10
+ from autoforge.cli.output import JsonOpt, emit, errors_as_envelope
11
+
12
+
13
+ def quickstart(
14
+ recipe: str = typer.Option("random_forest", "--recipe", help="logistic_regression or random_forest"),
15
+ as_json: bool = JsonOpt,
16
+ ) -> None:
17
+ """Hardware check → install a pack → train → register → predict, on a bundled churn dataset (~1 min)."""
18
+ from autoforge import api
19
+ from autoforge.errors import AutoForgeError
20
+ from autoforge.packs import installer
21
+ from autoforge.schemas.core import BrainKind
22
+
23
+ start = time.monotonic()
24
+ log = (lambda *a: None) if as_json else typer.echo
25
+ data = str(resources.files("autoforge.examples") / "churn.csv")
26
+ with errors_as_envelope(as_json):
27
+ hw = api.hardware()
28
+ log(f"1/5 hardware: {hw['cpu']['model']}, device {hw['best_device']}")
29
+ if "tabular-classic" not in installer.read_lock():
30
+ try:
31
+ rec = api.pack_install("tabular-classic")
32
+ except AutoForgeError: # offline or index unreachable: fall back to the GitHub source
33
+ rec = api.pack_install("github:Honguan/autoforge/packs/tabular-classic@main")
34
+ log(f"2/5 installed pack tabular-classic {rec['version']}")
35
+ else:
36
+ log("2/5 pack tabular-classic already installed")
37
+ plan = {"task": {"task_type": "tabular-classification", "input_modality": "tabular", "output_modality": "label",
38
+ "target": "churned", "metric": "f1_macro"},
39
+ "data_path": data, "pack": "tabular-classic", "recipe": recipe,
40
+ "hyperparameters": {"class_weight": "balanced"}}
41
+ run = api.run_start(plan, "human", BrainKind.human)
42
+ log(f"3/5 training {run['run_id']} …")
43
+ view = api.run_wait(run["run_id"], 120)
44
+ if view["status"] != "succeeded":
45
+ raise AutoForgeError(f"training {view['status']}: {view.get('error')}",
46
+ hint=f"autoforge run logs {run['run_id']}")
47
+ log(" " + ", ".join(f"{k}={v:.3f}" for k, v in view["metrics"].items()))
48
+ card = api.model_register(run["run_id"], "quickstart-churn", "human", "Quickstart churn model")
49
+ log(f"4/5 registered model {card['name']}:v{card['version']}")
50
+ rows = [{"tenure_months": 2, "monthly_fee": 110, "contract": "month-to-month", "support_calls": 5},
51
+ {"tenure_months": 60, "monthly_fee": 25, "contract": "two-year", "support_calls": 0}]
52
+ pred = api.model_predict(card["name"], rows)
53
+ for r, p in zip(rows, pred["predictions"]):
54
+ log(f"5/5 predict {r} → churn={p['prediction']} (p={p['probabilities'].get('1', 0):.2f})")
55
+ seconds = round(time.monotonic() - start, 1)
56
+ emit(as_json, {"seconds": seconds, "run_id": run["run_id"], "model": f"{card['name']}:v{card['version']}",
57
+ "metrics": view["metrics"], "predictions": pred["predictions"]},
58
+ f"\nDone in {seconds} s. Next:\n"
59
+ f" autoforge gui # web interface\n"
60
+ f" autoforge serve # REST API for the model\n"
61
+ f" claude mcp add autoforge -- autoforge mcp # let Claude drive AutoForge")