flashruntime 0.3.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.
Files changed (95) hide show
  1. flashml_workloads/__init__.py +7 -0
  2. flashml_workloads/fedavg_driver.py +569 -0
  3. flashml_workloads/fedavg_weights.py +223 -0
  4. flashml_workloads/fedavg_worker.py +166 -0
  5. flashml_workloads/kmeans_driver.py +134 -0
  6. flashml_workloads/kmeans_shard.py +69 -0
  7. flashml_workloads/sgd_trainer.py +127 -0
  8. flashml_workloads/sharded_kmeans.py +323 -0
  9. flashml_workloads/sklearn_trial.py +89 -0
  10. flashruntime/__init__.py +125 -0
  11. flashruntime/artifacts/__init__.py +25 -0
  12. flashruntime/artifacts/store.py +228 -0
  13. flashruntime/backends/__init__.py +26 -0
  14. flashruntime/backends/base.py +63 -0
  15. flashruntime/backends/kuberay.py +465 -0
  16. flashruntime/checkpoint/__init__.py +20 -0
  17. flashruntime/checkpoint/catalog.py +198 -0
  18. flashruntime/checkpoint/local.py +109 -0
  19. flashruntime/checkpoint/store.py +86 -0
  20. flashruntime/integrations/__init__.py +5 -0
  21. flashruntime/integrations/huggingface.py +59 -0
  22. flashruntime/integrations/pytorch.py +52 -0
  23. flashruntime/integrations/sklearn.py +42 -0
  24. flashruntime/launchers/__init__.py +130 -0
  25. flashruntime/launchers/local.py +126 -0
  26. flashruntime/leases/__init__.py +27 -0
  27. flashruntime/leases/manager.py +365 -0
  28. flashruntime/leases/sqlite_store.py +169 -0
  29. flashruntime/leases/store.py +103 -0
  30. flashruntime/monitor/__init__.py +7 -0
  31. flashruntime/monitor/sampler.py +232 -0
  32. flashruntime/planner/__init__.py +56 -0
  33. flashruntime/planner/candidates.py +597 -0
  34. flashruntime/planner/catalog.py +129 -0
  35. flashruntime/planner/comm.py +95 -0
  36. flashruntime/planner/explain.py +109 -0
  37. flashruntime/planner/memory.py +166 -0
  38. flashruntime/planner/resolve.py +120 -0
  39. flashruntime/planner/selector.py +169 -0
  40. flashruntime/planner/timecost.py +81 -0
  41. flashruntime/profiling/__init__.py +113 -0
  42. flashruntime/protocol/__init__.py +18 -0
  43. flashruntime/protocol/plan_v1alpha1.py +320 -0
  44. flashruntime/protocol/v1alpha1.py +465 -0
  45. flashruntime/providers/__init__.py +138 -0
  46. flashruntime/py.typed +0 -0
  47. flashruntime/recipes/__init__.py +135 -0
  48. flashruntime/recipes/command.py +166 -0
  49. flashruntime/recovery/__init__.py +21 -0
  50. flashruntime/recovery/policy.py +170 -0
  51. flashruntime/recovery/signals.py +135 -0
  52. flashruntime/recovery/taxonomy.py +91 -0
  53. flashruntime/scheduler/__init__.py +170 -0
  54. flashruntime/sdk.py +402 -0
  55. flashruntime/service/__init__.py +3 -0
  56. flashruntime/service/app.py +391 -0
  57. flashruntime/service/auth.py +180 -0
  58. flashruntime/service/checkpoints.py +90 -0
  59. flashruntime/service/cli.py +167 -0
  60. flashruntime/service/dashboard.py +193 -0
  61. flashruntime/service/ledger.py +101 -0
  62. flashruntime/service/modea.py +821 -0
  63. flashruntime/strategies/__init__.py +156 -0
  64. flashruntime/strategies/command.py +56 -0
  65. flashruntime/torch/__init__.py +274 -0
  66. flashruntime/viewer/__init__.py +20 -0
  67. flashruntime/viewer/_docs/benchmarks.html +771 -0
  68. flashruntime/viewer/_docs/concepts/architecture.html +302 -0
  69. flashruntime/viewer/_docs/get-started.html +263 -0
  70. flashruntime/viewer/_docs/guides/federated-averaging.html +363 -0
  71. flashruntime/viewer/_docs/guides/huggingface.html +223 -0
  72. flashruntime/viewer/_docs/guides/jobspec-and-isolation.html +271 -0
  73. flashruntime/viewer/_docs/guides/pytorch.html +313 -0
  74. flashruntime/viewer/_docs/guides/sklearn.html +232 -0
  75. flashruntime/viewer/_docs/index.html +251 -0
  76. flashruntime/viewer/_docs/reference/cli.html +254 -0
  77. flashruntime/viewer/_docs/reference/integrations.html +240 -0
  78. flashruntime/viewer/_docs/reference/sdk.html +341 -0
  79. flashruntime/viewer/_docs/reference/torch-helper.html +244 -0
  80. flashruntime/viewer/_docs/search-index.json +1 -0
  81. flashruntime/viewer/_docs/tutorials/convnet.html +571 -0
  82. flashruntime/viewer/_docs/tutorials/fault-tolerance.html +375 -0
  83. flashruntime/viewer/_docs/tutorials/sklearn-sweeps.html +278 -0
  84. flashruntime/viewer/flowmap.py +307 -0
  85. flashruntime/viewer/page.py +594 -0
  86. flashruntime/viewer/server.py +134 -0
  87. flashruntime/viewer/state.py +250 -0
  88. flashruntime/workloads/__init__.py +6 -0
  89. flashruntime/workloads/command.py +127 -0
  90. flashruntime-0.3.0.dist-info/METADATA +365 -0
  91. flashruntime-0.3.0.dist-info/RECORD +95 -0
  92. flashruntime-0.3.0.dist-info/WHEEL +5 -0
  93. flashruntime-0.3.0.dist-info/entry_points.txt +2 -0
  94. flashruntime-0.3.0.dist-info/licenses/LICENSE +202 -0
  95. flashruntime-0.3.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,127 @@
