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/cli/runs.py ADDED
@@ -0,0 +1,233 @@
1
+ """`autoforge run ...` and `autoforge plan ...` commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
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.engine import runner
13
+ from autoforge.engine.store import TERMINAL, RunStore
14
+ from autoforge.engine.validate import check_plan, load_plan_file
15
+
16
+ run_app = typer.Typer(help="Start and control training runs.", no_args_is_help=True)
17
+ plan_app = typer.Typer(help="Check training plans.", no_args_is_help=True)
18
+
19
+ ActorOpt = typer.Option(
20
+ None, "--actor", help="Who is acting: 'human' or 'ai:<name>'. Defaults to $AUTOFORGE_ACTOR or 'human'."
21
+ )
22
+
23
+
24
+ def _actor(actor: str | None) -> str:
25
+ return actor or os.environ.get("AUTOFORGE_ACTOR", "human")
26
+
27
+
28
+ def _view_line(v: runner.RunView) -> str:
29
+ m = v.metrics or v.latest_metrics
30
+ shown = ", ".join(f"{k}={val:.4g}" for k, val in m.items())
31
+ return f"{v.run_id:<40} {v.status.value:<10} {v.recipe or 'custom':<20} {shown}"
32
+
33
+
34
+ def _follow(store: RunStore, timeout: float | None) -> runner.RunView:
35
+ """Print metric/log events until the run finishes (human mode for --wait)."""
36
+ seen = 0
37
+ end = time.monotonic() + timeout if timeout else None
38
+ while True:
39
+ events = store.events()
40
+ for e in events[seen:]:
41
+ if e.get("type") == "metric":
42
+ step = f"step {e['step']}: " if e.get("step") is not None else ""
43
+ typer.echo(f" {step}{e['name']} = {e['value']:.4g}")
44
+ elif e.get("type") == "log":
45
+ typer.echo(f" {e['message']}")
46
+ seen = len(events)
47
+ view = runner.status(store)
48
+ if view.status in TERMINAL or (end and time.monotonic() > end):
49
+ return view
50
+ time.sleep(0.3)
51
+
52
+
53
+ @plan_app.command("validate")
54
+ def plan_validate(plan_file: Path, as_json: bool = JsonOpt) -> None:
55
+ """Check a plan against installed packs, data and hardware. Nothing is run."""
56
+ with errors_as_envelope(as_json):
57
+ plan = load_plan_file(plan_file)
58
+ check = check_plan(plan)
59
+ if not check.ok:
60
+ fail(as_json, "PLAN_INVALID", f"{len(check.problems)} problem(s) in plan",
61
+ "Fix every item in details.problems.", check.model_dump())
62
+ human = "\n".join([f"OK: device={check.device}, entrypoint={check.entrypoint}",
63
+ *(f"warning: {w}" for w in check.warnings)])
64
+ emit(as_json, check.model_dump(), human)
65
+
66
+
67
+ @run_app.command("start")
68
+ def start(
69
+ plan_file: Path,
70
+ wait: bool = typer.Option(False, "--wait", help="Block until the run finishes."),
71
+ timeout: float = typer.Option(None, "--timeout", help="Max seconds to wait with --wait."),
72
+ actor: str = ActorOpt,
73
+ as_json: bool = JsonOpt,
74
+ ) -> None:
75
+ """Validate a plan and start training in the background. Returns the run_id immediately."""
76
+ with errors_as_envelope(as_json):
77
+ plan = load_plan_file(plan_file)
78
+ store = runner.create_run(plan, _actor(actor))
79
+ runner.start(store, _actor(actor))
80
+ if not wait:
81
+ view = runner.status(store)
82
+ emit(as_json, view.model_dump(mode="json"), f"Started {store.run_id}\nPoll with: autoforge run status {store.run_id}")
83
+ return
84
+ if as_json:
85
+ view = runner.wait(store, timeout)
86
+ else:
87
+ typer.echo(f"Started {store.run_id}")
88
+ view = _follow(store, timeout)
89
+ emit(as_json, view.model_dump(mode="json"), _view_line(view) + (f"\nerror: {view.error}" if view.error else ""))
90
+
91
+
92
+ @run_app.command("status")
93
+ def status(run_id: str, as_json: bool = JsonOpt) -> None:
94
+ """Current status, final metrics and latest logged metrics of a run."""
95
+ with errors_as_envelope(as_json):
96
+ view = runner.status(RunStore.open(run_id))
97
+ emit(as_json, view.model_dump(mode="json"), _view_line(view) + (f"\nerror: {view.error}" if view.error else ""))
98
+
99
+
100
+ @run_app.command("list")
101
+ def list_cmd(as_json: bool = JsonOpt) -> None:
102
+ """All runs, newest first."""
103
+ views = runner.all_runs()
104
+ emit(as_json, [v.model_dump(mode="json") for v in views], "\n".join(map(_view_line, views)) or "(no runs)")
105
+
106
+
107
+ def _control(action: str, run_id: str, actor: str | None, as_json: bool, **kw) -> None:
108
+ with errors_as_envelope(as_json):
109
+ store = RunStore.open(run_id)
110
+ view = getattr(runner, action)(store, _actor(actor), **kw)
111
+ emit(as_json, view.model_dump(mode="json"), _view_line(view))
112
+
113
+
114
+ @run_app.command("pause")
115
+ def pause(run_id: str, actor: str = ActorOpt, as_json: bool = JsonOpt) -> None:
116
+ """Suspend a running run (works with any trainer)."""
117
+ _control("pause", run_id, actor, as_json)
118
+
119
+
120
+ @run_app.command("resume")
121
+ def resume(run_id: str, actor: str = ActorOpt, as_json: bool = JsonOpt) -> None:
122
+ """Continue a paused run."""
123
+ _control("resume", run_id, actor, as_json)
124
+
125
+
126
+ @run_app.command("stop")
127
+ def stop(
128
+ run_id: str,
129
+ grace: float = typer.Option(10.0, "--grace", help="Seconds to wait for a clean stop before terminating."),
130
+ actor: str = ActorOpt,
131
+ as_json: bool = JsonOpt,
132
+ ) -> None:
133
+ """Ask the run to stop; terminate it if it does not stop within the grace period."""
134
+ _control("stop", run_id, actor, as_json, grace_seconds=grace)
135
+
136
+
137
+ @run_app.command("retry")
138
+ def retry(
139
+ run_id: str,
140
+ wait: bool = typer.Option(False, "--wait"),
141
+ actor: str = ActorOpt,
142
+ as_json: bool = JsonOpt,
143
+ ) -> None:
144
+ """Start a new run with the same plan, resuming from the old run's checkpoints if any."""
145
+ with errors_as_envelope(as_json):
146
+ store = runner.retry(RunStore.open(run_id), _actor(actor))
147
+ runner.start(store, _actor(actor))
148
+ view = runner.wait(store) if wait else runner.status(store)
149
+ emit(as_json, view.model_dump(mode="json"), f"Started {store.run_id} (retry of {run_id})")
150
+
151
+
152
+ @run_app.command("logs")
153
+ def logs(run_id: str, tail: int = typer.Option(50, "--tail"), as_json: bool = JsonOpt) -> None:
154
+ """Last lines of the worker's stdout/stderr."""
155
+ with errors_as_envelope(as_json):
156
+ lines = RunStore.open(run_id).logs_tail(tail)
157
+ emit(as_json, {"lines": lines}, "\n".join(lines))
158
+
159
+
160
+ @run_app.command("metrics")
161
+ def metrics(run_id: str, last: int = typer.Option(5, "--last"), as_json: bool = JsonOpt) -> None:
162
+ """Per-metric summary: last, min, max, count and the most recent values."""
163
+ with errors_as_envelope(as_json):
164
+ summary = runner.metrics_summary(RunStore.open(run_id), last)
165
+ human = "\n".join(
166
+ f"{k:<20} last={v['last']:.4g} min={v['min']:.4g} max={v['max']:.4g} n={v['count']}"
167
+ for k, v in summary.items()
168
+ ) or "(no metrics yet)"
169
+ emit(as_json, summary, human)
170
+
171
+
172
+ @run_app.command("audit")
173
+ def audit(run_id: str, as_json: bool = JsonOpt) -> None:
174
+ """Every action taken on a run and who took it."""
175
+ with errors_as_envelope(as_json):
176
+ rows = RunStore.open(run_id).audit_log()
177
+ emit(as_json, rows, "\n".join(f"{r['t']} {r['actor']:<16} {r['action']}" for r in rows))
178
+
179
+
180
+ @run_app.command("diagnose")
181
+ def diagnose(run_id: str, as_json: bool = JsonOpt) -> None:
182
+ """Findings and suggestions: failure cause, diverging/plateauing loss, overfitting, leakage."""
183
+ from autoforge import api
184
+
185
+ with errors_as_envelope(as_json):
186
+ d = api.run_diagnose(run_id)
187
+ lines = [d["summary"]] + [f" [{f['severity']}] {f['code']}: {f['message']}" +
188
+ (f"\n → {f['suggestion']}" if f["suggestion"] else "") for f in d["findings"]]
189
+ emit(as_json, d, "\n".join(lines))
190
+
191
+
192
+ @run_app.command("adjust")
193
+ def adjust(
194
+ run_id: str,
195
+ set_: list[str] = typer.Option(..., "--set", help="name=value (YAML value), repeatable. e.g. --set lr=0.001"),
196
+ actor: str = ActorOpt,
197
+ as_json: bool = JsonOpt,
198
+ ) -> None:
199
+ """Change hyperparameters of a running run (trainer must call ctx.check_updates())."""
200
+ import yaml
201
+
202
+ from autoforge import api
203
+
204
+ changes = {}
205
+ for item in set_:
206
+ name, sep, value = item.partition("=")
207
+ if not sep or not name:
208
+ fail(as_json, "BAD_REQUEST", f"bad --set '{item}'", "use name=value")
209
+ changes[name.strip()] = yaml.safe_load(value)
210
+ with errors_as_envelope(as_json):
211
+ out = api.run_adjust(run_id, _actor(actor), changes)
212
+ emit(as_json, out, f"Sent {changes} to {run_id} (seq {out['seq']})")
213
+
214
+
215
+ @run_app.command("compare")
216
+ def compare(
217
+ run_ids: list[str],
218
+ metric: str = typer.Option(None, "--metric", "-m", help="Sort by this metric, best first."),
219
+ as_json: bool = JsonOpt,
220
+ ) -> None:
221
+ """Compare runs: metrics and the hyperparameters that differ."""
222
+ from autoforge import api
223
+
224
+ with errors_as_envelope(as_json):
225
+ c = api.run_compare(run_ids, metric)
226
+ diff = c["differing_hyperparameters"]
227
+ lines = [f"differing hyperparameters: {', '.join(diff) or '(none)'}"]
228
+ for r in c["runs"]:
229
+ m = ", ".join(f"{k}={v:.4g}" for k, v in r["metrics"].items())
230
+ hp = ", ".join(f"{k}={r['hyperparameters'].get(k)}" for k in diff)
231
+ star = " ★" if r["run_id"] == c["best_run_id"] else ""
232
+ lines.append(f"{r['run_id']:<40} {r['status']:<10} {m} [{hp}]{star}")
233
+ emit(as_json, c, "\n".join(lines))
autoforge/config.py ADDED
@@ -0,0 +1,31 @@
1
+ """Paths and settings. Override the home directory with AUTOFORGE_HOME."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ DEFAULT_INDEX_URL = (
9
+ "https://raw.githubusercontent.com/Honguan/autoforge/packs-index/index.json"
10
+ )
11
+
12
+
13
+ def home() -> Path:
14
+ return Path(os.environ.get("AUTOFORGE_HOME", Path.home() / ".autoforge")).expanduser()
15
+
16
+
17
+ def packs_dir() -> Path:
18
+ return home() / "packs"
19
+
20
+
21
+ def lock_file() -> Path:
22
+ return home() / "packs.lock.json"
23
+
24
+
25
+ def cache_dir() -> Path:
26
+ return home() / "cache"
27
+
28
+
29
+ def index_url() -> str:
30
+ """URL or local file path of the pack index."""
31
+ return os.environ.get("AUTOFORGE_INDEX_URL", DEFAULT_INDEX_URL)
File without changes
@@ -0,0 +1,99 @@
1
+ """Dataset profiling that respects the privacy guard.
2
+
3
+ - summary access: schema and aggregate statistics only (no rows). Top values are
4
+ shown only for low-cardinality columns, which are categories, not identifiers.
5
+ - full access: the same plus a few sample rows.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import csv
11
+ from collections import Counter
12
+ from pathlib import Path
13
+
14
+ from autoforge.policy.privacy import Access
15
+
16
+ MAX_ROWS = 200_000
17
+ LOW_CARDINALITY = 20
18
+ SAMPLE_ROWS = 5
19
+
20
+
21
+ def _num(v: str) -> float | None:
22
+ try:
23
+ return float(v)
24
+ except ValueError:
25
+ return None
26
+
27
+
28
+ def _profile_csv(path: Path, access: Access) -> dict:
29
+ with path.open(newline="", encoding="utf-8", errors="replace") as f:
30
+ reader = csv.reader(f)
31
+ header = next(reader, [])
32
+ cols = {h: {"values": Counter(), "missing": 0, "numeric": [], "non_numeric": 0} for h in header}
33
+ samples, n, truncated = [], 0, False
34
+ for row in reader:
35
+ if n >= MAX_ROWS:
36
+ truncated = True
37
+ break
38
+ n += 1
39
+ if len(samples) < SAMPLE_ROWS:
40
+ samples.append(dict(zip(header, row)))
41
+ for h, v in zip(header, row):
42
+ c = cols[h]
43
+ if v == "":
44
+ c["missing"] += 1
45
+ continue
46
+ if len(c["values"]) <= LOW_CARDINALITY or v in c["values"]:
47
+ c["values"][v] += 1
48
+ x = _num(v)
49
+ if x is None:
50
+ c["non_numeric"] += 1
51
+ else:
52
+ c["numeric"].append(x)
53
+
54
+ columns = {}
55
+ for h, c in cols.items():
56
+ present = n - c["missing"]
57
+ is_numeric = present > 0 and c["non_numeric"] == 0
58
+ info: dict = {"dtype": "numeric" if is_numeric else "categorical", "missing": c["missing"]}
59
+ distinct = len(c["values"])
60
+ if distinct <= LOW_CARDINALITY:
61
+ info["distinct"] = distinct
62
+ info["top_values"] = dict(c["values"].most_common(5))
63
+ else:
64
+ info["distinct"] = f">{LOW_CARDINALITY}"
65
+ if is_numeric:
66
+ xs = c["numeric"]
67
+ info |= {"min": min(xs), "max": max(xs), "mean": round(sum(xs) / len(xs), 6)}
68
+ columns[h] = info
69
+
70
+ out = {"format": "csv", "rows": n, "rows_truncated_at": MAX_ROWS if truncated else None, "columns": columns}
71
+ if access is Access.full:
72
+ out["sample_rows"] = samples
73
+ return out
74
+
75
+
76
+ def _profile_dir(path: Path) -> dict:
77
+ exts: Counter = Counter()
78
+ subdirs = sorted(p.name for p in path.iterdir() if p.is_dir())
79
+ files = 0
80
+ for p in path.rglob("*"):
81
+ if p.is_file():
82
+ files += 1
83
+ exts[p.suffix.lower() or "(none)"] += 1
84
+ return {
85
+ "format": "directory",
86
+ "files": files,
87
+ "extensions": dict(exts.most_common(10)),
88
+ "top_level_dirs": subdirs[:50], # e.g. class folders in image datasets
89
+ }
90
+
91
+
92
+ def profile(path: str | Path, access: Access) -> dict:
93
+ p = Path(path).expanduser().resolve()
94
+ base = {"path": str(p), "access": access.value}
95
+ if p.is_dir():
96
+ return base | _profile_dir(p)
97
+ if p.suffix.lower() == ".csv":
98
+ return base | _profile_csv(p, access)
99
+ return base | {"format": p.suffix.lstrip(".") or "unknown", "bytes": p.stat().st_size}
@@ -0,0 +1,47 @@
1
+ """Dataset sensitivity registry (~/.autoforge/datasets.json).
2
+
3
+ Marking a directory covers every file inside it. Unmarked data is 'internal'.
4
+ Only humans mark data (CLI); the MCP server exposes no way to change it, so a
5
+ cloud AI cannot downgrade a dataset to get access.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+
13
+ from autoforge import config
14
+ from autoforge.schemas.core import Sensitivity
15
+
16
+ DEFAULT = Sensitivity.internal
17
+
18
+
19
+ def _file() -> Path:
20
+ return config.home() / "datasets.json"
21
+
22
+
23
+ def _load() -> dict[str, str]:
24
+ f = _file()
25
+ return json.loads(f.read_text(encoding="utf-8")) if f.is_file() else {}
26
+
27
+
28
+ def mark(path: str | Path, sensitivity: Sensitivity) -> str:
29
+ key = str(Path(path).expanduser().resolve())
30
+ data = _load() | {key: sensitivity.value}
31
+ _file().parent.mkdir(parents=True, exist_ok=True)
32
+ _file().write_text(json.dumps(dict(sorted(data.items())), indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
33
+ return key
34
+
35
+
36
+ def sensitivity_of(path: str | Path) -> Sensitivity:
37
+ """The closest marked ancestor (or the path itself) decides; default 'internal'."""
38
+ data = _load()
39
+ p = Path(path).expanduser().resolve()
40
+ for candidate in (p, *p.parents):
41
+ if str(candidate) in data:
42
+ return Sensitivity(data[str(candidate)])
43
+ return DEFAULT
44
+
45
+
46
+ def all_marks() -> dict[str, str]:
47
+ return _load()
File without changes
@@ -0,0 +1,125 @@
1
+ """TrainContext: the object every trainer receives.
2
+
3
+ Trainer contract (v1):
4
+ def train(ctx: TrainContext) -> dict:
5
+ ...
6
+ return {"metrics": {"accuracy": 0.9}, "model_path": "..."}
7
+
8
+ Trainers should call `ctx.log_metric(...)` as they go and `ctx.check_stop()`
9
+ between expensive steps so a stop request can end the run cleanly.
10
+ Pause works without trainer cooperation (the process is suspended).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import time
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+
21
+ class RunStopped(Exception):
22
+ """Raised by ctx.check_stop() when a stop was requested."""
23
+
24
+
25
+ class TrainContext:
26
+ def __init__(
27
+ self,
28
+ run_dir: Path,
29
+ data_path: str,
30
+ target: str | None,
31
+ task_type: str,
32
+ hyperparameters: dict[str, Any],
33
+ device: str,
34
+ seed: int = 0,
35
+ resume_from: str | None = None,
36
+ ):
37
+ self.run_dir = Path(run_dir)
38
+ self.data_path = data_path
39
+ self.target = target
40
+ self.task_type = task_type
41
+ self.hyperparameters = hyperparameters
42
+ self.device = device
43
+ self.seed = seed
44
+ self.output_dir = self.run_dir / "output"
45
+ self.checkpoint_dir = self.run_dir / "checkpoints"
46
+ self.resume_from = Path(resume_from) if resume_from else None
47
+ self.output_dir.mkdir(parents=True, exist_ok=True)
48
+ self.checkpoint_dir.mkdir(parents=True, exist_ok=True)
49
+ self._events = self.run_dir / "events.jsonl"
50
+ self._control = self.run_dir / "control.json"
51
+ self._last_check = 0.0
52
+ self._stop = False
53
+ self._adjust_seq = 0
54
+
55
+ # ---- metrics / logging ----
56
+
57
+ def log_metric(self, name: str, value: float, step: int | None = None) -> None:
58
+ event = {"t": time.time(), "type": "metric", "name": name, "value": float(value), "step": step}
59
+ with self._events.open("a", encoding="utf-8") as f:
60
+ f.write(json.dumps(event) + "\n")
61
+
62
+ def log(self, message: str) -> None:
63
+ event = {"t": time.time(), "type": "log", "message": message}
64
+ with self._events.open("a", encoding="utf-8") as f:
65
+ f.write(json.dumps(event, ensure_ascii=False) + "\n")
66
+ print(message, flush=True)
67
+
68
+ # ---- control ----
69
+
70
+ def should_stop(self) -> bool:
71
+ """True once a stop was requested. Reads the control file at most every 0.5 s."""
72
+ now = time.monotonic()
73
+ if not self._stop and now - self._last_check >= 0.5:
74
+ self._last_check = now
75
+ try:
76
+ self._stop = bool(json.loads(self._control.read_text()).get("stop_requested"))
77
+ except (OSError, ValueError):
78
+ pass
79
+ return self._stop
80
+
81
+ def check_updates(self) -> dict[str, Any]:
82
+ """Hyperparameter changes sent with `run adjust` since the last call (already applied to
83
+ self.hyperparameters). Trainers that support live changes call this between steps."""
84
+ try:
85
+ control = json.loads(self._control.read_text())
86
+ except (OSError, ValueError):
87
+ return {}
88
+ seq = int(control.get("adjust_seq", 0))
89
+ if seq <= self._adjust_seq:
90
+ return {}
91
+ self._adjust_seq = seq
92
+ changes = dict(control.get("overrides") or {})
93
+ changed = {k: v for k, v in changes.items() if self.hyperparameters.get(k) != v}
94
+ self.hyperparameters.update(changed)
95
+ if changed:
96
+ self.log(f"hyperparameters adjusted: {changed}")
97
+ return changed
98
+
99
+ def check_stop(self) -> None:
100
+ if self.should_stop():
101
+ raise RunStopped()
102
+
103
+ def latest_checkpoint(self) -> Path | None:
104
+ """Newest file in resume_from (if retrying) or this run's checkpoint dir."""
105
+ for d in (self.resume_from, self.checkpoint_dir):
106
+ if d and d.is_dir():
107
+ files = sorted((p for p in d.iterdir() if p.is_file()), key=lambda p: p.stat().st_mtime)
108
+ if files:
109
+ return files[-1]
110
+ return None
111
+
112
+ # ---- compatibility with the provisional dict contract ----
113
+
114
+ def __getitem__(self, key: str) -> Any:
115
+ if key in ("data_path", "target", "task_type", "hyperparameters", "seed", "device"):
116
+ return getattr(self, key)
117
+ if key == "output_dir":
118
+ return str(self.output_dir)
119
+ raise KeyError(key)
120
+
121
+ def get(self, key: str, default: Any = None) -> Any:
122
+ try:
123
+ return self[key]
124
+ except KeyError:
125
+ return default
@@ -0,0 +1,134 @@
1
+ """Deterministic run diagnosis (P5). Gives any AI, including small local models,
2
+ concrete findings to act on instead of raw logs.
3
+
4
+ Each finding: {severity: error|warning|info, code, message, suggestion}.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import re
11
+
12
+ from autoforge.engine import runner
13
+ from autoforge.engine.store import RunStore
14
+ from autoforge.packs import installer
15
+ from autoforge.packs.errors import PackError
16
+ from autoforge.schemas.core import RunStatus
17
+
18
+ LOG_RULES: list[tuple[str, str, str, str]] = [
19
+ # (regex, code, message, suggestion)
20
+ (r"No module named '([\w.]+)'", "MISSING_PACKAGE", "Python package '{0}' is not installed",
21
+ "pip install {0} (check pack_info missing_dependencies)"),
22
+ (r"CUDA out of memory|OutOfMemoryError|CUBLAS_STATUS_ALLOC_FAILED", "GPU_OOM", "GPU ran out of memory",
23
+ "lower batch_size, use a smaller model, enable gradient accumulation or 4-bit/QLoRA"),
24
+ (r"MemoryError|Killed", "RAM_OOM", "the process ran out of RAM",
25
+ "use fewer rows or features, lower batch_size, or drop high-cardinality columns"),
26
+ (r"least populated class in y has only 1 member|The test_size .* should be greater or equal to the number of classes",
27
+ "RARE_CLASS", "a class has too few examples to split", "merge or drop rare classes, or collect more data"),
28
+ (r"could not convert string to float: '([^']*)'", "NON_NUMERIC", "a non-numeric value '{0}' reached a numeric step",
29
+ "encode categorical columns or clean the column containing that value"),
30
+ (r"KeyError: '([^']+)'", "MISSING_COLUMN", "column '{0}' was not found",
31
+ "check task.target and the CSV header (data_profile)"),
32
+ (r"FileNotFoundError: .*?'([^']+)'", "FILE_NOT_FOUND", "file '{0}' was not found", "check data_path"),
33
+ ]
34
+ LOSS_LIKE = ("loss", "rmse", "mse", "mae", "error", "perplexity")
35
+
36
+
37
+ def _is_loss(name: str) -> bool:
38
+ return any(h in name.lower() for h in LOSS_LIKE)
39
+
40
+
41
+ def _finding(severity: str, code: str, message: str, suggestion: str = "") -> dict:
42
+ return {"severity": severity, "code": code, "message": message, "suggestion": suggestion}
43
+
44
+
45
+ def _series(store: RunStore) -> dict[str, list[float]]:
46
+ out: dict[str, list[float]] = {}
47
+ for e in store.events():
48
+ if e.get("type") == "metric" and e.get("step") is not None:
49
+ out.setdefault(e["name"], []).append(float(e["value"]))
50
+ return out
51
+
52
+
53
+ def _log_findings(store: RunStore) -> list[dict]:
54
+ text = "\n".join(store.logs_tail(300))
55
+ found = []
56
+ for pattern, code, msg, sug in LOG_RULES:
57
+ m = re.search(pattern, text)
58
+ if m:
59
+ groups = m.groups() or ("",)
60
+ found.append(_finding("error", code, msg.format(*groups), sug.format(*groups)))
61
+ return found
62
+
63
+
64
+ def _curve_findings(series: dict[str, list[float]], tunable: set[str]) -> list[dict]:
65
+ found = []
66
+ lr = next((k for k in ("learning_rate", "lr") if k in tunable), "learning_rate")
67
+ for name, xs in series.items():
68
+ if any(not math.isfinite(x) for x in xs):
69
+ found.append(_finding("error", "DIVERGED", f"'{name}' became NaN/inf",
70
+ f"lower {lr} (e.g. ÷10), add gradient clipping, check input scaling"))
71
+ continue
72
+ if len(xs) < 4 or not _is_loss(name):
73
+ continue
74
+ best_i = min(range(len(xs)), key=xs.__getitem__)
75
+ if xs[-1] > xs[best_i] * 1.2 and best_i < len(xs) - 2 and not name.lower().startswith(("val", "valid", "eval", "test")):
76
+ found.append(_finding("warning", "LOSS_RISING", f"'{name}' rose {xs[-1] / xs[best_i]:.2f}× above its best",
77
+ f"lower {lr}; the run may be unstable"))
78
+ half = xs[len(xs) // 2:]
79
+ if len(half) >= 3 and half[0] != 0 and abs(half[0] - half[-1]) / abs(half[0]) < 0.01:
80
+ found.append(_finding("info", "PLATEAU", f"'{name}' changed < 1% over the last half of training",
81
+ f"stop earlier to save time, or raise {lr} / model capacity if the score is too low"))
82
+ # overfitting: training loss keeps falling while validation loss rises
83
+ for name, val in series.items():
84
+ lname = name.lower()
85
+ if not lname.startswith(("val_", "valid_", "eval_")) or not _is_loss(name) or len(val) < 4:
86
+ continue
87
+ base = name.split("_", 1)[1]
88
+ train = series.get(f"train_{base}") or series.get(base)
89
+ if not train or len(train) < 4:
90
+ continue
91
+ best_i = min(range(len(val)), key=val.__getitem__)
92
+ if best_i < len(val) - 2 and val[-1] > val[best_i] * 1.05 and train[-1] < train[len(train) // 2]:
93
+ reg = [k for k in ("dropout", "weight_decay", "min_samples_leaf", "max_depth", "C") if k in tunable]
94
+ found.append(_finding("warning", "OVERFITTING",
95
+ f"'{name}' was best at step {best_i} and then rose while training loss kept falling",
96
+ "stop at the best step (early stopping)" + (f"; regularize via {', '.join(reg)}" if reg else "")))
97
+ return found
98
+
99
+
100
+ def diagnose(store: RunStore) -> dict:
101
+ view = runner.status(store)
102
+ rec = store.record()
103
+ tunable: set[str] = set(rec.plan.hyperparameters)
104
+ if rec.plan.pack:
105
+ try:
106
+ _, pack = installer.get_installed(rec.plan.pack)
107
+ recipe = pack.recipe(rec.plan.recipe or "")
108
+ if recipe:
109
+ tunable |= set(recipe.search_space)
110
+ except PackError:
111
+ pass
112
+
113
+ findings: list[dict] = []
114
+ if view.status is RunStatus.failed:
115
+ findings += _log_findings(store) or [_finding(
116
+ "error", "UNKNOWN_FAILURE", view.error or "run failed", "read run_logs for the traceback")]
117
+ elif view.status is RunStatus.stopped:
118
+ findings.append(_finding("info", "STOPPED", "the run was stopped before finishing",
119
+ "run_retry resumes from checkpoints if the trainer saves them"))
120
+ findings += _curve_findings(_series(store), tunable)
121
+
122
+ for name, value in view.metrics.items():
123
+ if name.lower() in ("accuracy", "f1", "f1_macro", "auc", "roc_auc", "r2") and value >= 0.999:
124
+ findings.append(_finding("warning", "POSSIBLE_LEAKAGE", f"{name} = {value:.4f} is suspiciously perfect",
125
+ "check whether a feature leaks the target (e.g. an ID or post-outcome column)"))
126
+
127
+ status_text = view.status.value
128
+ if not findings:
129
+ summary = f"{status_text}; no problems detected"
130
+ else:
131
+ worst = next((s for s in ("error", "warning", "info") if any(f["severity"] == s for f in findings)), "info")
132
+ summary = f"{status_text}; {len(findings)} finding(s), worst: {worst}"
133
+ return {"run_id": store.run_id, "status": status_text, "metrics": view.metrics,
134
+ "tunable_hyperparameters": sorted(tunable), "findings": findings, "summary": summary}