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,135 @@
1
+ """Workload recipes: everything one workload type needs, behind one interface.
2
+
3
+ Today each workload is wired by hand in three places: an expansion branch
4
+ in `service/modea.py`, a module allowlist entry (both ends), and a task
5
+ module in `flashml_workloads/`. A `WorkloadRecipe` bundles those into one
6
+ registrable object so adding a workload becomes: write the task module,
7
+ write the recipe, register it. The existing hand-rolled expansions
8
+ (`hyperparameter_search`, `sharded_kmeans`) are the reference behavior and
9
+ should migrate here without any wire-visible change.
10
+
11
+ A recipe spans three moments in a task's life:
12
+ submit-time — `validate_params` + `expand` (coordinator)
13
+ run-time — `task_module` executed by the agent (the §2.2 contract:
14
+ `python -m <module> --spec spec.json --out OUTDIR`)
15
+ commit-time — `validate_output` (coordinator; runs BEFORE acceptance,
16
+ alongside the sha256 check) and, for map/reduce shapes,
17
+ `reduce` (driver side)
18
+
19
+ Hard rules inherited from the system: the task module must appear on BOTH
20
+ allowlists (coordinator + agent) — a recipe self-declares it, and the
21
+ registry is what the allowlists will be generated from once migration
22
+ lands. Hugging Face code lives here and only here (four-axes rule): HF is
23
+ workload, never a backend.
24
+
25
+ Status: interface complete (final surface); first concrete recipe is
26
+ HF Trainer + PEFT LoRA (SPRINT_PLAN Days 8–10, design in ADR-0004-to-be).
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from abc import ABC, abstractmethod
32
+ from typing import Any, ClassVar
33
+
34
+ from flashruntime.protocol.v1alpha1 import JobSpec, TaskSpec
35
+
36
+ __all__ = ["WorkloadRecipe", "register_recipe", "recipe_for"]
37
+
38
+
39
+ class WorkloadRecipe(ABC):
40
+ """One workload type, end to end.
41
+
42
+ Implementations must be *pure coordination logic*: no framework
43
+ imports at module import time (import torch/transformers inside the
44
+ task module, which runs on the agent — never in the coordinator's
45
+ process).
46
+ """
47
+
48
+ #: The `JobSpec.spec.workload.type` value this recipe owns
49
+ #: (e.g. "hyperparameter_search", "sharded_kmeans", "lora_finetune").
50
+ kind: ClassVar[str]
51
+
52
+ #: The task module executed on agents — the value placed in every
53
+ #: payload's "module" field, and the entry both allowlists must carry.
54
+ task_module: ClassVar[str]
55
+
56
+ def validate_params(self, params: dict[str, Any]) -> list[str]:
57
+ """Submit-time parameter validation.
58
+
59
+ Returns every problem found (empty list = valid); the service
60
+ turns a non-empty list into one 422 carrying all reasons — the
61
+ planner's surface-everything-at-once UX, applied to submission.
62
+ Default: accept anything (expand() remains the backstop).
63
+ """
64
+ return []
65
+
66
+ @abstractmethod
67
+ def expand(self, job_id: str, spec: JobSpec) -> list[TaskSpec]:
68
+ """Turn a JobSpec into independent TaskSpecs (Mode A expansion).
69
+
70
+ Requirements (mirror the existing expansions exactly):
71
+ - deterministic task_ids (`trial-000`-style) — retries and
72
+ dashboards depend on stable names;
73
+ - every payload carries: module, params, inputs (artifact:// URIs),
74
+ output_prefix, task_id, image, and `checkpoint` when the recipe
75
+ is checkpointable;
76
+ - commit_key = the metrics.json path under output_prefix (the
77
+ idempotency + validation anchor);
78
+ - honor spec.spec.retryPolicy.maxTaskAttempts and the workload's
79
+ lease_seconds parameter.
80
+ Raises ValueError (→ 422) when the spec cannot be expanded.
81
+ """
82
+
83
+ @abstractmethod
84
+ def validate_output(self, metrics: dict[str, Any]) -> None:
85
+ """Commit-time semantic validation of a task's metrics.json.
86
+
87
+ Runs on the coordinator after the sha256 check passes and before
88
+ the commit is accepted: raise (ValueError with a reason) to fail
89
+ the attempt — the task requeues, exactly like a hash mismatch.
90
+ This is the hook that catches \"the file is intact but the result
91
+ is nonsense\" (NaN loss, missing fields, accuracy > 1.0).
92
+ """
93
+
94
+ def reduce(self, outputs: list[dict[str, Any]]) -> dict[str, Any] | None:
95
+ """Combine per-task outputs into a job-level result (map/reduce
96
+ workloads: K-means partials → new centroids; HPO trials → best
97
+ config). Return None for workloads with no reduce step (default).
98
+ Runs driver/coordinator-side on already-validated outputs."""
99
+ return None
100
+
101
+ def checkpointable(self) -> bool:
102
+ """Whether tasks of this recipe write resumable checkpoints (turns
103
+ on the agent's checkpoint relay via the payload's `checkpoint`
104
+ key). Default False: stateless tasks retry from scratch, which is
105
+ cheaper than checkpointing for short work."""
106
+ return False
107
+
108
+ def plan_hints(self) -> dict[str, Any]:
109
+ """Optional coupling to the planner: static facts the estimators
110
+ may use (e.g. {'mode': 'independent_tasks'}). Keep it data-only —
111
+ the planner never calls recipe *code* (determinism + no-framework
112
+ rules)."""
113
+ return {}
114
+
115
+
116
+ _REGISTRY: dict[str, WorkloadRecipe] = {}
117
+
118
+
119
+ def register_recipe(recipe: WorkloadRecipe) -> None:
120
+ """Register (or replace) the recipe for its `kind`. At service startup
121
+ the registered kinds become the expansion dispatch table, and the set
122
+ of `task_module`s becomes the coordinator-side allowlist — one source
123
+ of truth instead of today's two hand-maintained sets."""
124
+ _REGISTRY[recipe.kind] = recipe
125
+
126
+
127
+ def recipe_for(kind: str) -> WorkloadRecipe:
128
+ """Lookup by workload type; LookupError → the service's 422 listing
129
+ the supported kinds (same UX as the current ExpansionError)."""
130
+ try:
131
+ return _REGISTRY[kind]
132
+ except KeyError:
133
+ raise LookupError(
134
+ f"no recipe for workload type {kind!r} (registered: {sorted(_REGISTRY) or 'none'})"
135
+ )
@@ -0,0 +1,166 @@
1
+ """The generic command recipe: JobSpec{workload.type: "command"} → lease tasks.
2
+
3
+ The first concrete WorkloadRecipe. Payloads carry `argv` — the §2.2
4
+ executor contract generalized from `module` — plus the isolation
5
+ requirement the placement gate enforces fail-closed. Executing argv
6
+ payloads is flashnode's runner tier (cross-repo, versioned change); this
7
+ recipe defines the coordinator half of that contract. Until flashnode
8
+ ships it, command jobs expand and lease correctly but only argv-aware
9
+ executors can run them.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from typing import Any, ClassVar
16
+
17
+ from flashruntime.protocol.v1alpha1 import JobSpec, TaskSpec
18
+ from flashruntime.recipes import WorkloadRecipe, register_recipe
19
+
20
+
21
+ class CommandRecipe(WorkloadRecipe):
22
+ kind: ClassVar[str] = "command"
23
+ #: argv payloads name no task module — the isolation tier, not a module
24
+ #: allowlist, is the security control for this workload type.
25
+ task_module: ClassVar[str] = ""
26
+
27
+ def validate_params(self, params: dict[str, Any]) -> list[str]:
28
+ problems: list[str] = []
29
+ command = params.get("command")
30
+ if (
31
+ not command
32
+ or not isinstance(command, list)
33
+ or not all(isinstance(t, str) for t in command)
34
+ ):
35
+ problems.append("'command' must be a non-empty argv list of strings")
36
+ for name, uri in (params.get("inputs") or {}).items():
37
+ if not str(uri).startswith("artifact://"):
38
+ problems.append(f"input '{name}' must be an artifact:// URI")
39
+ task_params = params.get("task_params")
40
+ if task_params is not None and (
41
+ not isinstance(task_params, list)
42
+ or not all(isinstance(p, dict) for p in task_params)
43
+ ):
44
+ problems.append("'task_params' must be a list of objects")
45
+ problems.extend(self._unpack_problems(params))
46
+ return problems
47
+
48
+ @staticmethod
49
+ def _unpack_problems(params: dict[str, Any]) -> list[str]:
50
+ """Validate `unpack_inputs` — the names flashnode may run an archive
51
+ extractor over.
52
+
53
+ This value decides which downloaded bytes get unpacked into a
54
+ directory tree on a *volunteer's* machine, so it is validated at
55
+ expansion time rather than forwarded as given. Naming an input that
56
+ was never declared is the interesting case: flashnode refuses it
57
+ too, but only after the task has been leased, claimed and half-run,
58
+ which surfaces to the submitter as a mysterious node-side failure
59
+ instead of the spec error it is. Catching it here fails on the
60
+ submitter's side, before anything is placed.
61
+ """
62
+ unpack = params.get("unpack_inputs")
63
+ if unpack is None:
64
+ return []
65
+ if not isinstance(unpack, list) or not all(isinstance(n, str) for n in unpack):
66
+ return ["'unpack_inputs' must be a list of input names (strings)"]
67
+ problems: list[str] = []
68
+ duplicates = sorted({n for n in unpack if unpack.count(n) > 1})
69
+ if duplicates:
70
+ problems.append(
71
+ f"'unpack_inputs' names {duplicates} more than once — an input "
72
+ f"is unpacked at most once"
73
+ )
74
+ declared = set((params.get("inputs") or {}).keys())
75
+ unknown = sorted(set(unpack) - declared)
76
+ if unknown:
77
+ problems.append(
78
+ f"'unpack_inputs' names inputs that are not declared in "
79
+ f"'inputs': {unknown}"
80
+ )
81
+ return problems
82
+
83
+ def expand(self, job_id: str, spec: JobSpec) -> list[TaskSpec]:
84
+ isolation_spec = spec.spec.isolation
85
+ if isolation_spec.allowFallback:
86
+ # allowFallback waives the sandbox capability requirement at
87
+ # placement time. Honouring it for argv would let a submitter
88
+ # place arbitrary code on an unsandboxed node.
89
+ raise ValueError(
90
+ "command jobs may not set isolation.allowFallback — "
91
+ "argv execution is container-only"
92
+ )
93
+ if isolation_spec.tier != "sandboxed":
94
+ # Coordinator-side opt-in only: the operator running the pool
95
+ # decides, never the submitter.
96
+ if os.environ.get("FLASHML_ALLOW_UNSANDBOXED_ARGV") != "1":
97
+ raise ValueError(
98
+ f"command jobs require isolation.tier 'sandboxed', got "
99
+ f"{isolation_spec.tier!r} (set FLASHML_ALLOW_UNSANDBOXED_ARGV=1 "
100
+ f"on the coordinator to allow a trusted fleet)"
101
+ )
102
+ p = spec.spec.workload.parameters
103
+ problems = self.validate_params(p)
104
+ if problems:
105
+ raise ValueError("; ".join(problems))
106
+
107
+ param_sets: list[dict | None] = p.get("task_params") or [None]
108
+ env: dict[str, str] = dict(p.get("env") or {})
109
+ inputs = dict(p.get("inputs") or {})
110
+ isolation = {
111
+ "tier": spec.spec.isolation.tier,
112
+ "allowFallback": spec.spec.isolation.allowFallback,
113
+ }
114
+
115
+ tasks: list[TaskSpec] = []
116
+ for i, params in enumerate(param_sets):
117
+ task_id = f"task-{i:03d}"
118
+ try:
119
+ argv = [t.format(**params) for t in p["command"]] if params else list(p["command"])
120
+ task_env = {
121
+ k: (v.format(**params) if params else v) for k, v in env.items()
122
+ }
123
+ except (KeyError, IndexError, ValueError) as exc:
124
+ # KeyError: a named {placeholder} with no matching param.
125
+ # IndexError/ValueError: an auto/positional field ({}, {0}) or
126
+ # a malformed brace str.format cannot fill from a params dict.
127
+ # All are user-input errors → ValueError (→422), never a 500.
128
+ raise ValueError(
129
+ f"task {i}: placeholder {exc} has no value in task_params[{i}]"
130
+ ) from None
131
+ payload: dict[str, Any] = {
132
+ "argv": argv,
133
+ "env": task_env,
134
+ "inputs": inputs,
135
+ "output_prefix": f"jobs/{job_id}/{task_id}/",
136
+ "task_id": task_id,
137
+ "image": spec.spec.image.reference,
138
+ "isolation": isolation,
139
+ }
140
+ if p.get("checkpoint") is not None:
141
+ payload["checkpoint"] = p["checkpoint"]
142
+ if p.get("unpack_inputs") is not None:
143
+ # Absent stays absent, never an empty list: flashnode reads
144
+ # `payload.get("unpack_inputs")` and an omitted key is the
145
+ # path where every input keeps its plain-file behaviour byte
146
+ # for byte. Emitting `[]` would mean the same thing today but
147
+ # would stop exercising that path.
148
+ payload["unpack_inputs"] = list(p["unpack_inputs"])
149
+ tasks.append(
150
+ TaskSpec(
151
+ task_id=task_id,
152
+ job_id=job_id,
153
+ commit_key=f"jobs/{job_id}/{task_id}/metrics.json",
154
+ max_attempts=spec.spec.retryPolicy.maxTaskAttempts,
155
+ lease_seconds=float(p.get("lease_seconds", 60.0)),
156
+ payload=payload,
157
+ )
158
+ )
159
+ return tasks
160
+
161
+ def validate_output(self, metrics: dict[str, Any]) -> None:
162
+ if not isinstance(metrics, dict):
163
+ raise ValueError("metrics.json must contain a JSON object")
164
+
165
+
166
+ register_recipe(CommandRecipe())
@@ -0,0 +1,21 @@
1
+ """Failure taxonomy and the deterministic recovery policy.
2
+
3
+ Two pure functions and a versioned table:
4
+
5
+ from flashruntime.recovery import FailureSignals, classify, decide
6
+
7
+ failure = classify(FailureSignals(heartbeat_lost=True)) # NODE_LOSS
8
+ decision = decide(failure, mode="independent_tasks")
9
+ # → RETRY_TASK, cordon_node=True, policy_version=0.1.0, reason=...
10
+
11
+ `classify` turns raw observed signals (exit codes, heartbeat loss, NCCL
12
+ messages, XID events) into one typed FailureClass with precedence-ordered
13
+ rules. `decide` maps (FailureClass, execution mode) to one typed, logged
14
+ RecoveryDecision. No scoring, no learning, no agent: same failure + same
15
+ policy version ⇒ same action.
16
+ """
17
+
18
+ from flashruntime.recovery.policy import POLICY_VERSION, decide
19
+ from flashruntime.recovery.taxonomy import CORRELATED_THRESHOLD, FailureSignals, classify
20
+
21
+ __all__ = ["FailureSignals", "classify", "decide", "POLICY_VERSION", "CORRELATED_THRESHOLD"]
@@ -0,0 +1,170 @@
1
+ """The versioned, deterministic recovery policy.
2
+
3
+ FailureClass × execution mode → one typed `RecoveryDecision`. The table *is*
4
+ the policy: no scoring, no learning, no agent — same failure + same policy
5
+ version ⇒ same action, always (ADR-0003 / master report guardrail: recovery
6
+ actions are typed, authorized, and logged).
7
+
8
+ The two execution modes matter because the same failure has different blast
9
+ radii: a worker crash in Mode A costs one task retry; in Mode B it stops the
10
+ whole group (NCCL state is not repairable in place — whole-group restart
11
+ from the latest valid checkpoint is the honest v1 promise).
12
+
13
+ Encoded rules worth naming:
14
+ - Deterministic application errors are never retried — fail fast, tell the
15
+ user. Burning capacity on a bug is the most expensive kind of "recovery".
16
+ - Storage outages pause rather than kill: compute waiting on dead storage
17
+ is wasted, but the job's state is intact — protect it and stop the burn.
18
+ - Correlated incidents freeze automation entirely. Retry storms during a
19
+ systemic incident are how orchestrators destroy trust; the policy's most
20
+ important action is knowing when to stop acting.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from flashruntime.protocol.v1alpha1 import (
26
+ FailureClass,
27
+ RecoveryActionType,
28
+ RecoveryDecision,
29
+ )
30
+
31
+ POLICY_VERSION = "0.1.0"
32
+
33
+ Mode = str # "independent_tasks" | "coordinated_training"
34
+
35
+ # (action, scope, cordon_node, needs_checkpoint, reason)
36
+ _TABLE: dict[tuple[FailureClass, Mode], tuple[RecoveryActionType, str, bool, bool, str]] = {
37
+ # -- application / data: not infrastructure's fault ---------------------
38
+ (FailureClass.APPLICATION_ERROR, "independent_tasks"): (
39
+ RecoveryActionType.FAIL_JOB, "job", False, False,
40
+ "deterministic application error — retrying burns money on a bug",
41
+ ),
42
+ (FailureClass.APPLICATION_ERROR, "coordinated_training"): (
43
+ RecoveryActionType.FAIL_JOB, "job", False, False,
44
+ "deterministic application error — retrying burns money on a bug",
45
+ ),
46
+ (FailureClass.DATA_ERROR, "independent_tasks"): (
47
+ RecoveryActionType.RETRY_TASK, "task", False, False,
48
+ "data error on one shard — retry once elsewhere; quarantine on repeat",
49
+ ),
50
+ (FailureClass.DATA_ERROR, "coordinated_training"): (
51
+ RecoveryActionType.FAIL_JOB, "job", False, False,
52
+ "data error in coordinated training — resume would repeat it",
53
+ ),
54
+ # -- process / node -----------------------------------------------------
55
+ (FailureClass.WORKER_CRASH, "independent_tasks"): (
56
+ RecoveryActionType.RETRY_TASK, "task", False, False,
57
+ "non-deterministic worker crash — lease expires, task requeues",
58
+ ),
59
+ (FailureClass.WORKER_CRASH, "coordinated_training"): (
60
+ RecoveryActionType.RESTART_GROUP, "group", False, True,
61
+ "a lost rank stops the group — restart all workers from latest valid checkpoint",
62
+ ),
63
+ (FailureClass.NODE_LOSS, "independent_tasks"): (
64
+ RecoveryActionType.RETRY_TASK, "task", True, False,
65
+ "node gone — cordon it, expire its leases, requeue its tasks",
66
+ ),
67
+ (FailureClass.NODE_LOSS, "coordinated_training"): (
68
+ RecoveryActionType.REPLACE_NODE, "group", True, True,
69
+ "node gone — acquire replacement capacity, then group restart from checkpoint",
70
+ ),
71
+ (FailureClass.ACCELERATOR_FAILURE, "independent_tasks"): (
72
+ RecoveryActionType.RETRY_TASK, "node", True, False,
73
+ "GPU/driver fault — cordon the accelerator's node, retry elsewhere",
74
+ ),
75
+ (FailureClass.ACCELERATOR_FAILURE, "coordinated_training"): (
76
+ RecoveryActionType.REPLACE_NODE, "group", True, True,
77
+ "GPU/driver fault — replace the node, group restart from checkpoint",
78
+ ),
79
+ # -- communication / network -------------------------------------------
80
+ (FailureClass.COMMUNICATION_ERROR, "independent_tasks"): (
81
+ RecoveryActionType.RETRY_TASK, "task", False, False,
82
+ "transport error on an independent task — plain retry",
83
+ ),
84
+ (FailureClass.COMMUNICATION_ERROR, "coordinated_training"): (
85
+ RecoveryActionType.RESTART_GROUP, "group", False, True,
86
+ "NCCL-class error — collective state is not repairable in place; whole-group restart",
87
+ ),
88
+ (FailureClass.NETWORK_DEGRADATION, "independent_tasks"): (
89
+ RecoveryActionType.RETRY_TASK, "task", False, False,
90
+ "degraded link — move the task; the node's reliability score absorbs the signal",
91
+ ),
92
+ (FailureClass.NETWORK_DEGRADATION, "coordinated_training"): (
93
+ RecoveryActionType.RESTART_GROUP, "group", False, True,
94
+ "sustained degradation starves the collective — restart, prefer a better pool",
95
+ ),
96
+ # -- dependencies -------------------------------------------------------
97
+ (FailureClass.STORAGE_TIMEOUT, "independent_tasks"): (
98
+ RecoveryActionType.PAUSE_JOB, "job", False, False,
99
+ "storage outage — pause instead of burning compute against a dead dependency",
100
+ ),
101
+ (FailureClass.STORAGE_TIMEOUT, "coordinated_training"): (
102
+ RecoveryActionType.PAUSE_JOB, "job", False, False,
103
+ "storage outage — pause instead of burning compute against a dead dependency",
104
+ ),
105
+ (FailureClass.ARTIFACT_CORRUPTION, "independent_tasks"): (
106
+ RecoveryActionType.RETRY_TASK, "task", False, False,
107
+ "output failed validation — reject commit, retry on a different node, score down producer",
108
+ ),
109
+ (FailureClass.ARTIFACT_CORRUPTION, "coordinated_training"): (
110
+ RecoveryActionType.RESTART_GROUP, "group", False, True,
111
+ "corrupt checkpoint/artifact — restart from the previous *valid* manifest",
112
+ ),
113
+ (FailureClass.PREEMPTION, "independent_tasks"): (
114
+ RecoveryActionType.RETRY_TASK, "task", False, False,
115
+ "priced-in preemption — requeue; no penalty to anyone",
116
+ ),
117
+ (FailureClass.PREEMPTION, "coordinated_training"): (
118
+ RecoveryActionType.REPLACE_NODE, "group", False, True,
119
+ "spot capacity reclaimed — acquire replacement, restart from checkpoint",
120
+ ),
121
+ # -- systemic: stop acting ---------------------------------------------
122
+ (FailureClass.CORRELATED_INCIDENT, "independent_tasks"): (
123
+ RecoveryActionType.FREEZE_AUTOMATION, "pool", False, False,
124
+ "multiple simultaneous failures — no retry storms; preserve state and escalate",
125
+ ),
126
+ (FailureClass.CORRELATED_INCIDENT, "coordinated_training"): (
127
+ RecoveryActionType.FREEZE_AUTOMATION, "pool", False, False,
128
+ "multiple simultaneous failures — no retry storms; preserve state and escalate",
129
+ ),
130
+ (FailureClass.CONTROL_PLANE_FAILURE, "independent_tasks"): (
131
+ RecoveryActionType.PAUSE_JOB, "job", False, False,
132
+ "coordinator unreachable — workers finish current leases; no new grants",
133
+ ),
134
+ (FailureClass.CONTROL_PLANE_FAILURE, "coordinated_training"): (
135
+ RecoveryActionType.PAUSE_JOB, "job", False, False,
136
+ "coordinator unreachable — preserve state; no automated decisions without a ledger",
137
+ ),
138
+ (FailureClass.UNKNOWN, "independent_tasks"): (
139
+ RecoveryActionType.RETRY_TASK, "task", False, False,
140
+ "unclassified failure — one conservative retry; repeats escalate via attempt limits",
141
+ ),
142
+ (FailureClass.UNKNOWN, "coordinated_training"): (
143
+ RecoveryActionType.PAUSE_JOB, "job", False, True,
144
+ "unclassified failure in coordinated training — pause for inspection over blind restart",
145
+ ),
146
+ }
147
+
148
+
149
+ def decide(failure: FailureClass, mode: Mode, evidence: dict | None = None) -> RecoveryDecision:
150
+ """Look up the typed action for a classified failure in a given mode.
151
+
152
+ Pure table lookup — deliberately no other inputs. Anything that should
153
+ influence the action (repeat counts, correlation) must appear upstream
154
+ as a different FailureClass, keeping every decision explainable by
155
+ (policy_version, failure_class, mode) alone.
156
+ """
157
+ try:
158
+ action, scope, cordon, needs_ckpt, reason = _TABLE[(failure, mode)]
159
+ except KeyError as exc:
160
+ raise ValueError(f"no policy entry for {failure.value} in mode {mode!r}") from exc
161
+ return RecoveryDecision(
162
+ policy_version=POLICY_VERSION,
163
+ failure_class=failure,
164
+ action=action,
165
+ scope=scope, # type: ignore[arg-type]
166
+ cordon_node=cordon,
167
+ needs_checkpoint=needs_ckpt,
168
+ reason=reason,
169
+ evidence=evidence or {},
170
+ )
@@ -0,0 +1,135 @@
1
+ """Translate a finished local process into recovery `FailureSignals`.
2
+
3
+ `from_local_launch(exit_code, log_tail)` is the first real caller of the
4
+ recovery package: it turns the only evidence a `LocalProcessLauncher` can
5
+ observe — the child's OS exit code and the tail of its captured stdout+stderr
6
+ — into the typed `FailureSignals` that `classify()` reads. It is a transparent
7
+ lookup table, not an inference engine: every rule is one `if` whose comment
8
+ names the reason the pattern implies its class, and the *first* matching rule
9
+ wins (order encodes priority — deterministic-bug evidence outranks the
10
+ transient default).
11
+
12
+ Rule table (checked top-to-bottom, first match wins). One rule, one reason:
13
+
14
+ 1. exit 0 / never started → neutral signals (nothing broke; a guard
15
+ that keeps the function total).
16
+ 2. a named deterministic
17
+ exception on a traceback
18
+ TERMINAL line → APPLICATION-ERROR (`exit_deterministic`):
19
+ import/parse/name errors recur
20
+ byte-for-byte, so fail fast, don't pay
21
+ for a re-run to the same certainty.
22
+ 3. a bare "Traceback" that is
23
+ NOT the torchrun wrapper → APPLICATION-ERROR: an unhandled exception
24
+ is a code error by default.
25
+ 4. everything else (signal death
26
+ / OOM / bare SystemExit /
27
+ torchrun ChildFailedError) → WORKER-CRASH (the transient default):
28
+ bad luck, worth one fresh attempt.
29
+
30
+ Why rule 2 is anchored to a traceback TERMINAL line, not a bare substring:
31
+ CPython prints the failing exception type at the START of the traceback's
32
+ final line — `ModuleNotFoundError: ...`, or dotted with its defining module,
33
+ `pkg.mod.SomeError: ...` — whereas prose only ever *mentions* an exception
34
+ name mid-line. The canonical false positive is the startup warning
35
+ `ImportError: flash_attn not available, falling back to eager attention`,
36
+ which real loggers emit behind a timestamp/level prefix
37
+ (`2026-… WARNING ImportError: …`) so the name never begins the line. A plain
38
+ `substring in log` scan read that prose as a deterministic bug and failed the
39
+ job fast — and because fail-fast is terminal, that was the worst-direction
40
+ false positive: a transient crash that would have resumed instead never
41
+ retried. Line-anchoring keeps every genuine traceback terminal (rule 2 still
42
+ fires on the real thing) while ignoring the name wherever it appears in prose.
43
+
44
+ Interaction of rules 2, 3 and the torchrun carve-out (precedence, explicit):
45
+ torchrun / torch.distributed.elastic wraps *every* worker death — transient
46
+ crashes included — in a `ChildFailedError` and prints ITS OWN traceback; by
47
+ default the child's real traceback is not in this log (`error_file: <N/A>`).
48
+ That wrapper terminal line is `…errors.ChildFailedError:` — `ChildFailedError`
49
+ is not in the deterministic marker set, so rule 2 never fires on the wrapper.
50
+ The wrapper's bare "Traceback" line WOULD trip rule 3, so rule 3 is
51
+ disqualified whenever `ChildFailedError` is present, letting the death fall
52
+ through to the transient default (rule 4) — which is exactly what the
53
+ kill-and-resume e2e depends on. Rule 2 is intentionally NOT gated by the
54
+ wrapper: a genuine child terminal line (e.g. a real `ModuleNotFoundError:` if
55
+ elastic error-files are enabled) is a true deterministic bug and should fail
56
+ fast even under torchrun.
57
+
58
+ Deliberately narrow: it never fabricates node / accelerator / communication /
59
+ storage signals a single local process cannot actually evidence — those
60
+ classes belong to the distributed coordinator, not the local launcher.
61
+ """
62
+
63
+ from __future__ import annotations
64
+
65
+ import re
66
+
67
+ from flashruntime.recovery.taxonomy import FailureSignals
68
+
69
+ # Exception-type names that mark a DETERMINISTIC bug: the same failure recurs
70
+ # byte-for-byte on retry, so retrying only re-reaches it.
71
+ _DETERMINISTIC_EXC_MARKERS = (
72
+ "SyntaxError", # the file will not parse — a retry parses the same file
73
+ "IndentationError", # a parse error too — same input, same failure
74
+ "ImportError", # a missing/broken dependency does not heal on retry
75
+ "ModuleNotFoundError", # ImportError's subclass; its name lacks the substring "ImportError"
76
+ "NameError", # an undefined name is a code bug, not bad luck
77
+ "AttributeError", # a code / version-contract bug, deterministic by nature
78
+ )
79
+
80
+ # A marker only counts when it stands where CPython prints the failing type: at
81
+ # the START of the traceback's final line, either bare (`NameError`) or with a
82
+ # message (`NameError: ...`), optionally dotted with the defining module
83
+ # (`pkg.mod.NameError: ...`). Line-anchoring (see module docstring) is the fix
84
+ # for the incidental-substring false positive — exception names in prose don't
85
+ # start lines; traceback terminals always do. re.MULTILINE makes ^/$ match at
86
+ # every line boundary; the trailing `(?::|$)` boundary stops a marker from
87
+ # matching a longer name it merely prefixes (e.g. `NameErrorX:`).
88
+ _DETERMINISTIC_TERMINAL_RE = re.compile(
89
+ r"^(?:\w+\.)*(?:" + "|".join(_DETERMINISTIC_EXC_MARKERS) + r")(?::|$)",
90
+ re.MULTILINE,
91
+ )
92
+
93
+ # torchrun / elastic re-raises ChildFailedError (with its own traceback) for
94
+ # ANY worker death, transient ones included — see the module docstring. Its
95
+ # presence disqualifies the bare-traceback rule below.
96
+ _ELASTIC_WRAPPER_MARKER = "ChildFailedError"
97
+ _TRACEBACK_MARKER = "Traceback (most recent call last):"
98
+
99
+
100
+ def from_local_launch(exit_code: int | None, log_tail: str) -> FailureSignals:
101
+ """Map a finished local process to the signals `classify()` reads.
102
+
103
+ `exit_code` is the child's OS return code (None if it never started);
104
+ `log_tail` is the tail of its captured stdout+stderr. Rules are checked
105
+ top-to-bottom, first match wins — see the module docstring for the full
106
+ table and the reasoning behind each class.
107
+ """
108
+ log = log_tail or ""
109
+
110
+ # Rule 1 — a clean exit is not a failure; nothing to recover. Guard only:
111
+ # the retry loop never asks on SUCCEEDED, but this keeps the function total.
112
+ if exit_code == 0 or exit_code is None:
113
+ return FailureSignals(exit_code=exit_code)
114
+
115
+ # Rule 2 — a named deterministic bug (import / parse / name error) standing
116
+ # on a traceback TERMINAL line: the same error reappears on a byte-identical
117
+ # retry, so mark it deterministic and let the policy fail fast. Anchored to
118
+ # the line start (not a bare substring) so an exception name mentioned in a
119
+ # prose log line — e.g. `… WARNING ImportError: flash_attn not available` —
120
+ # is not mistaken for the failure that actually killed the process.
121
+ if _DETERMINISTIC_TERMINAL_RE.search(log):
122
+ return FailureSignals(exit_code=exit_code, exit_deterministic=True)
123
+
124
+ # Rule 3 — an unhandled exception that printed its own traceback:
125
+ # deterministic by default (unhandled exceptions are code errors). Excluded
126
+ # when it is the torchrun elastic wrapper, whose traceback is the launcher's
127
+ # stack and says nothing about whether the user's failure was deterministic.
128
+ if _TRACEBACK_MARKER in log and _ELASTIC_WRAPPER_MARKER not in log:
129
+ return FailureSignals(exit_code=exit_code, exit_deterministic=True)
130
+
131
+ # Rule 4 — everything else: a signal death / OOM (SIGKILL/SIGSEGV, exit 137,
132
+ # a negative POSIX returncode), a bare SystemExit with no traceback, or a
133
+ # torchrun ChildFailedError — all transient. Leave exit_deterministic False
134
+ # so classify() returns WORKER_CRASH and the policy grants a fresh attempt.
135
+ return FailureSignals(exit_code=exit_code)