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,465 @@
1
+ """FlashML public protocol, version v1alpha1.
2
+
3
+ Every wire-visible model in the FlashML system lives here: the JobSpec users
4
+ submit, the job/event/artifact records FlashML Cloud stores and displays, and
5
+ the node registration/heartbeat messages FlashNode sends. flashnode and
6
+ flashml-cloud import these — they never define their own copies.
7
+
8
+ Versioning: `apiVersion` on the JobSpec and `schema_version` on wire messages
9
+ identify this revision. Additive changes are allowed within v1alpha1;
10
+ breaking changes require a new module (v1alpha2, ...).
11
+
12
+ Deployment-private details (ACK cluster IDs, ACR credentials, OSS keys, SLS
13
+ projects, Kubernetes namespaces, RuntimeClass names) are deliberately absent:
14
+ they belong to backend/deployment configuration, never the public spec.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from datetime import datetime, timezone
21
+ from enum import Enum
22
+ from typing import Any, Literal
23
+
24
+ from pydantic import BaseModel, Field, field_validator
25
+
26
+ API_VERSION = "flashml.dev/v1alpha1"
27
+ SCHEMA_VERSION = "v1alpha1"
28
+
29
+ # Label keys stamped on every backend resource created for a job.
30
+ LABEL_JOB_ID = "flashml.dev/job-id"
31
+ LABEL_RUNTIME_ID = "flashml.dev/runtime-id"
32
+ LABEL_BACKEND = "flashml.dev/backend"
33
+ LABEL_PROFILE = "flashml.dev/deployment-profile"
34
+
35
+ _NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
36
+
37
+
38
+ def utcnow() -> datetime:
39
+ return datetime.now(timezone.utc)
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # JobSpec
44
+ # ---------------------------------------------------------------------------
45
+
46
+
47
+ class ExecutionSpec(BaseModel):
48
+ # "ray": coordinated execution on a managed pool (KubeRay backend).
49
+ # "leases": Mode A — the job expands into independent tasks that
50
+ # FlashNode workers pull, heartbeat, and commit (additive, July 2026).
51
+ backend: Literal["ray", "leases"] = "ray"
52
+ environment: Literal["auto", "local", "alibaba-ack"] = "auto"
53
+
54
+
55
+ class ImageSpec(BaseModel):
56
+ repository: str
57
+ tag: str
58
+
59
+ @field_validator("tag")
60
+ @classmethod
61
+ def _no_latest(cls, v: str) -> str:
62
+ if v == "latest" or not v:
63
+ raise ValueError("image tag must be pinned; 'latest' is not allowed")
64
+ return v
65
+
66
+ @property
67
+ def reference(self) -> str:
68
+ return f"{self.repository}:{self.tag}"
69
+
70
+
71
+ class WorkloadSpec(BaseModel):
72
+ type: str
73
+ parameters: dict[str, Any] = Field(default_factory=dict)
74
+
75
+
76
+ class ResourcesSpec(BaseModel):
77
+ minimumWorkers: int = Field(ge=1, default=2)
78
+ maximumWorkers: int = Field(ge=1, default=3)
79
+ cpuPerTask: float = Field(gt=0, default=1)
80
+ memoryPerTask: str = "512Mi"
81
+
82
+ @field_validator("maximumWorkers")
83
+ @classmethod
84
+ def _max_ge_min(cls, v: int, info: Any) -> int:
85
+ minimum = info.data.get("minimumWorkers")
86
+ if minimum is not None and v < minimum:
87
+ raise ValueError("maximumWorkers must be >= minimumWorkers")
88
+ return v
89
+
90
+
91
+ class PlacementSpec(BaseModel):
92
+ pool: Literal["any", "local", "edge", "standard-cloud", "secure-cloud"] = "any"
93
+ architectures: list[Literal["amd64", "arm64"]] = Field(default_factory=lambda: ["amd64"])
94
+
95
+
96
+ class IsolationSpec(BaseModel):
97
+ tier: Literal["standard", "sandboxed"] = "standard"
98
+ allowFallback: bool = False
99
+
100
+
101
+ class RetryPolicySpec(BaseModel):
102
+ maxTaskAttempts: int = Field(ge=1, default=4)
103
+ retryWorkerLoss: bool = True
104
+
105
+
106
+ class ArtifactsSpec(BaseModel):
107
+ # Backend-neutral prefix; "{job_id}" is substituted at submission time.
108
+ outputPrefix: str = "artifact://jobs/{job_id}/"
109
+
110
+ @field_validator("outputPrefix")
111
+ @classmethod
112
+ def _scheme(cls, v: str) -> str:
113
+ if not v.startswith("artifact://"):
114
+ raise ValueError("outputPrefix must use the artifact:// scheme")
115
+ return v
116
+
117
+
118
+ class JobMetadata(BaseModel):
119
+ name: str
120
+ labels: dict[str, str] = Field(default_factory=dict)
121
+
122
+ @field_validator("name")
123
+ @classmethod
124
+ def _dns_name(cls, v: str) -> str:
125
+ if not _NAME_RE.match(v):
126
+ raise ValueError(
127
+ "job name must be a lowercase DNS-1123 label (a-z, 0-9, '-', max 63 chars)"
128
+ )
129
+ return v
130
+
131
+
132
+ class JobSpecInner(BaseModel):
133
+ execution: ExecutionSpec = Field(default_factory=ExecutionSpec)
134
+ image: ImageSpec
135
+ workload: WorkloadSpec
136
+ resources: ResourcesSpec = Field(default_factory=ResourcesSpec)
137
+ placement: PlacementSpec = Field(default_factory=PlacementSpec)
138
+ isolation: IsolationSpec = Field(default_factory=IsolationSpec)
139
+ retryPolicy: RetryPolicySpec = Field(default_factory=RetryPolicySpec)
140
+ artifacts: ArtifactsSpec = Field(default_factory=ArtifactsSpec)
141
+
142
+
143
+ class JobSpec(BaseModel):
144
+ apiVersion: Literal["flashml.dev/v1alpha1"] = API_VERSION
145
+ kind: Literal["Job"] = "Job"
146
+ metadata: JobMetadata
147
+ spec: JobSpecInner
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # Job state and events
152
+ # ---------------------------------------------------------------------------
153
+
154
+
155
+ class JobState(str, Enum):
156
+ PENDING = "PENDING"
157
+ SUBMITTED = "SUBMITTED"
158
+ RUNNING = "RUNNING"
159
+ RECOVERING = "RECOVERING"
160
+ SUCCEEDED = "SUCCEEDED"
161
+ FAILED = "FAILED"
162
+ CANCELLED = "CANCELLED"
163
+
164
+ @property
165
+ def terminal(self) -> bool:
166
+ return self in (JobState.SUCCEEDED, JobState.FAILED, JobState.CANCELLED)
167
+
168
+
169
+ class EventType(str, Enum):
170
+ JOB_ACCEPTED = "JOB_ACCEPTED"
171
+ BACKEND_SELECTED = "BACKEND_SELECTED"
172
+ RAYJOB_CREATED = "RAYJOB_CREATED"
173
+ RAY_CLUSTER_STARTING = "RAY_CLUSTER_STARTING"
174
+ NODE_REGISTERED = "NODE_REGISTERED"
175
+ NODE_READY = "NODE_READY"
176
+ TASK_ATTEMPT_STARTED = "TASK_ATTEMPT_STARTED"
177
+ TASK_ATTEMPT_COMPLETED = "TASK_ATTEMPT_COMPLETED"
178
+ NODE_HEARTBEAT_LOST = "NODE_HEARTBEAT_LOST"
179
+ RAY_WORKER_LOST = "RAY_WORKER_LOST"
180
+ TASK_ATTEMPT_RETRIED = "TASK_ATTEMPT_RETRIED"
181
+ RAY_WORKER_REPLACED = "RAY_WORKER_REPLACED"
182
+ ITERATION_COMPLETED = "ITERATION_COMPLETED"
183
+ ARTIFACT_UPLOAD_STARTED = "ARTIFACT_UPLOAD_STARTED"
184
+ ARTIFACT_COMMITTED = "ARTIFACT_COMMITTED"
185
+ JOB_SUCCEEDED = "JOB_SUCCEEDED"
186
+ JOB_FAILED = "JOB_FAILED"
187
+ # Mode A lease lifecycle (additive, July 2026)
188
+ TASK_CREATED = "TASK_CREATED"
189
+ LEASE_CLAIMED = "LEASE_CLAIMED"
190
+ LEASE_RENEWED = "LEASE_RENEWED"
191
+ LEASE_EXPIRED = "LEASE_EXPIRED"
192
+ TASK_REQUEUED = "TASK_REQUEUED"
193
+ TASK_COMMIT_ACCEPTED = "TASK_COMMIT_ACCEPTED"
194
+ TASK_COMMIT_REJECTED = "TASK_COMMIT_REJECTED"
195
+ TASK_ATTEMPT_FAILED = "TASK_ATTEMPT_FAILED"
196
+ TASK_EXHAUSTED = "TASK_EXHAUSTED"
197
+ # Checkpoint catalog lifecycle (additive, July 2026)
198
+ CHECKPOINT_PARTS_REGISTERED = "CHECKPOINT_PARTS_REGISTERED"
199
+ CHECKPOINT_MANIFEST_COMMITTED = "CHECKPOINT_MANIFEST_COMMITTED"
200
+ CHECKPOINT_RESTORE_VERIFIED = "CHECKPOINT_RESTORE_VERIFIED"
201
+ CHECKPOINT_REJECTED = "CHECKPOINT_REJECTED"
202
+ # Recovery policy (additive, July 2026)
203
+ FAILURE_CLASSIFIED = "FAILURE_CLASSIFIED"
204
+ RECOVERY_ACTION_SELECTED = "RECOVERY_ACTION_SELECTED"
205
+ RECOVERY_FROZEN = "RECOVERY_FROZEN"
206
+
207
+
208
+ class Event(BaseModel):
209
+ """One entry in the FlashRuntime event ledger.
210
+
211
+ `source` names the raw signal the event was derived from (e.g.
212
+ "kubernetes.pod", "ray.job-log", "workload.summary", "flashnode") so
213
+ recovery evidence is traceable, never fabricated.
214
+ """
215
+
216
+ schema_version: Literal["v1alpha1"] = SCHEMA_VERSION
217
+ job_id: str
218
+ type: EventType
219
+ timestamp: datetime = Field(default_factory=utcnow)
220
+ source: str
221
+ message: str = ""
222
+ data: dict[str, Any] = Field(default_factory=dict)
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # Artifacts
227
+ # ---------------------------------------------------------------------------
228
+
229
+
230
+ class ArtifactRecord(BaseModel):
231
+ schema_version: Literal["v1alpha1"] = SCHEMA_VERSION
232
+ uri: str # backend-neutral, e.g. artifact://jobs/<job-id>/centroids.json
233
+ backend: Literal["minio", "oss", "local"]
234
+ bucket: str
235
+ object_key: str
236
+ etag: str | None = None
237
+ sha256: str | None = None
238
+ size_bytes: int | None = None
239
+ created_at: datetime = Field(default_factory=utcnow)
240
+
241
+
242
+ # ---------------------------------------------------------------------------
243
+ # Job record (runtime-owned view served to FlashML Cloud)
244
+ # ---------------------------------------------------------------------------
245
+
246
+
247
+ class JobRecord(BaseModel):
248
+ schema_version: Literal["v1alpha1"] = SCHEMA_VERSION
249
+ job_id: str
250
+ spec: JobSpec
251
+ state: JobState = JobState.PENDING
252
+ backend: str = "kuberay"
253
+ deployment_profile: str = "local"
254
+ runtime_execution_id: str | None = None # e.g. the RayJob name
255
+ created_at: datetime = Field(default_factory=utcnow)
256
+ finished_at: datetime | None = None
257
+ error: str | None = None
258
+ artifacts: list[ArtifactRecord] = Field(default_factory=list)
259
+
260
+
261
+ # ---------------------------------------------------------------------------
262
+ # FlashNode <-> FlashML Cloud
263
+ # ---------------------------------------------------------------------------
264
+
265
+
266
+ class NodeEnvironment(str, Enum):
267
+ LOCAL = "local"
268
+ CLOUD = "cloud"
269
+ EDGE = "edge"
270
+
271
+
272
+ class NodeCapabilities(BaseModel):
273
+ cpu_cores: float | None = None
274
+ memory_bytes: int | None = None
275
+ gpus: list[dict[str, Any]] = Field(default_factory=list)
276
+ os: str = ""
277
+ architecture: str = ""
278
+
279
+
280
+ class NodeRegistration(BaseModel):
281
+ schema_version: Literal["v1alpha1"] = SCHEMA_VERSION
282
+ node_id: str
283
+ kubernetes_node: str
284
+ hostname: str
285
+ capabilities: NodeCapabilities
286
+ environment: NodeEnvironment = NodeEnvironment.LOCAL
287
+ sandbox_capable: bool = False
288
+ #: This node runs an argv-capable sandboxed runner. Defaults False so
289
+ #: every already-deployed agent is excluded from argv work until it is
290
+ #: upgraded and explicitly opted in (security fields fail closed).
291
+ argv_capable: bool = False
292
+ #: This node can run "module" (python -m <allowlisted module>) tasks.
293
+ #: Defaults True — unlike argv_capable this is an AVAILABILITY gate, not
294
+ #: a safety one: a module task placed on an incapable node just wastes
295
+ #: attempts, it never escapes a sandbox. Defaulting True means every
296
+ #: already-deployed agent (whose registration predates this field)
297
+ #: keeps receiving module work; only a node that explicitly opts into
298
+ #: an argv-only runner sets this False (see scheduler.IsolationAwarePlacement).
299
+ module_capable: bool = True
300
+ pool: str = "local"
301
+ runtime_profile: str = "kubernetes"
302
+ labels: dict[str, str] = Field(default_factory=dict)
303
+ agent_version: str = ""
304
+
305
+
306
+ class NodeHeartbeat(BaseModel):
307
+ schema_version: Literal["v1alpha1"] = SCHEMA_VERSION
308
+ node_id: str
309
+ timestamp: datetime = Field(default_factory=utcnow)
310
+ status: Literal["online", "draining", "terminating"] = "online"
311
+
312
+
313
+ class NodeStatusView(BaseModel):
314
+ """Cloud-side view of a node, derived from registration + heartbeats."""
315
+
316
+ schema_version: Literal["v1alpha1"] = SCHEMA_VERSION
317
+ registration: NodeRegistration
318
+ online: bool
319
+ last_heartbeat: datetime | None = None
320
+ accepted_task_count: int = 0
321
+
322
+
323
+ # ---------------------------------------------------------------------------
324
+ # Mode A: tasks, leases, attempts (additive, July 2026)
325
+ #
326
+ # The lease pattern: work is never pushed. A worker *claims* a time-bounded
327
+ # lease on a task, renews it with heartbeats, and only the first attempt to
328
+ # commit a valid result wins — late duplicates are rejected. A dead worker
329
+ # needs no handling: its lease expires and the task requeues.
330
+ # ---------------------------------------------------------------------------
331
+
332
+
333
+ class TaskState(str, Enum):
334
+ PENDING = "PENDING" # claimable
335
+ LEASED = "LEASED" # one live lease holds it
336
+ COMPLETED = "COMPLETED" # a commit was accepted (terminal)
337
+ FAILED = "FAILED" # attempts exhausted (terminal)
338
+ CANCELLED = "CANCELLED" # withdrawn by the job (terminal)
339
+
340
+
341
+ class TaskSpec(BaseModel):
342
+ """One unit of independent work. `payload` is workload-defined (e.g. a
343
+ hyperparameter trial config); `commit_key` is the idempotency anchor —
344
+ exactly one accepted output may ever exist under it."""
345
+
346
+ schema_version: Literal["v1alpha1"] = SCHEMA_VERSION
347
+ task_id: str
348
+ job_id: str
349
+ payload: dict[str, Any] = Field(default_factory=dict)
350
+ commit_key: str
351
+ max_attempts: int = Field(default=3, ge=1)
352
+ lease_seconds: float = Field(default=60.0, gt=0)
353
+
354
+
355
+ class Lease(BaseModel):
356
+ """A time-bounded right to execute one attempt of one task."""
357
+
358
+ schema_version: Literal["v1alpha1"] = SCHEMA_VERSION
359
+ lease_id: str
360
+ task_id: str
361
+ job_id: str
362
+ node_id: str
363
+ attempt_number: int
364
+ deadline: datetime
365
+ payload: dict[str, Any] = Field(default_factory=dict)
366
+
367
+
368
+ class TaskAttempt(BaseModel):
369
+ """One execution try. `accepted` flips true only on the winning commit."""
370
+
371
+ schema_version: Literal["v1alpha1"] = SCHEMA_VERSION
372
+ attempt_id: str
373
+ task_id: str
374
+ job_id: str
375
+ node_id: str
376
+ attempt_number: int
377
+ started: datetime
378
+ finished: datetime | None = None
379
+ outcome: Literal["running", "committed", "rejected", "failed", "expired"] = "running"
380
+ output_sha256: str | None = None
381
+ accepted: bool = False
382
+
383
+
384
+ # ---------------------------------------------------------------------------
385
+ # Checkpoint manifests (additive, July 2026)
386
+ #
387
+ # A checkpoint is not a path — it is a *manifest* proving completeness and
388
+ # compatibility. Parts upload first; the manifest is written last, only
389
+ # after every expected part's hash verifies. No manifest ⇒ no checkpoint.
390
+ # ---------------------------------------------------------------------------
391
+
392
+
393
+ class CheckpointPart(BaseModel):
394
+ key: str # storage key relative to the manifest's prefix
395
+ sha256: str
396
+ size_bytes: int = Field(ge=0)
397
+
398
+
399
+ class CheckpointValidation(str, Enum):
400
+ HASH_VERIFIED = "hash_verified" # all parts present, hashes match
401
+ RESTORE_VERIFIED = "restore_verified" # a real load succeeded from this manifest
402
+ INVALID = "invalid" # quarantined; recovery must never select it
403
+
404
+
405
+ class CheckpointManifest(BaseModel):
406
+ schema_version: Literal["v1alpha1"] = SCHEMA_VERSION
407
+ manifest_id: str
408
+ job_id: str
409
+ attempt_id: str
410
+ step: int = Field(ge=0)
411
+ framework: str = "" # e.g. "pytorch-2.9"
412
+ strategy_family: str = "" # e.g. "ddp", "fsdp2"
413
+ world_size: int = Field(default=1, ge=1)
414
+ compatible_world_sizes: list[int] = Field(default_factory=list) # via resharding
415
+ storage_prefix: str # e.g. "artifact://checkpoints/<job>/<attempt>/<step>/"
416
+ parts: list[CheckpointPart]
417
+ validation: CheckpointValidation
418
+ created: datetime = Field(default_factory=utcnow)
419
+ checkpoint_duration_s: float | None = None
420
+
421
+
422
+ # ---------------------------------------------------------------------------
423
+ # Failure taxonomy and recovery decisions (additive, July 2026)
424
+ # ---------------------------------------------------------------------------
425
+
426
+
427
+ class FailureClass(str, Enum):
428
+ APPLICATION_ERROR = "application_error"
429
+ DATA_ERROR = "data_error"
430
+ WORKER_CRASH = "worker_crash"
431
+ NODE_LOSS = "node_loss"
432
+ ACCELERATOR_FAILURE = "accelerator_failure"
433
+ COMMUNICATION_ERROR = "communication_error" # NCCL/RCCL/rendezvous
434
+ NETWORK_DEGRADATION = "network_degradation"
435
+ STORAGE_TIMEOUT = "storage_timeout"
436
+ ARTIFACT_CORRUPTION = "artifact_corruption"
437
+ PREEMPTION = "preemption"
438
+ CORRELATED_INCIDENT = "correlated_incident"
439
+ CONTROL_PLANE_FAILURE = "control_plane_failure"
440
+ UNKNOWN = "unknown"
441
+
442
+
443
+ class RecoveryActionType(str, Enum):
444
+ FAIL_JOB = "fail_job" # deterministic app error: fail fast, tell the user
445
+ RETRY_TASK = "retry_task" # Mode A: requeue the attempt elsewhere
446
+ RESTART_GROUP = "restart_group" # Mode B: whole-group restart from checkpoint
447
+ REPLACE_NODE = "replace_node" # cordon + acquire replacement, then restart/requeue
448
+ PAUSE_JOB = "pause_job" # e.g. storage outage: protect state, stop burning compute
449
+ FREEZE_AUTOMATION = "freeze_automation" # correlated incident: no retry storms; escalate
450
+
451
+
452
+ class RecoveryDecision(BaseModel):
453
+ """One typed, logged recovery decision. `policy_version` + inputs make it
454
+ reproducible; there is no discretionary path."""
455
+
456
+ schema_version: Literal["v1alpha1"] = SCHEMA_VERSION
457
+ policy_version: str
458
+ failure_class: FailureClass
459
+ action: RecoveryActionType
460
+ scope: Literal["task", "node", "group", "job", "pool"]
461
+ cordon_node: bool = False
462
+ needs_checkpoint: bool = False
463
+ reason: str
464
+ evidence: dict[str, Any] = Field(default_factory=dict)
465
+ decided_at: datetime = Field(default_factory=utcnow)
@@ -0,0 +1,138 @@
1
+ """Resource providers: where machines come from (the fourth axis).
2
+
3
+ A provider turns money into capacity: list what's available (`offers`),
4
+ acquire some (`acquire`), release it (`release`). It NEVER runs workloads
5
+ — acquired machines join the system the same way a laptop does (a
6
+ FlashNode agent registers and pulls leases) or as a Mode B pool (KubeRay
7
+ on the acquired cluster). Keeping acquisition separate from execution is
8
+ what lets one job span a rented pool and a volunteer laptop without either
9
+ knowing about the other.
10
+
11
+ Rules distilled from the evaluation (§J) and the master report:
12
+ - One adapter first, strict conformance, before any second provider —
13
+ every adapter is a permanent support cost.
14
+ - Providers are *quoted*, not trusted: an Offer's price/specs are claims;
15
+ admission benchmarks (flashnode `benchmark/`) verify capability after
16
+ the machine joins, exactly as for volunteered hardware.
17
+ - The planner consumes `hourly_usd` and capability fields to price plans;
18
+ the scheduler never talks to providers — acquisition is a deliberate,
19
+ budgeted act by the coordinator/cloud, not a side effect of placement.
20
+ - Idempotency: `release` on an unknown/already-released id is a no-op —
21
+ reconciliation loops must be safe to re-run.
22
+
23
+ Status: interface complete (final surface); first concrete adapter per
24
+ demand (SkyPilot as provisioner, or Alibaba ECS for Stage-5 automation —
25
+ see HANDBOOK §4).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from abc import ABC, abstractmethod
31
+ from datetime import datetime
32
+ from typing import Any, ClassVar
33
+
34
+ from pydantic import BaseModel, Field
35
+
36
+ __all__ = ["Requirements", "Offer", "AcquiredCapacity", "ResourceProvider", "ProviderError"]
37
+
38
+
39
+ class ProviderError(Exception):
40
+ """Provider API failure (auth, quota, capacity gone). Carries enough
41
+ context to be a ledger event — provider errors are recovery evidence
42
+ (FailureClass.PREEMPTION / capacity loss), never silent retries."""
43
+
44
+
45
+ class Requirements(BaseModel):
46
+ """What the caller needs — the provider filters its inventory by this.
47
+ All fields optional: an empty Requirements means 'show me everything'."""
48
+
49
+ gpus: int | None = Field(default=None, ge=0)
50
+ gpu_type: str | None = None
51
+ vram_gb: float | None = Field(default=None, gt=0)
52
+ cpus: int | None = Field(default=None, ge=1)
53
+ memory_gb: float | None = Field(default=None, gt=0)
54
+ region: str | None = None
55
+ max_hourly_usd: float | None = Field(default=None, gt=0)
56
+ spot_ok: bool = Field(
57
+ default=True,
58
+ description="Whether preemptible capacity is acceptable — spot "
59
+ "failure rates feed the planner's expected-failure-cost math",
60
+ )
61
+
62
+
63
+ class Offer(BaseModel):
64
+ """One acquirable configuration, as quoted by the provider now.
65
+
66
+ Offers are snapshots: they can expire or be taken. `acquire` must
67
+ re-validate; a stale offer raises ProviderError, never silently
68
+ substitutes different hardware."""
69
+
70
+ provider: str
71
+ gpu_type: str | None = None
72
+ gpus: int = Field(default=0, ge=0)
73
+ cpus: int | None = None
74
+ memory_gb: float | None = None
75
+ vram_gb: float | None = None
76
+ hourly_usd: float = Field(gt=0)
77
+ region: str | None = None
78
+ spot: bool = False
79
+ interconnect: str | None = Field(
80
+ default=None, description="Planner link-class name when known (e.g. 'same_host_nvlink')"
81
+ )
82
+ offer_id: str = Field(default="", description="Provider-native id for acquire()")
83
+ expires_at: datetime | None = None
84
+ raw: dict[str, Any] = Field(
85
+ default_factory=dict, description="Provider response verbatim, for audit"
86
+ )
87
+
88
+
89
+ class AcquiredCapacity(BaseModel):
90
+ """A machine (or pool) we are now paying for.
91
+
92
+ Contains what the *coordinator* needs to onboard it — never workload
93
+ credentials. `join_hint` describes how it enters the system: a cloud-
94
+ init/user-data script that runs `flashnode work --coordinator ...`
95
+ (Mode A) or a kubeconfig reference for a Mode B pool."""
96
+
97
+ capacity_id: str
98
+ provider: str
99
+ offer: Offer
100
+ acquired_at: datetime
101
+ join_hint: dict[str, Any] = Field(default_factory=dict)
102
+
103
+
104
+ class ResourceProvider(ABC):
105
+ """One source of rentable capacity.
106
+
107
+ Lifecycle: `healthy()` preflight → `offers()` → `acquire()` →
108
+ (capacity works for a while) → `release()`. Implementations must be
109
+ safe to call from reconciliation loops: every method idempotent or
110
+ read-only, every failure a typed ProviderError.
111
+ """
112
+
113
+ #: Registry/ledger name: "alibaba-ecs", "skypilot", "runpod", ...
114
+ name: ClassVar[str]
115
+
116
+ def healthy(self) -> tuple[bool, str]:
117
+ """Credentials valid, API reachable? (ok, reason). Called before
118
+ showing this provider's offers to anything that spends money."""
119
+ return True, "no preflight implemented"
120
+
121
+ @abstractmethod
122
+ def offers(self, requirements: Requirements) -> list[Offer]:
123
+ """Quote current availability matching `requirements`, cheapest
124
+ first. Read-only; may be cached briefly by the caller. An empty
125
+ list is an answer, not an error."""
126
+
127
+ @abstractmethod
128
+ def acquire(self, offer: Offer) -> AcquiredCapacity:
129
+ """Buy it. Either returns capacity we are provably paying for, or
130
+ raises ProviderError with nothing leaked — a half-acquired machine
131
+ the caller doesn't know about is a money leak; implementations
132
+ must clean up their own partial failures."""
133
+
134
+ @abstractmethod
135
+ def release(self, capacity_id: str) -> None:
136
+ """Stop paying. Idempotent: unknown/already-released ids are
137
+ no-ops. This is the method cost-safety leans on — it must be the
138
+ most reliable code in the adapter."""
flashruntime/py.typed ADDED
File without changes