modulearn 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
modulearn/registry.py ADDED
@@ -0,0 +1,283 @@
1
+ #!/usr/bin/env python3
2
+ """The registry: how a host application declares its nodes to ModuLearn.
3
+
4
+ WHAT THIS REPLACES
5
+ ------------------
6
+ ModuLearn is a visual, "Unreal-Blueprints"-style editor for building training runs:
7
+ you drop Dataset / Model / Hyperparameter / Loss / Transform nodes on a canvas,
8
+ wire them into a single Train sink, and launch. The canvas is just a JSON graph.
9
+
10
+ The *only* thing tying that generic editor to your project is this registry. Your
11
+ app creates a :class:`Registry`, calls ``add_dataset`` / ``add_model`` / … to
12
+ describe what it can train, and hands the registry to :func:`modulearn.server.create_app`.
13
+ Everything downstream — the node palette (:mod:`modulearn.catalog`), the type-checked
14
+ graph compiler (:mod:`modulearn.compiler`), and the web editor — is built from it.
15
+ There is no hardcoded node list anywhere, so the palette can never drift from what
16
+ your backend actually accepts.
17
+
18
+ THE VOCABULARY
19
+ --------------
20
+ * **Port** — a typed socket on a node. Two ports connect only if their types match
21
+ exactly. Families: ``dataset``, ``model``, ``loss``, ``scalar`` and ``run``.
22
+ Scalars carry a field-specific subtype (``scalar/lr``) so a learning-rate value
23
+ only fits the ``lr`` input, never ``epochs``.
24
+ * **Param** — an in-node widget the user edits directly (float / int / enum / bool /
25
+ int_list), or a read-only descriptor drawn as a subtitle.
26
+ * **NodeSpec** — one node type: its ports, its params, and where it lives in the
27
+ palette. This module builds these for you via the ``add_*`` helpers.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ from dataclasses import dataclass, field
32
+ from typing import Any, Callable
33
+
34
+ # The link type-families. A port type is either a family ("model") or a subtype
35
+ # ("scalar/lr"); the base before "/" must be one of these. Kept in lockstep with
36
+ # the compiler and the frontend's wire colors.
37
+ PORT_TYPES: dict[str, str] = {
38
+ "dataset": "A resolved dataset: its feature columns, target columns and kind.",
39
+ "model": "A model specification (architecture + params), ready to train.",
40
+ "loss": "A training-loss specification.",
41
+ "scalar": "A single numeric value from a hyperparameter node, on the "
42
+ "field-specific subtype 'scalar/<field>' so it only fits its match.",
43
+ "run": "The terminal handle of a launched training run (its run_id).",
44
+ }
45
+
46
+ # Palette groupings, in display order.
47
+ CATEGORIES: list[str] = [
48
+ "dataset", "model", "hyperparameter", "loss", "transform", "train",
49
+ ]
50
+
51
+
52
+ @dataclass
53
+ class Param:
54
+ """One in-node value. ``kind`` is float|int|enum|bool|int_list.
55
+
56
+ ``readonly`` marks a descriptor (e.g. a dataset's feature count) — the editor
57
+ renders it as a compact subtitle instead of an editable widget. ``choices`` is
58
+ required when ``kind == "enum"``.
59
+ """
60
+ name: str
61
+ label: str
62
+ kind: str
63
+ default: Any = None
64
+ help: str = ""
65
+ min: float | None = None
66
+ max: float | None = None
67
+ choices: list[str] | None = None
68
+ readonly: bool = False
69
+
70
+ def to_json(self) -> dict:
71
+ if self.kind == "enum" and not self.choices:
72
+ raise ValueError(f"enum param {self.name!r} needs choices")
73
+ d: dict = {"name": self.name, "label": self.label, "kind": self.kind,
74
+ "default": self.default, "help": self.help,
75
+ "readonly": self.readonly}
76
+ if self.min is not None:
77
+ d["min"] = self.min
78
+ if self.max is not None:
79
+ d["max"] = self.max
80
+ if self.choices is not None:
81
+ d["choices"] = list(self.choices)
82
+ return d
83
+
84
+
85
+ @dataclass
86
+ class Port:
87
+ name: str
88
+ type: str
89
+
90
+ def to_json(self) -> dict:
91
+ base = self.type.split("/", 1)[0]
92
+ if base not in PORT_TYPES:
93
+ raise ValueError(f"unknown port type {self.type!r}; base must be one "
94
+ f"of {sorted(PORT_TYPES)}")
95
+ return {"name": self.name, "type": self.type}
96
+
97
+
98
+ @dataclass
99
+ class NodeSpec:
100
+ """One node type in the catalog. ``id`` is ``"<category>.<name>"``."""
101
+ id: str
102
+ category: str
103
+ title: str
104
+ help: str = ""
105
+ title_short: str | None = None
106
+ inputs: list[Port] = field(default_factory=list)
107
+ outputs: list[Port] = field(default_factory=list)
108
+ params: list[Param] = field(default_factory=list)
109
+ experimental: bool = False
110
+ meta: dict = field(default_factory=dict)
111
+
112
+ def to_json(self) -> dict:
113
+ d = {
114
+ "id": self.id, "category": self.category, "title": self.title,
115
+ "help": self.help,
116
+ "inputs": [p.to_json() for p in self.inputs],
117
+ "outputs": [p.to_json() for p in self.outputs],
118
+ "params": [p.to_json() for p in self.params],
119
+ "experimental": self.experimental,
120
+ }
121
+ if self.title_short:
122
+ d["title_short"] = self.title_short
123
+ if self.meta:
124
+ d["meta"] = self.meta
125
+ return d
126
+
127
+
128
+ # A semantic check runs after structural/type validation succeeds. It receives the
129
+ # compiled graph and returns a list of human-readable error strings ([] == ok).
130
+ SemanticCheck = Callable[["Any"], list[str]]
131
+
132
+
133
+ class Registry:
134
+ """The declarative surface a host app fills in, then hands to ``create_app``.
135
+
136
+ Call the ``add_*`` methods in any order. Every hyperparameter you add becomes a
137
+ matching typed input on the Train sink automatically, so you never wire the sink
138
+ by hand. Call :meth:`catalog` (the ``/api/nodes`` payload) once you're done —
139
+ it lazily appends the Train node from the hyperparameters registered so far.
140
+ """
141
+
142
+ def __init__(self) -> None:
143
+ self._nodes: list[NodeSpec] = []
144
+ self._by_id: dict[str, NodeSpec] = {}
145
+ self._hparam_ids: list[str] = [] # order-preserving, for the sink
146
+ self._checks: list[SemanticCheck] = []
147
+ self._live_transforms: set[str] = set()
148
+
149
+ # -- low-level -----------------------------------------------------------
150
+ def add_node(self, spec: NodeSpec) -> NodeSpec:
151
+ if spec.id in self._by_id:
152
+ raise ValueError(f"duplicate node id {spec.id!r}")
153
+ if spec.category not in CATEGORIES:
154
+ raise ValueError(f"unknown category {spec.category!r}")
155
+ self._nodes.append(spec)
156
+ self._by_id[spec.id] = spec
157
+ return spec
158
+
159
+ def add_semantic_check(self, fn: SemanticCheck) -> None:
160
+ """Register a project-specific validity rule (e.g. model/dataset kind must
161
+ match). Runs only once the graph is structurally sound."""
162
+ self._checks.append(fn)
163
+
164
+ # -- ergonomic builders --------------------------------------------------
165
+ def add_dataset(self, name: str, *, title: str, help: str = "",
166
+ features: list[str] | None = None,
167
+ targets: list[str] | None = None,
168
+ kind: str = "", trainable: bool = True,
169
+ meta: dict | None = None) -> NodeSpec:
170
+ """A training source. Emits a single ``dataset`` port, no inputs.
171
+
172
+ ``kind`` is a free-form tag (e.g. "tabular", "sequence") you can match
173
+ against a model's required kind in a semantic check. ``trainable=False``
174
+ lists a feature-only set that the compiler will refuse to train."""
175
+ features = features or []
176
+ targets = targets or []
177
+ m = {"trainable": trainable, "kind": kind,
178
+ "features": list(features), "targets": list(targets)}
179
+ m.update(meta or {})
180
+ return self.add_node(NodeSpec(
181
+ id=f"dataset.{name}", category="dataset", title=title,
182
+ title_short=name, help=help,
183
+ outputs=[Port("dataset", "dataset")],
184
+ params=[
185
+ Param("n_features", "features", "int", len(features),
186
+ help=", ".join(features) or "(none)", readonly=True),
187
+ Param("n_targets", "targets", "int", len(targets),
188
+ help=", ".join(targets) or "(none)", readonly=True),
189
+ ] + ([Param("kind", "kind", "enum", kind, choices=[kind],
190
+ help="Dataset kind.", readonly=True)] if kind else []),
191
+ meta=m,
192
+ ))
193
+
194
+ def add_model(self, name: str, *, title: str, title_short: str | None = None,
195
+ help: str = "", params: list[Param] | None = None,
196
+ requires_kind: str = "") -> NodeSpec:
197
+ """A trainable architecture. Takes a ``dataset`` in, emits a ``model`` out.
198
+
199
+ ``requires_kind`` (optional) is matched against the dataset's ``kind`` by
200
+ the built-in kind check, so an incompatible pairing fails to compile."""
201
+ return self.add_node(NodeSpec(
202
+ id=f"model.{name}", category="model", title=title,
203
+ title_short=title_short or name, help=help,
204
+ inputs=[Port("dataset", "dataset")],
205
+ outputs=[Port("model", "model")],
206
+ params=list(params or []),
207
+ meta={"requires_kind": requires_kind} if requires_kind else {},
208
+ ))
209
+
210
+ def add_hyperparameter(self, name: str, *, label: str, kind: str = "float",
211
+ default: Any = None, help: str = "",
212
+ min: float | None = None,
213
+ max: float | None = None) -> NodeSpec:
214
+ """A single scalar knob. Emits ``scalar/<name>`` so it only plugs into the
215
+ Train sink's matching ``<name>`` input (added automatically)."""
216
+ self._hparam_ids.append(name)
217
+ return self.add_node(NodeSpec(
218
+ id=f"hyperparameter.{name}", category="hyperparameter", title=label,
219
+ help=help, outputs=[Port("value", f"scalar/{name}")],
220
+ params=[Param(name, label, kind, default, help=help, min=min, max=max)],
221
+ ))
222
+
223
+ def add_loss(self, params: list[Param], *, title: str = "Loss",
224
+ help: str = "Training objective.") -> NodeSpec:
225
+ """The Loss node. Emits a ``loss`` port into the Train sink."""
226
+ return self.add_node(NodeSpec(
227
+ id="loss.loss", category="loss", title=title, help=help,
228
+ outputs=[Port("loss", "loss")], params=list(params),
229
+ ))
230
+
231
+ def add_transform(self, name: str, *, title: str, help: str = "",
232
+ live: bool = True) -> NodeSpec:
233
+ """A chainable feature transform: ``dataset`` in, ``dataset`` out.
234
+
235
+ ``live=False`` marks it experimental — shown in the palette but rejected by
236
+ the compiler until your backend can honor it."""
237
+ if live:
238
+ self._live_transforms.add(name)
239
+ return self.add_node(NodeSpec(
240
+ id=f"transform.{name}", category="transform", title=title,
241
+ help=help if live else f"(not yet wireable) {help}",
242
+ inputs=[Port("dataset", "dataset")],
243
+ outputs=[Port("dataset", "dataset")],
244
+ experimental=not live,
245
+ ))
246
+
247
+ # -- assembly ------------------------------------------------------------
248
+ def _train_node(self) -> NodeSpec:
249
+ """The terminal Train sink: model + loss + one scalar input per registered
250
+ hyperparameter, each on its field-specific subtype."""
251
+ scalar_inputs = [Port(h, f"scalar/{h}") for h in self._hparam_ids]
252
+ return NodeSpec(
253
+ id="train.train", category="train", title="Train",
254
+ help="Compiles the wired graph and launches a background training job. "
255
+ "Exactly one per graph.",
256
+ inputs=[Port("model", "model"), Port("loss", "loss"), *scalar_inputs],
257
+ outputs=[Port("run", "run")],
258
+ )
259
+
260
+ def nodes(self) -> list[NodeSpec]:
261
+ """All registered nodes plus the auto-built Train sink."""
262
+ return [*self._nodes, self._train_node()]
263
+
264
+ def catalog(self) -> dict:
265
+ """The ``GET /api/nodes`` payload: port glossary, ordered categories, nodes."""
266
+ return {
267
+ "port_types": PORT_TYPES,
268
+ "categories": CATEGORIES,
269
+ "nodes": [n.to_json() for n in self.nodes()],
270
+ }
271
+
272
+ # accessors used by the compiler
273
+ @property
274
+ def hyperparameter_ids(self) -> list[str]:
275
+ return list(self._hparam_ids)
276
+
277
+ @property
278
+ def semantic_checks(self) -> list[SemanticCheck]:
279
+ return list(self._checks)
280
+
281
+ @property
282
+ def live_transforms(self) -> set[str]:
283
+ return set(self._live_transforms)
modulearn/server.py ADDED
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env python3
2
+ """The ModuLearn web server: a FastAPI app factory around your :class:`Registry`.
3
+
4
+ :func:`create_app` gives you the whole visual editor — palette, canvas, live
5
+ type-checking, one-click launch and on-canvas run tracking — wired to *your*
6
+ training code through two things you provide:
7
+
8
+ * a :class:`~modulearn.registry.Registry` describing your nodes, and
9
+ * an ``on_train(compiled, reporter)`` callback that actually trains.
10
+
11
+ ModuLearn owns the run directory, the background thread, and the state/metrics files
12
+ the editor polls; your callback just trains and calls ``reporter`` to report
13
+ progress. Training runs in a daemon thread owned by this process and persists to
14
+ disk, so the page is a stateless live window: closing the browser never stops a run.
15
+
16
+ THE STATE CONTRACT (what the editor's live panel + chart read)
17
+ --------------------------------------------------------------
18
+ ``reporter.state(...)`` merges keys into ``state.json``; ``reporter.metric(...)``
19
+ appends a row to ``metrics.jsonl``. The frontend understands these keys:
20
+ state: ``phase`` (running|done|error|paused), ``epoch``, ``epochs``,
21
+ ``best_val``, ``test_score``, ``error``.
22
+ metric row: ``epoch``, ``train``, ``val`` — drives the live learning curve.
23
+ Anything else you write is preserved and returned by the API, just not charted.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import threading
29
+ import uuid
30
+ from dataclasses import dataclass, field
31
+ from pathlib import Path
32
+ from typing import Callable
33
+
34
+ from fastapi import FastAPI, HTTPException
35
+ from fastapi.responses import FileResponse, Response
36
+ from fastapi.staticfiles import StaticFiles
37
+ from pydantic import BaseModel
38
+
39
+ from modulearn import compiler
40
+ from modulearn.registry import Registry
41
+
42
+ STATIC = Path(__file__).resolve().parent / "static"
43
+
44
+
45
+ @dataclass
46
+ class RunReporter:
47
+ """The handle your ``on_train`` uses to publish progress. Writes are atomic
48
+ enough for a poller: state is a merged JSON doc, metrics an append-only log."""
49
+ run_dir: Path
50
+ _state: dict = field(default_factory=dict)
51
+
52
+ def state(self, **kw) -> None:
53
+ self._state.update(kw)
54
+ (self.run_dir / "state.json").write_text(json.dumps(self._state, indent=2))
55
+
56
+ def metric(self, **row) -> None:
57
+ with (self.run_dir / "metrics.jsonl").open("a") as f:
58
+ f.write(json.dumps(row) + "\n")
59
+
60
+
61
+ # on_train(compiled, reporter) -> None. Raise to mark the run errored.
62
+ TrainFn = Callable[[compiler.CompiledGraph, RunReporter], None]
63
+
64
+
65
+ class _GraphReq(BaseModel):
66
+ nodes: list[dict] = []
67
+ links: list[dict] = []
68
+
69
+
70
+ class _Job:
71
+ def __init__(self, app_state: "_AppState", compiled: compiler.CompiledGraph):
72
+ self.compiled = compiled
73
+ self._app = app_state
74
+ self.thread = threading.Thread(target=self._run, daemon=True)
75
+
76
+ def _run(self):
77
+ run_dir = self._app.run_dir(self.compiled.run_id)
78
+ reporter = RunReporter(run_dir)
79
+ reporter.state(phase="running", epoch=0)
80
+ try:
81
+ self._app.on_train(self.compiled, reporter)
82
+ if reporter._state.get("phase") == "running":
83
+ reporter.state(phase="done")
84
+ except Exception as e: # never let a job crash silently
85
+ reporter.state(phase="error", error=str(e))
86
+ finally:
87
+ self._app.jobs.pop(self.compiled.run_id, None)
88
+
89
+ def start(self):
90
+ self.thread.start()
91
+
92
+
93
+ @dataclass
94
+ class _AppState:
95
+ registry: Registry
96
+ on_train: TrainFn
97
+ runs: Path
98
+ jobs: dict = field(default_factory=dict)
99
+
100
+ def run_dir(self, run_id: str) -> Path:
101
+ d = self.runs / run_id
102
+ d.mkdir(parents=True, exist_ok=True)
103
+ return d
104
+
105
+
106
+ def create_app(registry: Registry, on_train: TrainFn, *,
107
+ runs_dir: str | Path = "runs",
108
+ title: str = "ModuLearn", subtitle: str = "") -> FastAPI:
109
+ """Build the FastAPI app. ``title``/``subtitle`` brand the editor's header."""
110
+ state = _AppState(registry=registry, on_train=on_train, runs=Path(runs_dir))
111
+ app = FastAPI(title=title)
112
+
113
+ def _read(run_id: str, fname: str) -> dict:
114
+ p = state.runs / run_id / fname
115
+ return json.loads(p.read_text()) if p.exists() else {}
116
+
117
+ @app.get("/api/nodes")
118
+ def api_nodes():
119
+ return registry.catalog()
120
+
121
+ @app.get("/api/meta")
122
+ def api_meta():
123
+ return {"title": title, "subtitle": subtitle}
124
+
125
+ @app.get("/api/runs")
126
+ def api_runs():
127
+ runs = []
128
+ if state.runs.exists():
129
+ for d in sorted(state.runs.iterdir(),
130
+ key=lambda p: p.stat().st_mtime, reverse=True):
131
+ if not d.is_dir():
132
+ continue
133
+ st = _read(d.name, "state.json")
134
+ runs.append({"run_id": d.name, "phase": st.get("phase", "?"),
135
+ "epoch": st.get("epoch"), "epochs": st.get("epochs"),
136
+ "best_val": st.get("best_val"),
137
+ "test_score": st.get("test_score"),
138
+ "live": d.name in state.jobs})
139
+ return {"runs": runs}
140
+
141
+ @app.get("/api/run/{run_id}")
142
+ def api_run(run_id: str):
143
+ d = state.runs / run_id
144
+ if not d.exists():
145
+ raise HTTPException(404, "no such run")
146
+ metrics = []
147
+ mp = d / "metrics.jsonl"
148
+ if mp.exists():
149
+ metrics = [json.loads(l) for l in mp.read_text().splitlines() if l.strip()]
150
+ return {"run_id": run_id, "state": _read(run_id, "state.json"),
151
+ "config": _read(run_id, "config.json"), "metrics": metrics,
152
+ "live": run_id in state.jobs}
153
+
154
+ @app.post("/api/graph/compile")
155
+ def api_compile(req: _GraphReq):
156
+ try:
157
+ c = compiler.compile_graph({"nodes": req.nodes, "links": req.links}, registry)
158
+ except compiler.GraphError as e:
159
+ raise HTTPException(400, {"errors": e.errors})
160
+ return {"ok": True, "config": c.as_dict()}
161
+
162
+ @app.post("/api/graph/start")
163
+ def api_start(req: _GraphReq):
164
+ try:
165
+ c = compiler.compile_graph({"nodes": req.nodes, "links": req.links}, registry)
166
+ except compiler.GraphError as e:
167
+ raise HTTPException(400, {"errors": e.errors})
168
+ d = state.run_dir(c.run_id)
169
+ (d / "graph.json").write_text(json.dumps(
170
+ {"nodes": req.nodes, "links": req.links}, indent=2))
171
+ (d / "config.json").write_text(json.dumps(c.as_dict(), indent=2))
172
+ job = _Job(state, c)
173
+ state.jobs[c.run_id] = job
174
+ job.start()
175
+ return {"run_id": c.run_id, "config": c.as_dict()}
176
+
177
+ @app.get("/api/graph/{run_id}")
178
+ def api_graph_get(run_id: str):
179
+ p = state.runs / run_id / "graph.json"
180
+ if not p.exists():
181
+ raise HTTPException(404, "no saved graph for this run")
182
+ return json.loads(p.read_text())
183
+
184
+ @app.get("/")
185
+ def index():
186
+ return FileResponse(STATIC / "graph.html")
187
+
188
+ @app.get("/favicon.ico")
189
+ def favicon():
190
+ svg = ('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">'
191
+ '<circle cx="16" cy="16" r="13" fill="#a8542f"/></svg>')
192
+ return Response(content=svg, media_type="image/svg+xml")
193
+
194
+ app.mount("/static", StaticFiles(directory=STATIC), name="static")
195
+ return app