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/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """ModuLearn — a visual, node-graph training editor you wire to your own models.
2
+
3
+ Describe your datasets/models/hyperparameters with a :class:`Registry`, pass it to
4
+ :func:`create_app` with an ``on_train`` callback, and you get an Unreal-Blueprints
5
+ style canvas: drop nodes, wire them, launch, and watch training live on the graph.
6
+ """
7
+ from modulearn.registry import Registry, Param, Port, NodeSpec, PORT_TYPES, CATEGORIES
8
+ from modulearn.compiler import compile_graph, CompiledGraph, GraphError
9
+ from modulearn.server import create_app, RunReporter
10
+
11
+ __all__ = [
12
+ "Registry", "Param", "Port", "NodeSpec", "PORT_TYPES", "CATEGORIES",
13
+ "compile_graph", "CompiledGraph", "GraphError",
14
+ "create_app", "RunReporter",
15
+ ]
16
+ __version__ = "0.1.0"
modulearn/cli.py ADDED
@@ -0,0 +1,88 @@
1
+ """``modulearn`` command-line entry point.
2
+
3
+ modulearn demo # serve the bundled demo editor
4
+ modulearn run app.py # serve YOUR app's FastAPI object (attr `app`)
5
+ modulearn run app.py:editor # ...or a differently-named attribute
6
+ modulearn --version
7
+
8
+ ``run`` loads a Python file that builds an app with ``create_app`` and serves it.
9
+ The target is a FastAPI instance (e.g. ``app = create_app(reg, on_train)``) or a
10
+ zero-arg factory that returns one.
11
+ """
12
+ import argparse
13
+ import importlib.util
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from modulearn import __version__
18
+
19
+
20
+ def _load_app(target: str):
21
+ """Resolve ``path.py`` or ``path.py:attr`` to a FastAPI app object."""
22
+ path_str, _, attr = target.partition(":")
23
+ attr = attr or "app"
24
+ path = Path(path_str).resolve()
25
+ if not path.exists():
26
+ sys.exit(f"modulearn: no such file: {path_str}")
27
+
28
+ # Import the file as a module, with its own directory on sys.path so the
29
+ # user's app can import sibling modules.
30
+ sys.path.insert(0, str(path.parent))
31
+ spec = importlib.util.spec_from_file_location(path.stem, path)
32
+ module = importlib.util.module_from_spec(spec)
33
+ try:
34
+ spec.loader.exec_module(module)
35
+ except Exception as e: # surface the user's import error cleanly
36
+ sys.exit(f"modulearn: failed to import {path_str}: {e}")
37
+
38
+ obj = getattr(module, attr, None)
39
+ if obj is None:
40
+ sys.exit(f"modulearn: {path_str} has no attribute '{attr}' "
41
+ f"(expected a FastAPI app or a factory returning one)")
42
+ return obj() if callable(obj) and not _is_asgi_app(obj) else obj
43
+
44
+
45
+ def _is_asgi_app(obj) -> bool:
46
+ """A FastAPI/Starlette instance is callable too — tell it apart from a factory."""
47
+ return obj.__class__.__module__.startswith(("fastapi", "starlette"))
48
+
49
+
50
+ def _serve(app, host: str, port: int):
51
+ import uvicorn
52
+ url = f"http://{'localhost' if host in ('0.0.0.0', '') else host}:{port}"
53
+ print(f"ModuLearn → {url} (Ctrl-C to stop)")
54
+ uvicorn.run(app, host=host, port=port)
55
+
56
+
57
+ def main(argv=None):
58
+ parser = argparse.ArgumentParser(
59
+ prog="modulearn",
60
+ description="Visual, node-graph editor for building and launching ML training runs.")
61
+ parser.add_argument("-V", "--version", action="version",
62
+ version=f"modulearn {__version__}")
63
+ sub = parser.add_subparsers(dest="command")
64
+
65
+ p_demo = sub.add_parser("demo", help="serve the bundled demo editor")
66
+ p_demo.add_argument("--host", default="127.0.0.1")
67
+ p_demo.add_argument("--port", type=int, default=8000)
68
+
69
+ p_run = sub.add_parser("run", help="serve your own app.py (an app built with create_app)")
70
+ p_run.add_argument("target", metavar="APP",
71
+ help="path to your app file, e.g. train.py or train.py:app")
72
+ p_run.add_argument("--host", default="127.0.0.1")
73
+ p_run.add_argument("--port", type=int, default=8000)
74
+
75
+ args = parser.parse_args(argv)
76
+
77
+ if args.command == "demo":
78
+ from modulearn.demo import build_app
79
+ _serve(build_app(), args.host, args.port)
80
+ elif args.command == "run":
81
+ _serve(_load_app(args.target), args.host, args.port)
82
+ else:
83
+ parser.print_help()
84
+ sys.exit(1)
85
+
86
+
87
+ if __name__ == "__main__":
88
+ main()
modulearn/compiler.py ADDED
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env python3
2
+ """Compile a visual ('Blueprints') canvas into a validated :class:`CompiledGraph`.
3
+
4
+ The frontend serializes the canvas to the compact **graph JSON** below and POSTs it
5
+ here. This module walks that graph, type-checks every wire against the registry's
6
+ catalog, runs any project-specific semantic checks, and — if valid — hands back a
7
+ plain :class:`CompiledGraph`. Your ``on_train`` callback turns that into whatever
8
+ config object your trainer wants. Compilation is pure and side-effect-free, so it is
9
+ cheap to unit-test with no web server or ML framework in the loop.
10
+
11
+ THE GRAPH JSON (input to :func:`compile_graph`)
12
+ -----------------------------------------------
13
+ {
14
+ "nodes": [
15
+ {"id": "n1", "type": "dataset.iris", "params": {}},
16
+ {"id": "n2", "type": "model.mlp", "params": {"hidden": [64, 32]}},
17
+ {"id": "n3", "type": "hyperparameter.lr", "params": {"lr": 5e-4}},
18
+ {"id": "n4", "type": "loss.loss", "params": {"loss": "mse"}},
19
+ {"id": "n9", "type": "train.train", "params": {}}
20
+ ],
21
+ "links": [
22
+ {"from": "n1", "from_port": "dataset", "to": "n2", "to_port": "dataset"},
23
+ {"from": "n2", "from_port": "model", "to": "n9", "to_port": "model"},
24
+ {"from": "n4", "from_port": "loss", "to": "n9", "to_port": "loss"},
25
+ {"from": "n3", "from_port": "value", "to": "n9", "to_port": "lr"}
26
+ ]
27
+ }
28
+
29
+ VALIDATION (all failures collected, then raised together)
30
+ ---------------------------------------------------------
31
+ :func:`compile_graph` raises :class:`GraphError` (carrying the full list, so the UI
32
+ can flag every problem at once) on: unknown/duplicate node, a link to a missing node
33
+ or port, a **type mismatch** on any wire (scalars being field-specific catches
34
+ lr→epochs), not exactly one Train node, an unconnected/mis-sourced ``model`` input,
35
+ a Model→Transform→…→Dataset chain that doesn't terminate at a Dataset, a wired
36
+ ``experimental`` node, a feature-only dataset, or any host semantic check failing.
37
+ """
38
+ from __future__ import annotations
39
+
40
+ import uuid
41
+ from dataclasses import dataclass, field
42
+
43
+ from modulearn.registry import Registry
44
+
45
+
46
+ class GraphError(Exception):
47
+ """Raised when a graph can't compile. ``.errors`` is the full problem list."""
48
+
49
+ def __init__(self, errors: list[str]):
50
+ self.errors = errors
51
+ super().__init__("; ".join(errors))
52
+
53
+
54
+ @dataclass
55
+ class CompiledGraph:
56
+ """The validated result of a canvas. Everything a trainer needs, no node-graph
57
+ or wire concepts left. ``model`` is the short model name (``model.mlp`` → ``mlp``);
58
+ ``hyperparameters`` maps each wired knob to its value."""
59
+ run_id: str
60
+ dataset: str
61
+ model: str
62
+ transforms: tuple[str, ...] = ()
63
+ model_params: dict = field(default_factory=dict)
64
+ loss_params: dict = field(default_factory=dict)
65
+ hyperparameters: dict = field(default_factory=dict)
66
+ meta: dict = field(default_factory=dict) # {"dataset_meta":..., "model_meta":...}
67
+
68
+ def as_dict(self) -> dict:
69
+ return {
70
+ "run_id": self.run_id, "dataset": self.dataset, "model": self.model,
71
+ "transforms": list(self.transforms), "model_params": self.model_params,
72
+ "loss_params": self.loss_params, "hyperparameters": self.hyperparameters,
73
+ }
74
+
75
+
76
+ def _index_catalog(reg: Registry) -> dict[str, dict]:
77
+ idx = {}
78
+ for n in reg.catalog()["nodes"]:
79
+ idx[n["id"]] = {
80
+ **n,
81
+ "_in": {p["name"]: p["type"] for p in n["inputs"]},
82
+ "_out": {p["name"]: p["type"] for p in n["outputs"]},
83
+ }
84
+ return idx
85
+
86
+
87
+ def _params_with_defaults(catalog: dict, gnode: dict) -> dict:
88
+ spec = catalog[gnode["type"]]
89
+ out = {p["name"]: p.get("default") for p in spec["params"]}
90
+ out.update(gnode.get("params") or {})
91
+ return out
92
+
93
+
94
+ def compile_graph(graph: dict, reg: Registry,
95
+ run_id: str | None = None) -> CompiledGraph:
96
+ """Validate ``graph`` against ``reg`` and compile it to a :class:`CompiledGraph`.
97
+
98
+ Raises :class:`GraphError` with every problem found. ``run_id`` is used verbatim
99
+ when given, else generated as ``<dataset>_<hex8>``."""
100
+ catalog = _index_catalog(reg)
101
+ errors: list[str] = []
102
+
103
+ raw_nodes = graph.get("nodes", []) or []
104
+ raw_links = graph.get("links", []) or []
105
+
106
+ # -- index nodes, catching unknown types / duplicate ids -----------------
107
+ gnodes: dict[str, dict] = {}
108
+ for gn in raw_nodes:
109
+ nid, ntype = gn.get("id"), gn.get("type")
110
+ if nid is None or ntype is None:
111
+ errors.append(f"node missing id or type: {gn!r}")
112
+ continue
113
+ if nid in gnodes:
114
+ errors.append(f"duplicate node id {nid!r}")
115
+ continue
116
+ if ntype not in catalog:
117
+ errors.append(f"node {nid!r} has unknown type {ntype!r}")
118
+ continue
119
+ gnodes[nid] = gn
120
+
121
+ # -- validate every link: endpoints, ports, TYPES MATCH ------------------
122
+ incoming: dict[str, dict[str, tuple[str, str]]] = {nid: {} for nid in gnodes}
123
+ for lk in raw_links:
124
+ src, dst = lk.get("from"), lk.get("to")
125
+ sp, dp = lk.get("from_port"), lk.get("to_port")
126
+ if src not in gnodes or dst not in gnodes:
127
+ errors.append(f"link references unknown node(s): {lk!r}")
128
+ continue
129
+ src_out = catalog[gnodes[src]["type"]]["_out"]
130
+ dst_in = catalog[gnodes[dst]["type"]]["_in"]
131
+ if sp not in src_out:
132
+ errors.append(f"node {src!r} has no output port {sp!r}")
133
+ continue
134
+ if dp not in dst_in:
135
+ errors.append(f"node {dst!r} has no input port {dp!r}")
136
+ continue
137
+ if src_out[sp] != dst_in[dp]:
138
+ errors.append(f"type mismatch on wire {src}.{sp} ({src_out[sp]}) -> "
139
+ f"{dst}.{dp} ({dst_in[dp]})")
140
+ continue
141
+ if dp in incoming[dst]:
142
+ errors.append(f"input {dst!r}.{dp!r} is wired more than once")
143
+ continue
144
+ incoming[dst][dp] = (src, sp)
145
+
146
+ # -- reject experimental nodes that are actually wired in ----------------
147
+ wired = {nid for lk in raw_links for nid in (lk.get("from"), lk.get("to"))
148
+ if nid in gnodes}
149
+ for nid in wired:
150
+ if catalog[gnodes[nid]["type"]].get("experimental"):
151
+ errors.append(f"node {nid!r} ({gnodes[nid]['type']}) is experimental "
152
+ f"and cannot be compiled yet — remove it from the run")
153
+
154
+ if errors:
155
+ raise GraphError(errors)
156
+
157
+ # -- exactly one Train sink ----------------------------------------------
158
+ trains = [nid for nid, gn in gnodes.items() if gn["type"] == "train.train"]
159
+ if len(trains) != 1:
160
+ raise GraphError([f"graph needs exactly one Train node, found {len(trains)}"])
161
+ train_id = trains[0]
162
+
163
+ # -- resolve model <- (transforms) <- dataset ----------------------------
164
+ model_link = incoming[train_id].get("model")
165
+ if not model_link:
166
+ raise GraphError(["Train node's 'model' input is not connected"])
167
+ model_id = model_link[0]
168
+ model_type = gnodes[model_id]["type"]
169
+
170
+ transforms: list[str] = []
171
+ cur = model_id
172
+ while True:
173
+ link = incoming[cur].get("dataset")
174
+ if not link:
175
+ raise GraphError([f"node {cur!r} 'dataset' input is not connected"])
176
+ src_id = link[0]
177
+ src_type = gnodes[src_id]["type"]
178
+ if src_type.startswith("transform."):
179
+ transforms.append(src_type.split(".", 1)[1])
180
+ cur = src_id
181
+ elif src_type.startswith("dataset."):
182
+ ds_id = src_id
183
+ break
184
+ else:
185
+ raise GraphError([f"node {cur!r} 'dataset' input must come from a "
186
+ f"Dataset or Transform, not {src_type!r}"])
187
+ transforms.reverse()
188
+
189
+ ds_key = gnodes[ds_id]["type"].split(".", 1)[1]
190
+ ds_meta = catalog[gnodes[ds_id]["type"]].get("meta", {})
191
+ model_meta = catalog[model_type].get("meta", {})
192
+
193
+ # -- gather params -------------------------------------------------------
194
+ model_params = _params_with_defaults(catalog, gnodes[model_id])
195
+ loss_params: dict = {}
196
+ loss_link = incoming[train_id].get("loss")
197
+ if loss_link:
198
+ loss_params = _params_with_defaults(catalog, gnodes[loss_link[0]])
199
+
200
+ hyper: dict = {}
201
+ for port_name, (src_id, _sp) in incoming[train_id].items():
202
+ if port_name in ("model", "loss"):
203
+ continue
204
+ hp = _params_with_defaults(catalog, gnodes[src_id])
205
+ if port_name in hp:
206
+ hyper[port_name] = hp[port_name]
207
+
208
+ compiled = CompiledGraph(
209
+ run_id=run_id or f"{ds_key}_{uuid.uuid4().hex[:8]}",
210
+ dataset=ds_key, model=model_type.split(".", 1)[1],
211
+ transforms=tuple(transforms), model_params=model_params,
212
+ loss_params=loss_params, hyperparameters=hyper,
213
+ meta={"dataset_meta": ds_meta, "model_meta": model_meta},
214
+ )
215
+
216
+ # -- semantic checks: built-in kind/trainable rules, then host rules -----
217
+ sem: list[str] = list(_builtin_checks(compiled))
218
+ for check in reg.semantic_checks:
219
+ sem.extend(check(compiled) or [])
220
+ if sem:
221
+ raise GraphError(sem)
222
+
223
+ return compiled
224
+
225
+
226
+ def _builtin_checks(c: CompiledGraph) -> list[str]:
227
+ """Always-on rules derived from registry metadata: a dataset must be trainable,
228
+ and a model's ``requires_kind`` (if set) must match the dataset's ``kind``."""
229
+ errs: list[str] = []
230
+ ds = c.meta.get("dataset_meta", {})
231
+ if not ds.get("trainable", True) or not ds.get("targets"):
232
+ errs.append(f"dataset {c.dataset!r} is feature-only and cannot be trained")
233
+ need = c.meta.get("model_meta", {}).get("requires_kind")
234
+ have = ds.get("kind")
235
+ if need and have and need != have:
236
+ errs.append(f"model {c.model!r} needs a {need!r} dataset but "
237
+ f"{c.dataset!r} is {have!r}")
238
+ return errs
modulearn/demo.py ADDED
@@ -0,0 +1,81 @@
1
+ """A complete, dependency-free demo app shipped inside the package.
2
+
3
+ This is what ``modulearn demo`` serves. It mirrors ``examples/quickstart.py``
4
+ (the teaching file), but lives in the package so it ships in the wheel and can be
5
+ launched without the source checkout. Swap ``on_train`` for a real trainer and the
6
+ same editor drives it.
7
+ """
8
+ import math
9
+ import random
10
+ import time
11
+
12
+ from modulearn import Registry, Param, create_app
13
+
14
+
15
+ def build_registry() -> Registry:
16
+ """The declarative surface: what this demo can train."""
17
+ reg = Registry()
18
+
19
+ reg.add_dataset("iris", title="Iris (tabular)", kind="tabular",
20
+ features=["sepal_len", "sepal_wid", "petal_len", "petal_wid"],
21
+ targets=["species"], help="Classic 4-feature flower dataset.")
22
+ reg.add_dataset("digits", title="Digits (tabular)", kind="tabular",
23
+ features=[f"px{i}" for i in range(64)], targets=["digit"],
24
+ help="8x8 handwritten digits, flattened.")
25
+ reg.add_dataset("features_only", title="Unlabeled pool", kind="tabular",
26
+ features=["a", "b", "c"], targets=[], trainable=False,
27
+ help="No target column — the compiler will refuse to train it.")
28
+
29
+ reg.add_model("mlp", title="MLP (feed-forward net)", title_short="MLP",
30
+ requires_kind="tabular",
31
+ help="Dense ReLU network over tabular features.",
32
+ params=[
33
+ Param("hidden", "hidden layers", "int_list", [64, 32],
34
+ help="Comma-separated widths, e.g. 64,32."),
35
+ Param("dropout", "dropout", "float", 0.0, min=0.0, max=0.9),
36
+ ])
37
+
38
+ reg.add_hyperparameter("lr", label="learning rate", kind="float", default=1e-3,
39
+ min=1e-6, max=1.0, help="Step size.")
40
+ reg.add_hyperparameter("epochs", label="epochs", kind="int", default=60,
41
+ min=1, max=2000, help="Total training epochs.")
42
+ reg.add_hyperparameter("seed", label="seed", kind="int", default=0, min=0,
43
+ max=2**31 - 1, help="Reproducibility seed.")
44
+
45
+ reg.add_loss([
46
+ Param("loss", "kind", "enum", "cross_entropy",
47
+ choices=["cross_entropy", "mse"], help="Objective."),
48
+ ])
49
+
50
+ reg.add_transform("standardize", title="Standardize",
51
+ help="Zero-mean, unit-variance each feature.")
52
+ reg.add_transform("pca", title="PCA (whiten)", live=False,
53
+ help="Dimensionality reduction — not wired up in this demo.")
54
+ return reg
55
+
56
+
57
+ def on_train(compiled, reporter):
58
+ """A toy loss curve so the demo runs anywhere — no ML deps."""
59
+ hp = compiled.hyperparameters
60
+ epochs = int(hp.get("epochs", 60))
61
+ lr = float(hp.get("lr", 1e-3))
62
+ random.seed(int(hp.get("seed", 0)))
63
+ reporter.state(epochs=epochs)
64
+
65
+ best = float("inf")
66
+ floor = 0.15 + random.random() * 0.1
67
+ for e in range(epochs):
68
+ t = e / max(1, epochs - 1)
69
+ train = floor + (1.4 * math.exp(-4 * lr * 100 * t)) + random.uniform(-0.02, 0.02)
70
+ val = train + 0.05 + random.uniform(0, 0.04)
71
+ best = min(best, val)
72
+ reporter.metric(epoch=e, train=round(train, 4), val=round(val, 4))
73
+ reporter.state(epoch=e, best_val=round(best, 4))
74
+ time.sleep(0.05)
75
+ reporter.state(phase="done", test_score=round(best + 0.02, 4))
76
+
77
+
78
+ def build_app(runs_dir="runs"):
79
+ """The FastAPI app ``modulearn demo`` serves."""
80
+ return create_app(build_registry(), on_train, title="ModuLearn",
81
+ subtitle="demo", runs_dir=runs_dir)