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 +16 -0
- modulearn/cli.py +88 -0
- modulearn/compiler.py +238 -0
- modulearn/demo.py +81 -0
- modulearn/registry.py +283 -0
- modulearn/server.py +195 -0
- modulearn/static/graph-engine.js +623 -0
- modulearn/static/graph.html +170 -0
- modulearn/static/graph.js +1003 -0
- modulearn-0.1.0.dist-info/METADATA +163 -0
- modulearn-0.1.0.dist-info/RECORD +14 -0
- modulearn-0.1.0.dist-info/WHEEL +4 -0
- modulearn-0.1.0.dist-info/entry_points.txt +2 -0
- modulearn-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|