1
+ """Checkpointable logistic-regression SGD, runnable as a leased task.
2
+
3
+ Executor contract (same CLI as the other task modules):
4
+
5
+ python -m flashml_workloads.sgd_trainer --spec spec.json --out OUTDIR
6
+
7
+ `spec.json`:
8
+ params: steps, checkpoint_every, lr, seed,
9
+ kill_at_step (optional — fires only on a FRESH start, so a
10
+ resumed retry never re-crashes; this is the deterministic
11
+ kill-and-recover test hook)
12
+ inputs: dataset (headerless CSV, label last), resume (optional path to
13
+ a checkpoint json downloaded by the executor)
14
+
15
+ Checkpoints go to OUTDIR/ckpt/step-NNNNNN.json ({step, weights, bias}); the
16
+ executor's relay uploads them and commits manifests as they appear.
17
+
18
+ Determinism is the contract: batches are indexed by step number (cyclic
19
+ slices, no RNG state to carry), so resuming from step k reproduces the
20
+ uninterrupted run bit-for-bit — recovery must never silently change the
21
+ result. Pure stdlib; runs anywhere, including `--network none` containers.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import csv
28
+ import json
29
+ import math
30
+ import sys
31
+ from pathlib import Path
32
+
33
+ BATCH = 4
34
+
35
+
36
+ def _load(path: str) -> tuple[list[list[float]], list[float]]:
37
+ xs, ys = [], []
38
+ with open(path, newline="") as f:
39
+ for row in csv.reader(f):
40
+ if row:
41
+ xs.append([float(v) for v in row[:-1]])
42
+ ys.append(float(row[-1]))
43
+ return xs, ys
44
+
45
+
46
+ def _loss(xs, ys, w, b) -> float:
47
+ total = 0.0
48
+ for x, y in zip(xs, ys):
49
+ z = sum(wi * xi for wi, xi in zip(w, x)) + b
50
+ p = 1.0 / (1.0 + math.exp(-z))
51
+ p = min(max(p, 1e-12), 1 - 1e-12)
52
+ total += -(y * math.log(p) + (1 - y) * math.log(1 - p))
53
+ return total / len(xs)
54
+
55
+
56
+ def run_trainer(spec: dict, out: Path) -> dict:
57
+ params = spec.get("params", {})
58
+ steps = int(params["steps"])
59
+ every = int(params.get("checkpoint_every", 100))
60
+ lr = float(params.get("lr", 0.1))
61
+ kill_at = params.get("kill_at_step")
62
+
63
+ xs, ys = _load(spec["inputs"]["dataset"])
64
+ dims = len(xs[0])
65
+
66
+ resume_path = spec.get("inputs", {}).get("resume")
67
+ if resume_path:
68
+ ckpt = json.loads(Path(resume_path).read_text())
69
+ w, b, start = list(ckpt["weights"]), float(ckpt["bias"]), int(ckpt["step"])
70
+ resumed = True
71
+ else:
72
+ w, b, start = [0.0] * dims, 0.0, 0
73
+ resumed = False
74
+
75
+ out = Path(out)
76
+ ckpt_dir = out / "ckpt"
77
+ ckpt_dir.mkdir(parents=True, exist_ok=True)
78
+
79
+ n = len(xs)
80
+ for step in range(start + 1, steps + 1):
81
+ # batch indexed by step ⇒ no RNG state; resume is trivially exact
82
+ base = ((step - 1) * BATCH) % n
83
+ idx = [(base + i) % n for i in range(BATCH)]
84
+ gw, gb = [0.0] * dims, 0.0
85
+ for i in idx:
86
+ z = sum(wi * xi for wi, xi in zip(w, xs[i])) + b
87
+ p = 1.0 / (1.0 + math.exp(-z))
88
+ err = p - ys[i]
89
+ for d in range(dims):
90
+ gw[d] += err * xs[i][d]
91
+ gb += err
92
+ for d in range(dims):
93
+ w[d] -= lr * gw[d] / BATCH
94
+ b -= lr * gb / BATCH
95
+
96
+ if step % every == 0 and step < steps:
97
+ (ckpt_dir / f"step-{step:06d}.json").write_text(
98
+ json.dumps({"step": step, "weights": w, "bias": b})
99
+ )
100
+ if kill_at is not None and not resumed and step >= int(kill_at):
101
+ # simulated crash on a fresh run only — retries resume, not re-die
102
+ sys.exit(3)
103
+
104
+ metrics = {
105
+ "task_id": spec.get("task_id", ""),
106
+ "steps": steps,
107
+ "started_from_step": start,
108
+ "resumed": resumed,
109
+ "final_loss": round(_loss(xs, ys, w, b), 12),
110
+ "weights": [round(v, 12) for v in w],
111
+ "bias": round(b, 12),
112
+ }
113
+ (out / "metrics.json").write_text(json.dumps(metrics, sort_keys=True))
114
+ return metrics
115
+
116
+
117
+ def main(argv: list[str] | None = None) -> int:
118
+ parser = argparse.ArgumentParser(prog="sgd_trainer")
119
+ parser.add_argument("--spec", required=True)
120
+ parser.add_argument("--out", required=True)
121
+ args = parser.parse_args(argv)
122
+ run_trainer(json.loads(Path(args.spec).read_text()), Path(args.out))
123
+ return 0
124
+
125
+
126
+ if __name__ == "__main__":
127
+ raise SystemExit(main())
@@ -0,0 +1,323 @@
1
+ """Iterative sharded K-Means on Ray tasks — the POC's real distributed job.
2
+
3
+ Shape (per docs/SYSTEM_OVERVIEW and the POC brief):
4
+ - deterministic synthetic dataset, generated per shard from (seed, shard_id)
5
+ so shards are reproducible anywhere without shipping data;
6
+ - each iteration maps one retriable Ray task per shard (many more shards
7
+ than workers → dynamic scheduling), reduces partial sums on the driver,
8
+ updates centroids;
9
+ - every task reports which Ray node / Kubernetes node / pod it ran on;
10
+ - retry evidence is *measured*, not fabricated: after the run the driver
11
+ queries Ray's task state API for tasks whose attempt count exceeds 1 and
12
+ correlates in-run worker-set changes;
13
+ - final artifacts (centroids, metrics, execution summary, node
14
+ contributions, recovery events) are uploaded to the configured artifact
15
+ store (MinIO locally, OSS on Alibaba) — durable outside any worker.
16
+
17
+ Parameters (FLASHML_WORKLOAD_PARAMS JSON): samples, dimensions, clusters,
18
+ shards, iterations, seed, task_delay_seconds.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import json
25
+ import os
26
+ import socket
27
+ import time
28
+ from datetime import datetime, timezone
29
+ from pathlib import Path
30
+
31
+ import numpy as np
32
+
33
+ # `ray` is imported lazily inside the functions that need it: the pure data
34
+ # functions (make_shard, _true_centers) must stay importable in environments
35
+ # without Ray (unit tests, the runtime service image).
36
+
37
+
38
+ def _now_iso() -> str:
39
+ return datetime.now(timezone.utc).isoformat()
40
+
41
+
42
+ def _params() -> dict:
43
+ p = json.loads(os.environ.get("FLASHML_WORKLOAD_PARAMS", "{}"))
44
+ return {
45
+ "samples": int(p.get("samples", 150_000)),
46
+ "dimensions": int(p.get("dimensions", 24)),
47
+ "clusters": int(p.get("clusters", 6)),
48
+ "shards": int(p.get("shards", 36)),
49
+ "iterations": int(p.get("iterations", 12)),
50
+ "seed": int(p.get("seed", 42)),
51
+ "task_delay_seconds": float(p.get("task_delay_seconds", 0.4)),
52
+ }
53
+
54
+
55
+ def _true_centers(seed: int, clusters: int, dimensions: int) -> np.ndarray:
56
+ return np.random.default_rng(seed).uniform(-10, 10, size=(clusters, dimensions))
57
+
58
+
59
+ def make_shard(seed: int, shard_id: int, samples_per_shard: int,
60
+ clusters: int, dimensions: int) -> np.ndarray:
61
+ """Deterministic shard: same (seed, shard_id) always yields the same
62
+ points, so a retried task recomputes identical data."""
63
+ centers = _true_centers(seed, clusters, dimensions)
64
+ rng = np.random.default_rng(seed * 1_000_003 + shard_id)
65
+ assignment = rng.integers(0, clusters, size=samples_per_shard)
66
+ noise = rng.normal(0.0, 1.5, size=(samples_per_shard, dimensions))
67
+ return centers[assignment] + noise
68
+
69
+
70
+ def assign_shard(shard_id: int, centroids: np.ndarray, cfg: dict) -> dict:
71
+ """One map task: assign a shard's points to the current centroids and
72
+ return partial sums/counts plus the identity of the node that did it."""
73
+ import ray
74
+
75
+ t0 = time.monotonic()
76
+ X = make_shard(cfg["seed"], shard_id, cfg["samples_per_shard"],
77
+ cfg["clusters"], cfg["dimensions"])
78
+ if cfg["task_delay_seconds"] > 0:
79
+ # Only to stretch the run long enough to inject a failure reliably.
80
+ time.sleep(cfg["task_delay_seconds"])
81
+
82
+ # Nearest-centroid assignment.
83
+ d2 = ((X[:, None, :] - centroids[None, :, :]) ** 2).sum(axis=2)
84
+ labels = d2.argmin(axis=1)
85
+ inertia = float(d2[np.arange(len(X)), labels].sum())
86
+
87
+ k = centroids.shape[0]
88
+ sums = np.zeros_like(centroids)
89
+ counts = np.zeros(k, dtype=np.int64)
90
+ for c in range(k):
91
+ mask = labels == c
92
+ counts[c] = int(mask.sum())
93
+ if counts[c]:
94
+ sums[c] = X[mask].sum(axis=0)
95
+
96
+ ctx = ray.get_runtime_context()
97
+ return {
98
+ "shard_id": shard_id,
99
+ "partial_sums": sums,
100
+ "partial_counts": counts,
101
+ "inertia": inertia,
102
+ "n_samples": int(len(X)),
103
+ "duration_ms": int((time.monotonic() - t0) * 1000),
104
+ "ray_node_id": ctx.get_node_id(),
105
+ "pod": os.environ.get("K8S_POD_NAME", socket.gethostname()),
106
+ "k8s_node": os.environ.get("K8S_NODE_NAME", "unknown"),
107
+ }
108
+
109
+
110
+ def _worker_set() -> dict[str, str]:
111
+ """Alive Ray nodes (node_id -> node name/ip), head included."""
112
+ import ray
113
+
114
+ return {n["NodeID"]: n.get("NodeManagerHostname", n.get("NodeManagerAddress", "?"))
115
+ for n in ray.nodes() if n["Alive"]}
116
+
117
+
118
+ def _collect_retry_evidence(job_id: str) -> list[dict]:
119
+ """Real per-task attempt metadata from Ray's state API. A task with
120
+ attempt_number > 0 was genuinely retried by Ray."""
121
+ events: list[dict] = []
122
+ try:
123
+ from ray.util.state import list_tasks
124
+
125
+ tasks = list_tasks(filters=[("name", "=", "assign_shard")],
126
+ limit=10_000, detail=True)
127
+ for t in tasks:
128
+ attempt = getattr(t, "attempt_number", 0) or 0
129
+ if attempt > 0:
130
+ events.append({
131
+ "schema_version": "v1alpha1",
132
+ "job_id": job_id,
133
+ "type": "TASK_ATTEMPT_RETRIED",
134
+ "timestamp": _now_iso(),
135
+ "source": "ray.task-state",
136
+ "message": f"task {t.task_id} retried (attempt {attempt + 1})",
137
+ "data": {
138
+ "task_id": t.task_id,
139
+ "attempt_number": attempt,
140
+ "state": str(getattr(t, "state", "")),
141
+ "node_id": getattr(t, "node_id", None),
142
+ "error_type": str(getattr(t, "error_type", "") or ""),
143
+ },
144
+ })
145
+ except Exception as exc: # state API is best-effort; never fake evidence
146
+ print(json.dumps({"msg": "could not query Ray task state API",
147
+ "error": str(exc)}))
148
+ return events
149
+
150
+
151
+ def run() -> None:
152
+ import ray
153
+
154
+ job_id = os.environ.get("FLASHML_JOB_ID", "local-dev")
155
+ params = _params()
156
+ max_attempts = int(os.environ.get("FLASHML_MAX_TASK_ATTEMPTS", "4"))
157
+
158
+ ray.init() # inside the RayJob-managed cluster
159
+
160
+ cfg = {
161
+ "seed": params["seed"],
162
+ "clusters": params["clusters"],
163
+ "dimensions": params["dimensions"],
164
+ "samples_per_shard": max(1, params["samples"] // params["shards"]),
165
+ "task_delay_seconds": params["task_delay_seconds"],
166
+ }
167
+
168
+ # max_retries counts *re*-executions: attempts = 1 + max_retries.
169
+ remote_assign = ray.remote(max_retries=max_attempts - 1, num_cpus=1)(assign_shard)
170
+
171
+ rng = np.random.default_rng(params["seed"])
172
+ centers = _true_centers(params["seed"], params["clusters"], params["dimensions"])
173
+ centroids = centers + rng.normal(0, 4.0, size=centers.shape)
174
+
175
+ print(json.dumps({"msg": "kmeans starting", "job_id": job_id, **params}))
176
+ initial_workers = _worker_set()
177
+ print(json.dumps({"msg": "ray nodes online", "nodes": list(initial_workers.values()),
178
+ "count": len(initial_workers)}))
179
+
180
+ metrics_history: list[dict] = []
181
+ recovery_events: list[dict] = []
182
+ node_contributions: dict[str, dict] = {}
183
+ seen_workers = dict(initial_workers)
184
+ t_start = time.monotonic()
185
+
186
+ for iteration in range(params["iterations"]):
187
+ it0 = time.monotonic()
188
+ refs = [remote_assign.remote(s, centroids, cfg) for s in range(params["shards"])]
189
+ results = ray.get(refs)
190
+
191
+ # Reduce.
192
+ sums = np.zeros_like(centroids)
193
+ counts = np.zeros(params["clusters"], dtype=np.int64)
194
+ inertia = 0.0
195
+ for r in results:
196
+ sums += r["partial_sums"]
197
+ counts += r["partial_counts"]
198
+ inertia += r["inertia"]
199
+ node_key = f'{r["k8s_node"]}|{r["pod"]}'
200
+ entry = node_contributions.setdefault(node_key, {
201
+ "k8s_node": r["k8s_node"], "pod": r["pod"],
202
+ "ray_node_id": r["ray_node_id"], "tasks": 0, "samples": 0,
203
+ "busy_ms": 0,
204
+ })
205
+ entry["tasks"] += 1
206
+ entry["samples"] += r["n_samples"]
207
+ entry["busy_ms"] += r["duration_ms"]
208
+
209
+ nonzero = counts > 0
210
+ old = centroids.copy()
211
+ centroids[nonzero] = sums[nonzero] / counts[nonzero, None]
212
+ movement = float(np.linalg.norm(centroids - old))
213
+
214
+ # Worker-set diffs are observed facts from ray.nodes().
215
+ current_workers = _worker_set()
216
+ for node_id, name in seen_workers.items():
217
+ if node_id not in current_workers:
218
+ recovery_events.append({
219
+ "schema_version": "v1alpha1", "job_id": job_id,
220
+ "type": "RAY_WORKER_LOST", "timestamp": _now_iso(),
221
+ "source": "ray.nodes",
222
+ "message": f"Ray node {name} left the cluster during iteration {iteration}",
223
+ "data": {"ray_node_id": node_id, "node": name,
224
+ "iteration": iteration},
225
+ })
226
+ for node_id, name in current_workers.items():
227
+ if node_id not in seen_workers:
228
+ recovery_events.append({
229
+ "schema_version": "v1alpha1", "job_id": job_id,
230
+ "type": "RAY_WORKER_REPLACED", "timestamp": _now_iso(),
231
+ "source": "ray.nodes",
232
+ "message": f"Ray node {name} joined during iteration {iteration}",
233
+ "data": {"ray_node_id": node_id, "node": name,
234
+ "iteration": iteration},
235
+ })
236
+ seen_workers = current_workers
237
+
238
+ entry = {
239
+ "iteration": iteration,
240
+ "inertia": inertia,
241
+ "movement": movement,
242
+ "duration_s": round(time.monotonic() - it0, 3),
243
+ "tasks": params["shards"],
244
+ "workers_online": len(current_workers),
245
+ "timestamp": _now_iso(),
246
+ }
247
+ metrics_history.append(entry)
248
+ print(json.dumps({"msg": "iteration complete", **entry}))
249
+ recovery_events.append({
250
+ "schema_version": "v1alpha1", "job_id": job_id,
251
+ "type": "ITERATION_COMPLETED", "timestamp": _now_iso(),
252
+ "source": "workload.driver",
253
+ "message": f"iteration {iteration} complete "
254
+ f"(inertia={inertia:.1f}, movement={movement:.4f})",
255
+ "data": entry,
256
+ })
257
+
258
+ total_s = round(time.monotonic() - t_start, 2)
259
+ recovery_events.extend(_collect_retry_evidence(job_id))
260
+
261
+ summary = {
262
+ "job_id": job_id,
263
+ "parameters": params,
264
+ "total_duration_s": total_s,
265
+ "iterations_run": len(metrics_history),
266
+ "final_inertia": metrics_history[-1]["inertia"],
267
+ "final_movement": metrics_history[-1]["movement"],
268
+ "tasks_total": params["shards"] * params["iterations"],
269
+ "max_task_attempts": max_attempts,
270
+ "retried_tasks": sum(1 for e in recovery_events
271
+ if e["type"] == "TASK_ATTEMPT_RETRIED"),
272
+ "workers_lost": sum(1 for e in recovery_events
273
+ if e["type"] == "RAY_WORKER_LOST"),
274
+ "nodes_contributing": len(node_contributions),
275
+ "finished_at": _now_iso(),
276
+ }
277
+ print(json.dumps({"msg": "kmeans finished", **summary}))
278
+
279
+ _upload_artifacts(job_id, {
280
+ "centroids.json": {"centroids": centroids.tolist(),
281
+ "clusters": params["clusters"],
282
+ "dimensions": params["dimensions"]},
283
+ "metrics.json": {"history": metrics_history},
284
+ "execution-summary.json": summary,
285
+ "node-contributions.json": {"nodes": list(node_contributions.values())},
286
+ "recovery-events.json": recovery_events,
287
+ })
288
+
289
+
290
+ def _upload_artifacts(job_id: str, artifacts: dict[str, object]) -> None:
291
+ prefix = os.environ.get("FLASHML_ARTIFACT_PREFIX", f"artifact://jobs/{job_id}/")
292
+ try:
293
+ from flashruntime.artifacts import artifact_uri_to_key, store_from_env
294
+
295
+ store = store_from_env()
296
+ except Exception as exc:
297
+ print(json.dumps({"msg": "artifact store unavailable; writing locally",
298
+ "error": str(exc)}))
299
+ out = Path("/tmp/flashml-artifacts") / job_id
300
+ out.mkdir(parents=True, exist_ok=True)
301
+ for name, payload in artifacts.items():
302
+ (out / name).write_text(json.dumps(payload, indent=2))
303
+ return
304
+
305
+ key_prefix = artifact_uri_to_key(prefix)
306
+
307
+ async def _put_all():
308
+ import tempfile
309
+
310
+ with tempfile.TemporaryDirectory() as tmp:
311
+ for name, payload in artifacts.items():
312
+ path = Path(tmp) / name
313
+ path.write_text(json.dumps(payload, indent=2))
314
+ record = await store.put_file(path, f"{key_prefix}{name}")
315
+ print(json.dumps({"msg": "artifact committed", "uri": record.uri,
316
+ "etag": record.etag,
317
+ "size_bytes": record.size_bytes}))
318
+
319
+ asyncio.run(_put_all())
320
+
321
+
322
+ if __name__ == "__main__":
323
+ run()
@@ -0,0 +1,89 @@
1
+ """One hyperparameter-search trial, runnable as a leased task.
2
+
3
+ Executor contract (Tier-1 subprocess runner):
4
+
5
+ python -m flashml_workloads.sklearn_trial --spec spec.json --out OUTDIR
6
+
7
+ `spec.json` (written by the executor from the lease payload):
8
+ {
9
+ "task_id": "trial-003",
10
+ "params": {"model": "logreg", "C": 0.1}, # this trial's config
11
+ "inputs": {"dataset": "/abs/path/to/dataset.csv"} # downloaded shared data
12
+ }
13
+
14
+ The dataset is a headerless CSV, features then label in the last column —
15
+ shared data hosted by the coordinator's local artifact endpoint, downloaded
16
+ once per task. Output: `OUTDIR/metrics.json` with the cross-validated
17
+ accuracy; the executor uploads it and commits its sha256.
18
+
19
+ Supported params: model ∈ {logreg, rf}; logreg: C; rf: n_estimators,
20
+ max_depth. Deterministic (fixed random_state) so retried attempts produce
21
+ identical results — which is what makes idempotent commit meaningful.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import csv
28
+ import json
29
+ import time
30
+ from pathlib import Path
31
+
32
+
33
+ def run_trial(spec: dict) -> dict:
34
+ from sklearn.ensemble import RandomForestClassifier
35
+ from sklearn.linear_model import LogisticRegression
36
+ from sklearn.model_selection import cross_val_score
37
+
38
+ params = spec.get("params", {})
39
+ dataset_path = spec["inputs"]["dataset"]
40
+
41
+ features: list[list[float]] = []
42
+ labels: list[float] = []
43
+ with open(dataset_path, newline="") as f:
44
+ for row in csv.reader(f):
45
+ if not row:
46
+ continue
47
+ features.append([float(x) for x in row[:-1]])
48
+ labels.append(float(row[-1]))
49
+
50
+ model_kind = params.get("model", "logreg")
51
+ if model_kind == "logreg":
52
+ model = LogisticRegression(C=float(params.get("C", 1.0)), max_iter=500, random_state=0)
53
+ elif model_kind == "rf":
54
+ model = RandomForestClassifier(
55
+ n_estimators=int(params.get("n_estimators", 50)),
56
+ max_depth=int(params["max_depth"]) if params.get("max_depth") else None,
57
+ random_state=0,
58
+ )
59
+ else:
60
+ raise ValueError(f"unsupported model {model_kind!r} (logreg|rf)")
61
+
62
+ start = time.monotonic()
63
+ scores = cross_val_score(model, features, labels, cv=3)
64
+ return {
65
+ "task_id": spec.get("task_id", ""),
66
+ "params": params,
67
+ "accuracy_mean": round(float(sum(scores) / len(scores)), 4),
68
+ "accuracy_folds": [round(float(s), 4) for s in scores],
69
+ "n_samples": len(labels),
70
+ "train_seconds": round(time.monotonic() - start, 3),
71
+ }
72
+
73
+
74
+ def main(argv: list[str] | None = None) -> int:
75
+ parser = argparse.ArgumentParser(prog="sklearn_trial")
76
+ parser.add_argument("--spec", required=True, help="path to spec.json")
77
+ parser.add_argument("--out", required=True, help="output directory")
78
+ args = parser.parse_args(argv)
79
+
80
+ spec = json.loads(Path(args.spec).read_text())
81
+ metrics = run_trial(spec)
82
+ out = Path(args.out)
83
+ out.mkdir(parents=True, exist_ok=True)
84
+ (out / "metrics.json").write_text(json.dumps(metrics, indent=2, sort_keys=True))
85
+ return 0
86
+
87
+
88
+ if __name__ == "__main__":
89
+ raise SystemExit(main())
@@ -0,0 +1,125 @@
1
+ """FlashRuntime — open fault-tolerant distributed ML runtime.
2
+
3
+ A clean, pure-Python core (`pip install flashruntime` brings only pydantic):
4
+
5
+ 1. **Strategy planner** — decide *how* a job should run, with the math shown:
6
+
7
+ import flashruntime as flash
8
+
9
+ report = flash.plan(flash.PlanRequest(
10
+ workload=flash.TransformerFineTune(model="Qwen/Qwen2.5-7B", method="lora"),
11
+ resources=flash.Resources(gpus=4, gpu_type="RTX4090"),
12
+ objective=flash.Objective(mode="balanced", deadline_minutes=240),
13
+ ))
14
+ print(flash.render(report))
15
+
16
+ 2. **Lease manager** (`flashruntime.leases`) — the Mode A reliability core:
17
+ claim → heartbeat → expiry → requeue → idempotent commit, as a plain
18
+ state machine anyone can embed (the FlashML coordinator is one consumer).
19
+
20
+ 3. **Checkpoint catalog** (`flashruntime.checkpoint`) — manifests with
21
+ parts-first / manifest-last commit: a partial checkpoint can never look
22
+ valid, and recovery selects only verified, topology-compatible state.
23
+
24
+ 4. **Recovery policy** (`flashruntime.recovery`) — the typed failure
25
+ taxonomy and a versioned, deterministic action table. No agents, no
26
+ guesswork: same failure + same policy version ⇒ same action.
27
+
28
+ Infrastructure integrations are opt-in extras, never core imports:
29
+ `[service]` FastAPI coordinator + CLI job commands · `[k8s]` KubeRay
30
+ backend · `[artifacts]`/`[oss]` MinIO / Alibaba OSS stores ·
31
+ `[sklearn]` numpy + scikit-learn for the built-in task-module examples
32
+ (`flashml_workloads/`).
33
+ """
34
+
35
+ from typing import Any
36
+
37
+ from flashruntime.planner import PLANNER_VERSION, plan, render
38
+ from flashruntime.protocol.plan_v1alpha1 import (
39
+ ClassicalML,
40
+ IndependentTasks,
41
+ Objective,
42
+ PlanReport,
43
+ PlanRequest,
44
+ PyTorchTraining,
45
+ Resources,
46
+ StrategyPlan,
47
+ TransformerFineTune,
48
+ )
49
+
50
+ __all__ = [
51
+ # planner
52
+ "plan",
53
+ "render",
54
+ "run",
55
+ "PLANNER_VERSION",
56
+ "PlanRequest",
57
+ "PlanReport",
58
+ "StrategyPlan",
59
+ "TransformerFineTune",
60
+ "PyTorchTraining",
61
+ "ClassicalML",
62
+ "IndependentTasks",
63
+ "Resources",
64
+ "Objective",
65
+ # bring-your-own-code SDK (lazy — stdlib+pydantic only, but kept lazy
66
+ # so `import flashruntime` stays minimal)
67
+ "submit",
68
+ "CommandWorkload",
69
+ "OutputSpec",
70
+ "Source",
71
+ "integrations",
72
+ ]
73
+
74
+ def run(plan: StrategyPlan, coordinator_url: str | None = None):
75
+ """Execute a planner-selected StrategyPlan — **designed, not yet built**.
76
+
77
+ This is the plan→execution bridge (HANDBOOK §6 "known conscious
78
+ debts"). The designed pipeline, so the implementer starts from the
79
+ intended shape rather than inventing one:
80
+
81
+ 1. Freeze & record: the plan's `plan_id` is stamped on the job attempt
82
+ (every incident must answer "what did we think, and why").
83
+ 2. Translate: for `workload_mode == "independent_tasks"`, build a
84
+ lease-backend JobSpec via the matching `recipes.WorkloadRecipe` and
85
+ POST it to the coordinator (`coordinator_url` or
86
+ FLASHML_RUNTIME_API). For `coordinated_training`,
87
+ `strategies.compiler_for(plan).compile(plan)` →
88
+ `launchers` by `plan.launcher` → launch and watch.
89
+ 3. Return a run handle exposing job_id, state polling, and the ledger
90
+ event stream — mirroring `LaunchHandle` semantics.
91
+
92
+ Until then this raises NotImplementedError so callers fail loudly at
93
+ the boundary instead of half-running: today you plan with
94
+ `flash.plan()` and submit the JobSpec yourself (see
95
+ e2e/test_local_loop.py for the manual pattern).
96
+ """
97
+ raise NotImplementedError(
98
+ "flash.run() is designed but not implemented — build the plan→JobSpec "
99
+ "translation via recipes/ (Mode A) or strategies/+launchers/ (Mode B); "
100
+ "see the docstring for the intended pipeline and e2e/test_local_loop.py "
101
+ "for today's manual equivalent."
102
+ )
103
+
104
+
105
+ # SDK exports resolve lazily: name -> (module, attribute) so the pydantic-only
106
+ # core stays minimal until a bring-your-own-code helper is actually used.
107
+ _SDK_EXPORTS = {
108
+ "submit": ("flashruntime.sdk", "submit"),
109
+ "CommandWorkload": ("flashruntime.workloads.command", "CommandWorkload"),
110
+ "OutputSpec": ("flashruntime.workloads.command", "OutputSpec"),
111
+ "Source": ("flashruntime.workloads.command", "Source"),
112
+ "integrations": ("flashruntime.integrations", None),
113
+ }
114
+
115
+
116
+ def __getattr__(name: str) -> Any:
117
+ if name in _SDK_EXPORTS:
118
+ import importlib
119
+
120
+ module_name, attr = _SDK_EXPORTS[name]
121
+ module = importlib.import_module(module_name)
122
+ value = module if attr is None else getattr(module, attr)
123
+ globals()[name] = value
124
+ return value
125
+ raise AttributeError(f"module 'flashruntime' has no attribute {name!r}")
@@ -0,0 +1,25 @@
1
+ """Backend-neutral artifact storage.
2
+
3
+ Public artifact identity is always an `artifact://` URI; the physical
4
+ location (MinIO bucket locally, OSS bucket on Alibaba) is an ArtifactRecord
5
+ detail. Implementations: S3CompatibleArtifactStore (MinIO and S3-compatible
6
+ endpoints) and OSSArtifactStore (native Alibaba OSS via oss2).
7
+ """
8
+
9
+ from flashruntime.artifacts.store import (
10
+ ArtifactStore,
11
+ OSSArtifactStore,
12
+ S3CompatibleArtifactStore,
13
+ artifact_uri_to_key,
14
+ key_to_artifact_uri,
15
+ store_from_env,
16
+ )
17
+
18
+ __all__ = [
19
+ "ArtifactStore",
20
+ "OSSArtifactStore",
21
+ "S3CompatibleArtifactStore",
22
+ "artifact_uri_to_key",
23
+ "key_to_artifact_uri",
24
+ "store_from_env",
25
+ ]