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.
- autoforge/__init__.py +3 -0
- autoforge/agents/__init__.py +0 -0
- autoforge/agents/loop.py +168 -0
- autoforge/agents/providers.py +272 -0
- autoforge/api.py +330 -0
- autoforge/cli/__init__.py +0 -0
- autoforge/cli/agent.py +102 -0
- autoforge/cli/data.py +42 -0
- autoforge/cli/hpo.py +98 -0
- autoforge/cli/main.py +112 -0
- autoforge/cli/models.py +172 -0
- autoforge/cli/output.py +40 -0
- autoforge/cli/packs.py +185 -0
- autoforge/cli/quickstart.py +61 -0
- autoforge/cli/runs.py +233 -0
- autoforge/config.py +31 -0
- autoforge/data/__init__.py +0 -0
- autoforge/data/profile.py +99 -0
- autoforge/data/registry.py +47 -0
- autoforge/engine/__init__.py +0 -0
- autoforge/engine/context.py +125 -0
- autoforge/engine/diagnose.py +134 -0
- autoforge/engine/hpo.py +355 -0
- autoforge/engine/runner.py +380 -0
- autoforge/engine/store.py +207 -0
- autoforge/engine/validate.py +172 -0
- autoforge/engine/worker.py +68 -0
- autoforge/errors.py +17 -0
- autoforge/examples/__init__.py +0 -0
- autoforge/examples/churn.csv +601 -0
- autoforge/gui/__init__.py +0 -0
- autoforge/gui/app.py +243 -0
- autoforge/gui/static/app.js +546 -0
- autoforge/gui/static/index.html +26 -0
- autoforge/gui/static/style.css +83 -0
- autoforge/hardware/__init__.py +0 -0
- autoforge/hardware/detect.py +161 -0
- autoforge/packs/__init__.py +0 -0
- autoforge/packs/authoring.py +203 -0
- autoforge/packs/errors.py +42 -0
- autoforge/packs/index.py +149 -0
- autoforge/packs/installer.py +241 -0
- autoforge/packs/manifest.py +192 -0
- autoforge/policy/__init__.py +0 -0
- autoforge/policy/privacy.py +68 -0
- autoforge/registry/__init__.py +0 -0
- autoforge/registry/models.py +350 -0
- autoforge/schemas/__init__.py +0 -0
- autoforge/schemas/core.py +137 -0
- autoforge/serve/__init__.py +0 -0
- autoforge/serve/mcp_server.py +293 -0
- autoforge/serve/model_mcp.py +98 -0
- autoforge/serve/rest.py +87 -0
- autoforge_trainer-1.0.2.dist-info/METADATA +216 -0
- autoforge_trainer-1.0.2.dist-info/RECORD +58 -0
- autoforge_trainer-1.0.2.dist-info/WHEEL +4 -0
- autoforge_trainer-1.0.2.dist-info/entry_points.txt +2 -0
- autoforge_trainer-1.0.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Deterministic plan checks (no AI involved). A wrong AI plan stops here."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import csv
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
11
|
+
|
|
12
|
+
from autoforge.errors import AutoForgeError
|
|
13
|
+
from autoforge.hardware.detect import detect_hardware
|
|
14
|
+
from autoforge.packs import installer
|
|
15
|
+
from autoforge.packs.errors import PackError
|
|
16
|
+
from autoforge.schemas.core import HardwareProfile, TrainingPlan
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PlanInvalid(AutoForgeError):
|
|
20
|
+
code = "PLAN_INVALID"
|
|
21
|
+
hint = "Fix every item in details.problems, then run `autoforge plan validate` again."
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PlanCheck(BaseModel):
|
|
25
|
+
ok: bool
|
|
26
|
+
problems: list[str] = Field(default_factory=list)
|
|
27
|
+
warnings: list[str] = Field(default_factory=list)
|
|
28
|
+
device: str | None = None
|
|
29
|
+
entrypoint: str | None = Field(default=None, description="absolute 'file.py:function'")
|
|
30
|
+
hyperparameters: dict = Field(default_factory=dict, description="recipe defaults merged with the plan")
|
|
31
|
+
pack_version: str | None = None
|
|
32
|
+
pack_source: str | None = None
|
|
33
|
+
pack_root: str | None = None
|
|
34
|
+
predictor: str | None = Field(default=None, description="predictor module, relative to the pack root")
|
|
35
|
+
exporters: dict[str, str] = Field(default_factory=dict)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_plan_file(path: Path) -> TrainingPlan:
|
|
39
|
+
"""Load a YAML/JSON plan; relative paths are resolved against the plan file's directory."""
|
|
40
|
+
path = Path(path)
|
|
41
|
+
try:
|
|
42
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
43
|
+
except FileNotFoundError:
|
|
44
|
+
raise PlanInvalid(f"plan file not found: {path}")
|
|
45
|
+
except yaml.YAMLError as e:
|
|
46
|
+
raise PlanInvalid(f"plan file is not valid YAML/JSON: {e}")
|
|
47
|
+
return parse_plan(raw, path.resolve().parent)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_plan(raw: object, base: Path) -> TrainingPlan:
|
|
51
|
+
"""Validate a plan dict; relative paths are resolved against `base`."""
|
|
52
|
+
try:
|
|
53
|
+
plan = TrainingPlan.model_validate(raw)
|
|
54
|
+
except ValidationError as e:
|
|
55
|
+
problems = [f"{'.'.join(map(str, err['loc']))}: {err['msg']}" for err in e.errors()]
|
|
56
|
+
raise PlanInvalid("plan does not match the TrainingPlan schema", details={"problems": problems})
|
|
57
|
+
plan.data_path = str((base / plan.data_path).resolve())
|
|
58
|
+
if plan.custom_code_path:
|
|
59
|
+
file, _, func = plan.custom_code_path.partition(":")
|
|
60
|
+
plan.custom_code_path = str((base / file).resolve()) + (f":{func}" if func else "")
|
|
61
|
+
return plan
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _function_defined(file: Path, func: str) -> bool:
|
|
65
|
+
try:
|
|
66
|
+
tree = ast.parse(file.read_text(encoding="utf-8"))
|
|
67
|
+
except (OSError, SyntaxError):
|
|
68
|
+
return False
|
|
69
|
+
return any(isinstance(n, ast.FunctionDef) and n.name == func for n in tree.body)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _csv_columns(path: Path) -> list[str] | None:
|
|
73
|
+
try:
|
|
74
|
+
with path.open(newline="", encoding="utf-8") as f:
|
|
75
|
+
return next(csv.reader(f), [])
|
|
76
|
+
except (OSError, UnicodeDecodeError):
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def check_plan(plan: TrainingPlan, hardware: HardwareProfile | None = None) -> PlanCheck:
|
|
81
|
+
hw = hardware or detect_hardware()
|
|
82
|
+
problems: list[str] = []
|
|
83
|
+
warnings: list[str] = []
|
|
84
|
+
out = PlanCheck(ok=False)
|
|
85
|
+
has_cuda = hw.best_device == "cuda"
|
|
86
|
+
|
|
87
|
+
# data
|
|
88
|
+
data = Path(plan.data_path)
|
|
89
|
+
if not data.exists():
|
|
90
|
+
problems.append(f"data_path not found: {plan.data_path}")
|
|
91
|
+
elif data.suffix.lower() == ".csv" and plan.task.target:
|
|
92
|
+
cols = _csv_columns(data)
|
|
93
|
+
if cols is not None and plan.task.target not in cols:
|
|
94
|
+
problems.append(f"target column '{plan.task.target}' not in CSV columns {cols}")
|
|
95
|
+
|
|
96
|
+
# method: exactly one of pack+recipe or custom code
|
|
97
|
+
required_device = "any"
|
|
98
|
+
if plan.custom_code_path and plan.pack:
|
|
99
|
+
problems.append("set either pack+recipe or custom_code_path, not both")
|
|
100
|
+
elif plan.custom_code_path:
|
|
101
|
+
file, _, func = plan.custom_code_path.partition(":")
|
|
102
|
+
func = func or "train"
|
|
103
|
+
if not Path(file).is_file():
|
|
104
|
+
problems.append(f"custom code not found: {file}")
|
|
105
|
+
elif not _function_defined(Path(file), func):
|
|
106
|
+
problems.append(f"function '{func}' not defined at top level of {file}")
|
|
107
|
+
else:
|
|
108
|
+
out.entrypoint = f"{Path(file).resolve()}:{func}"
|
|
109
|
+
out.hyperparameters = dict(plan.hyperparameters)
|
|
110
|
+
warnings.append("custom code runs with full permissions; it is versioned in the run's git workspace")
|
|
111
|
+
elif plan.pack:
|
|
112
|
+
try:
|
|
113
|
+
rec, pack = installer.get_installed(plan.pack)
|
|
114
|
+
except PackError as e:
|
|
115
|
+
problems.append(str(e))
|
|
116
|
+
else:
|
|
117
|
+
out.pack_version, out.pack_source = rec.version, rec.source
|
|
118
|
+
recipe = pack.recipe(plan.recipe or "")
|
|
119
|
+
if recipe is None:
|
|
120
|
+
problems.append(
|
|
121
|
+
f"recipe '{plan.recipe}' not in pack '{plan.pack}'; available: {[r.id for r in pack.recipes]}"
|
|
122
|
+
)
|
|
123
|
+
else:
|
|
124
|
+
if plan.task.task_type not in recipe.task_types:
|
|
125
|
+
problems.append(
|
|
126
|
+
f"recipe '{recipe.id}' does not support task '{plan.task.task_type}' "
|
|
127
|
+
f"(supports {recipe.task_types})"
|
|
128
|
+
)
|
|
129
|
+
req = recipe.requires
|
|
130
|
+
required_device = req.device
|
|
131
|
+
if req.device == "cuda" and not has_cuda:
|
|
132
|
+
problems.append(f"recipe '{recipe.id}' needs an NVIDIA GPU with CUDA; none usable")
|
|
133
|
+
if req.min_vram_gb and has_cuda and hw.max_vram_gb < req.min_vram_gb:
|
|
134
|
+
problems.append(f"recipe needs {req.min_vram_gb} GB VRAM; largest GPU has {hw.max_vram_gb} GB")
|
|
135
|
+
if req.min_ram_gb and hw.ram_available_gb < req.min_ram_gb:
|
|
136
|
+
warnings.append(f"recipe suggests {req.min_ram_gb} GB free RAM; {hw.ram_available_gb} GB available")
|
|
137
|
+
unknown = set(plan.hyperparameters) - set(recipe.defaults) - set(recipe.search_space)
|
|
138
|
+
if unknown:
|
|
139
|
+
warnings.append(f"hyperparameters not declared by the recipe: {sorted(unknown)}")
|
|
140
|
+
file, func = recipe.entrypoint.rsplit(":", 1)
|
|
141
|
+
out.entrypoint = f"{(pack.root / file).resolve()}:{func}"
|
|
142
|
+
out.pack_root = str(pack.root)
|
|
143
|
+
out.predictor, out.exporters = recipe.predictor, dict(recipe.exporters)
|
|
144
|
+
out.hyperparameters = recipe.defaults | plan.hyperparameters
|
|
145
|
+
missing = installer.missing_dependencies(pack)
|
|
146
|
+
if missing:
|
|
147
|
+
problems.append(f"missing Python packages: {missing} (pip install them)")
|
|
148
|
+
else:
|
|
149
|
+
problems.append("plan needs pack+recipe or custom_code_path")
|
|
150
|
+
|
|
151
|
+
# device
|
|
152
|
+
if plan.device == "cuda" and not has_cuda:
|
|
153
|
+
problems.append("plan asks for device 'cuda' but no usable NVIDIA GPU/CUDA was found")
|
|
154
|
+
if plan.device == "cuda" and required_device == "cpu":
|
|
155
|
+
problems.append("recipe is CPU-only but plan asks for 'cuda'")
|
|
156
|
+
if required_device in ("cpu", "cuda"):
|
|
157
|
+
out.device = required_device
|
|
158
|
+
elif plan.device != "auto":
|
|
159
|
+
out.device = plan.device
|
|
160
|
+
else:
|
|
161
|
+
out.device = hw.best_device
|
|
162
|
+
|
|
163
|
+
out.problems, out.warnings = problems, warnings
|
|
164
|
+
out.ok = not problems
|
|
165
|
+
return out
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def require_valid(plan: TrainingPlan, hardware: HardwareProfile | None = None) -> PlanCheck:
|
|
169
|
+
check = check_plan(plan, hardware)
|
|
170
|
+
if not check.ok:
|
|
171
|
+
raise PlanInvalid(f"{len(check.problems)} problem(s) in plan", details=check.model_dump())
|
|
172
|
+
return check
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Worker process: `python -m autoforge.engine.worker <run_dir>`.
|
|
2
|
+
|
|
3
|
+
Loads the trainer entrypoint, runs it with a TrainContext and records the
|
|
4
|
+
outcome in state.json. Runs in its own process so a crash or kill never
|
|
5
|
+
takes down the caller.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib.util
|
|
11
|
+
import sys
|
|
12
|
+
import traceback
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from autoforge.engine.context import RunStopped, TrainContext
|
|
16
|
+
from autoforge.engine.store import RunState, RunStore, now_iso
|
|
17
|
+
from autoforge.schemas.core import RunStatus
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_entrypoint(entrypoint: str):
|
|
21
|
+
file, func = entrypoint.rsplit(":", 1)
|
|
22
|
+
path = Path(file)
|
|
23
|
+
spec = importlib.util.spec_from_file_location(f"autoforge_trainer_{path.stem}", path)
|
|
24
|
+
module = importlib.util.module_from_spec(spec)
|
|
25
|
+
sys.path.insert(0, str(path.parent)) # allow trainers to import sibling modules
|
|
26
|
+
spec.loader.exec_module(module)
|
|
27
|
+
return getattr(module, func)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run(run_dir: Path) -> int:
|
|
31
|
+
store = RunStore(run_dir)
|
|
32
|
+
rec = store.record()
|
|
33
|
+
state = RunState(status=RunStatus.running, started_at=now_iso())
|
|
34
|
+
store.write_state(state)
|
|
35
|
+
ctx = TrainContext(
|
|
36
|
+
run_dir=store.dir,
|
|
37
|
+
data_path=rec.plan.data_path,
|
|
38
|
+
target=rec.plan.task.target,
|
|
39
|
+
task_type=rec.plan.task.task_type,
|
|
40
|
+
hyperparameters=rec.plan.hyperparameters,
|
|
41
|
+
device=rec.device,
|
|
42
|
+
seed=rec.plan.seed,
|
|
43
|
+
resume_from=rec.resume_from,
|
|
44
|
+
)
|
|
45
|
+
try:
|
|
46
|
+
fn = load_entrypoint(rec.entrypoint)
|
|
47
|
+
result = fn(ctx) or {}
|
|
48
|
+
if not isinstance(result, dict):
|
|
49
|
+
raise TypeError(f"trainer must return a dict, got {type(result).__name__}")
|
|
50
|
+
state.metrics = {k: float(v) for k, v in (result.get("metrics") or {}).items()}
|
|
51
|
+
state.model_path = result.get("model_path")
|
|
52
|
+
for name, value in state.metrics.items():
|
|
53
|
+
ctx.log_metric(name, value, step=None)
|
|
54
|
+
state.status = RunStatus.succeeded
|
|
55
|
+
except RunStopped:
|
|
56
|
+
state.status = RunStatus.stopped
|
|
57
|
+
except BaseException as e: # noqa: BLE001 - record every failure, including KeyboardInterrupt
|
|
58
|
+
traceback.print_exc()
|
|
59
|
+
state.status = RunStatus.failed
|
|
60
|
+
state.error = f"{type(e).__name__}: {e}"
|
|
61
|
+
state.finished_at = now_iso()
|
|
62
|
+
store.write_state(state)
|
|
63
|
+
print(f"[autoforge] run {store.run_id} finished: {state.status.value}", flush=True)
|
|
64
|
+
return 0 if state.status is RunStatus.succeeded else 1
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
sys.exit(run(Path(sys.argv[1])))
|
autoforge/errors.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Base error: every AutoForge error carries a stable code, an optional hint
|
|
2
|
+
and optional structured details, so AI clients can branch on them."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AutoForgeError(Exception):
|
|
10
|
+
code = "AUTOFORGE_ERROR"
|
|
11
|
+
hint: str | None = None
|
|
12
|
+
|
|
13
|
+
def __init__(self, message: str, hint: str | None = None, details: Any = None):
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
if hint is not None:
|
|
16
|
+
self.hint = hint
|
|
17
|
+
self.details = details
|
|
File without changes
|