Skip to main content

flodl_cli/config/
cluster.rs

1//! DDP + cluster configuration types: DdpConfig, SpeedHint,
2//! TrainingConfig, OutputConfig, ClusterConfig, ClusterController,
3//! LocalDevices, ClusterWorker + the `ClusterConfig` impl block.
4
5
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9/// DDP configuration. Maps 1:1 to flodl DdpConfig / DdpRunConfig.
10#[derive(Debug, Clone, Default, Deserialize)]
11#[serde(deny_unknown_fields)]
12pub struct DdpConfig {
13    pub mode: Option<String>,
14    pub policy: Option<String>,
15    pub backend: Option<String>,
16    /// "auto" or integer.
17    pub anchor: Option<serde_json::Value>,
18    pub max_anchor: Option<u32>,
19    pub overhead_target: Option<f64>,
20    pub divergence_threshold: Option<f64>,
21    /// null (unlimited) or integer.
22    pub max_batch_diff: Option<serde_json::Value>,
23    pub speed_hint: Option<SpeedHint>,
24    pub partition_ratios: Option<Vec<f64>>,
25    /// "auto" or bool.
26    pub progressive: Option<serde_json::Value>,
27    pub max_grad_norm: Option<f64>,
28    pub lr_scale_ratio: Option<f64>,
29    pub snapshot_timeout: Option<u32>,
30    pub checkpoint_every: Option<u32>,
31    pub timeline: Option<bool>,
32}
33
34#[derive(Debug, Clone, Default, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct SpeedHint {
37    pub slow_rank: usize,
38    pub ratio: f64,
39}
40
41/// Training scalars.
42#[derive(Debug, Clone, Default, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct TrainingConfig {
45    pub epochs: Option<u32>,
46    pub batch_size: Option<u32>,
47    pub batches_per_epoch: Option<u32>,
48    pub lr: Option<f64>,
49    pub seed: Option<u64>,
50}
51
52/// Output settings.
53#[derive(Debug, Clone, Default, Deserialize)]
54#[serde(deny_unknown_fields)]
55pub struct OutputConfig {
56    pub dir: Option<String>,
57    pub timeline: Option<bool>,
58    pub monitor: Option<u16>,
59}
60
61// ── Cluster topology (multi-host DDP) ───────────────────────────────────
62
63/// Default port for the controller's rendezvous bind. Overridable via
64/// `cluster.controller.port:` in fdl.cluster.yml.
65pub const DEFAULT_CONTROLLER_PORT: u16 = 1337;
66
67fn default_controller_port() -> u16 {
68    DEFAULT_CONTROLLER_PORT
69}
70
71/// Cluster topology, parsed from the `cluster:` block at the project
72/// root. Two role-separated sub-blocks:
73///
74/// - `controller`: the orchestrator host fdl-cli runs on. Holds the
75///   rendezvous bind point (`host`/`port`) plus the controller-local
76///   fields needed for pre-flight build and the ClusterController TCP
77///   listener. The controller is NOT a NCCL rank.
78/// - `workers`: every rank-carrying host. Each entry binds one or more
79///   global ranks and their CUDA device indices.
80///
81/// This is the controller-side full topology; per-worker slim
82/// envelopes are derived from it and shipped to each node, where the
83/// library reads them via `flodl::distributed::LocalCluster::from_env`.
84/// Launcher-only fields (`ssh:`) are not propagated to the envelope.
85///
86/// The library re-validates after reading; this validation runs earlier
87/// so errors surface before `fdl-cli` opens any SSH connection.
88#[derive(Debug, Clone, Deserialize, Serialize)]
89#[serde(deny_unknown_fields)]
90pub struct ClusterConfig {
91    pub controller: ClusterController,
92    pub workers: Vec<ClusterWorker>,
93    /// Cluster-scope env vars exported into every rank child on every
94    /// worker. Mapping `NAME: VALUE` (string→string). Use for tuning
95    /// the launcher itself shouldn't hardcode — e.g. on the Pascal-
96    /// under-VFIO rig, set `NCCL_P2P_DISABLE: "1"` +
97    /// `NCCL_SHM_DISABLE: "1"` so NCCL falls back to socket transport
98    /// (the direct-IPC transports fail under VFIO on consumer Pascal).
99    /// Worker-scope [`ClusterWorker::env`] takes precedence for matching
100    /// keys.
101    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
102    pub env: std::collections::BTreeMap<String, String>,
103}
104
105/// Controller-side cluster config. Holds the rendezvous bind point and
106/// pre-flight build context. The controller is the orchestrator host
107/// fdl-cli runs on; it is NOT a NCCL rank itself.
108///
109/// Rejected fields (via `deny_unknown_fields`): `ranks`,
110/// `local_devices`, `ssh*` (controller is local to fdl-cli),
111/// per-controller `env` (use cluster-scope `env:` instead) — and any
112/// mistyped key.
113#[derive(Debug, Clone, Deserialize, Serialize)]
114#[serde(deny_unknown_fields)]
115pub struct ClusterController {
116    /// Bind address. Used as the rendezvous endpoint workers dial.
117    /// What was `cluster.controller.host` in the pre-Refactor-2 schema.
118    pub host: String,
119    /// Bind port. What was `cluster.controller.port` in the pre-Refactor-2
120    /// schema. Defaults to [`DEFAULT_CONTROLLER_PORT`] when omitted.
121    #[serde(default = "default_controller_port")]
122    pub port: u16,
123    /// Controller's view of the shared project root. Used by the pre-
124    /// flight build phase to drive cargo invocations from the
125    /// controller's filesystem perspective; the remote-side path (each
126    /// worker's [`ClusterWorker::path`]) is used at runtime via SSH.
127    ///
128    /// On homogeneous-mount rigs the controller's view equals every
129    /// worker's view; on heterogeneous rigs (different mount points
130    /// per host), the controller's value diverges (e.g. the controller
131    /// sees `/opt/flodl` while a VM worker sees `/mnt/flodl`).
132    pub path: String,
133    /// Docker compose service the controller runs the pre-flight build
134    /// inside (e.g. `cuda`, `dev`). Optional; affects build context
135    /// only.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub docker: Option<String>,
138    /// libtorch variant subpath under `<path>/libtorch/` for the
139    /// pre-flight build (e.g. `precompiled/cu128`,
140    /// `builds/sm61-sm120`). When unset, fdl-cli falls back to the
141    /// controller's project-root `.active` file.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub arch: Option<String>,
144    /// Shared-storage path visible to the controller. flodl assumes a
145    /// shared filesystem reachable at the same logical path on every
146    /// node — training data, model checkpoints, and per-rank logs all
147    /// live here. When absent, the convention default
148    /// [`DEFAULT_DATA_PATH`] applies.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub data_path: Option<String>,
151    /// Join-window quorum knobs (dial-in membership). fdl-cli carries
152    /// the block through the launcher envelope verbatim; flodl owns
153    /// validation and the capacity-derived defaults.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub join: Option<ClusterJoin>,
156}
157
158/// `controller.join:` sub-block — per-field overrides for the
159/// membership window. All fields optional; unset keeps flodl's fan-out
160/// defaults (quorum = early-close target = configured capacity, window
161/// 300s, hard cap 600s, pre-shared-salt admission).
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(deny_unknown_fields)]
164pub struct ClusterJoin {
165    /// Quorum in ranks — the run cannot start below it.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub min_rank_start: Option<usize>,
168    /// Join window in seconds; quorum reached early does NOT close it.
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub join_timeout: Option<u64>,
171    /// Early-close target in ranks: the window closes the moment this
172    /// many are in.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub target_ranks: Option<usize>,
175    /// Hard cap in seconds: quorum still unmet when it expires fails
176    /// the run loudly.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub max_join_timeout: Option<u64>,
179    /// Accept joins without pre-shared-salt authentication on a
180    /// non-loopback bind (loudly warned by flodl).
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub open_admission: Option<bool>,
183}
184
185impl ClusterController {
186    /// Effective shared-data path: `data_path` if set, else
187    /// [`DEFAULT_DATA_PATH`].
188    pub fn effective_data_path(&self) -> &str {
189        self.data_path.as_deref().unwrap_or(DEFAULT_DATA_PATH)
190    }
191}
192
193/// CUDA device indices on a host. Either explicit (a list of indices) or
194/// the `"all"` shorthand (auto-detect at startup on that host).
195///
196/// `local_devices: all` defers device-index resolution to startup on
197/// whichever host the value lives on. The host uses `cuda_device_count()`
198/// and binds devices `0..ranks.len()` (sequential from index 0). The
199/// detected count must be at least as large as `ranks.len()`, otherwise
200/// rendezvous errors loudly.
201///
202/// `local_devices: [0, 1]` is the explicit form; same as before this
203/// shorthand existed.
204///
205/// Symmetric: works for both the controller host and remote nodes. Pair
206/// with explicit `ranks: [N, N+1, ...]` to define the world topology;
207/// `local_devices` only selects which device indices on this host map to
208/// those ranks.
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub enum LocalDevices {
211    /// Auto-detect at startup on this host. Indices `0..ranks.len()` bound.
212    All,
213    /// Explicit list of CUDA device indices, paired positionally with ranks.
214    Explicit(Vec<u8>),
215}
216
217impl LocalDevices {
218    /// True for the auto-detect form (unresolved).
219    pub fn is_all(&self) -> bool {
220        matches!(self, LocalDevices::All)
221    }
222
223    /// Explicit indices as a slice, or `None` for `All`.
224    pub fn as_explicit(&self) -> Option<&[u8]> {
225        match self {
226            LocalDevices::All => None,
227            LocalDevices::Explicit(v) => Some(v.as_slice()),
228        }
229    }
230}
231
232impl Serialize for LocalDevices {
233    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
234        match self {
235            LocalDevices::All => s.serialize_str("all"),
236            LocalDevices::Explicit(v) => v.serialize(s),
237        }
238    }
239}
240
241impl<'de> Deserialize<'de> for LocalDevices {
242    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
243        use serde::de::Error;
244        let v = Value::deserialize(d)?;
245        match v {
246            Value::String(s) if s == "all" => Ok(LocalDevices::All),
247            Value::String(s) => Err(D::Error::custom(format!(
248                "local_devices: expected \"all\" or array of device indices, got string {s:?}"
249            ))),
250            Value::Array(arr) => {
251                let mut out = Vec::with_capacity(arr.len());
252                for (i, item) in arr.iter().enumerate() {
253                    let n = item.as_u64().ok_or_else(|| {
254                        D::Error::custom(format!(
255                            "local_devices[{i}]: expected integer device index, got {item}"
256                        ))
257                    })?;
258                    let d = u8::try_from(n).map_err(|_| {
259                        D::Error::custom(format!(
260                            "local_devices[{i}]: value {n} does not fit in u8"
261                        ))
262                    })?;
263                    out.push(d);
264                }
265                Ok(LocalDevices::Explicit(out))
266            }
267            _ => Err(D::Error::custom(
268                "local_devices: expected \"all\" or array of device indices",
269            )),
270        }
271    }
272}
273
274/// SSH endpoint configuration for a remote worker host.
275///
276/// fdl-cli parser side; mirrors `flodl::distributed::launcher::SshConfig`
277/// shape so the slim envelope round-trips cleanly into the launcher. All
278/// fields optional; absent fields fall back to system ssh defaults or
279/// `~/.ssh/config` rules.
280#[derive(Debug, Clone, Default, Deserialize, Serialize)]
281#[serde(deny_unknown_fields)]
282pub struct SshConfig {
283    /// SSH target hostname / IP / alias. Defaults to the worker's
284    /// `host` field when unset.
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub target: Option<String>,
287    /// SSH port (`ssh -p <port>`).
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub port: Option<u16>,
290    /// SSH login user (`ssh -l <user>`). Defaults to the current user
291    /// (or `FLODL_INTERNAL_HOST_USER` from the controller env).
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub user: Option<String>,
294    /// Identity file / private key (`ssh -i <path>`).
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub identity_file: Option<String>,
297    /// Pass-through `-o Key=Value` SSH options (e.g.
298    /// `"ProxyJump=bastion"`, `"StrictHostKeyChecking=no"`). Each entry
299    /// becomes one `-o ...` arg on the spawned `ssh` command, in the
300    /// declared order.
301    #[serde(default, skip_serializing_if = "Vec::is_empty")]
302    pub options: Vec<String>,
303}
304
305/// One worker (a physical host running one or more NCCL ranks).
306#[derive(Debug, Clone, Deserialize, Serialize)]
307#[serde(deny_unknown_fields)]
308pub struct ClusterWorker {
309    /// Hostname / identifier (was `name:` in the pre-Refactor-2 schema).
310    /// Used for /etc/hosts resolution, `--add-host` injection, and as
311    /// the default SSH target (override via `ssh:`). On the worker
312    /// itself, this is the value `hostname` returns (or the
313    /// `FLODL_HOST_NAME`-overridden value).
314    pub host: String,
315    /// Global ranks owned by this worker.
316    ///
317    /// NOT part of the user YAML schema — populated internally by
318    /// [`ClusterConfig::populate_ranks`] from probed device counts
319    /// (called by `prepare_cluster_env` before envelope emission).
320    /// Sequential assignment by worker order: worker 0 owns
321    /// `[0..counts[0])`, worker 1 owns
322    /// `[counts[0]..counts[0]+counts[1])`, etc.
323    ///
324    /// Serialized into the wire format so the rank-side library reads
325    /// the post-probe assignment via `FullCluster::from_value`, and
326    /// deserializable so the canonical JSON round-trips. A `ranks:` key
327    /// in USER yaml is rejected loudly at load
328    /// (`loading::reject_user_ranks`): a user writing it expects it to
329    /// pin ranks, and it never did (probe is authoritative).
330    #[serde(default)]
331    pub ranks: Vec<usize>,
332    /// CUDA device indices paired by position with `ranks`, or `"all"`
333    /// shorthand for auto-detect at startup. See [`LocalDevices`] for
334    /// semantics.
335    pub local_devices: LocalDevices,
336    /// Network interface NCCL binds to (e.g. `virbr0`, `enp1s0`). Becomes
337    /// `NCCL_SOCKET_IFNAME` in the spawned process environment.
338    pub nccl_socket_ifname: String,
339    /// Project checkout path on this host. `fdl-cli` cd's here before
340    /// invoking the remote command. Heterogeneous mounts are fine
341    /// (e.g. `/opt/flodl` on the controller, `/srv/flodl` in a VM); training
342    /// data is the user's responsibility to mount identically across hosts
343    /// (NAS / SMB / virtiofs / S3-FUSE).
344    pub path: String,
345    /// SSH endpoint for `fdl-cli`'s launcher. `None` means the host
346    /// runs on the same machine as the launcher (fork/exec, no ssh).
347    /// When `Some`, all fields inside are optional and fall back to
348    /// system ssh defaults (or `~/.ssh/config` rules) when unset.
349    ///
350    /// In YAML, the sub-block accepts:
351    ///
352    /// ```yaml
353    /// ssh:
354    ///   target: node-b.lan          # ssh hostname/IP, default: host
355    ///   port: 2222                  # -p <port>
356    ///   user: ubuntu                # -l <user>
357    ///   identity_file: ~/.ssh/key   # -i <path>
358    ///   options:                    # -o <opt> (repeatable)
359    ///     - ProxyJump=bastion
360    ///     - StrictHostKeyChecking=no
361    /// ```
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub ssh: Option<SshConfig>,
364    /// Route this worker's training traffic through its fan-out SSH
365    /// session instead of a direct TCP connection to the controller:
366    /// the launcher adds a remote forward to the host's relay SSH
367    /// session and points the host at `127.0.0.1:<controller.port>`.
368    /// Requires a CPU ElChe mode (NCCL's peer-to-peer data plane cannot
369    /// ride a controller tunnel) and a remote host — validated loudly
370    /// at launch. When EVERY remote worker is tunneled, the controller
371    /// binds loopback only. Consumed by the library's launcher; fdl-cli
372    /// just carries it through the envelope.
373    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
374    pub tunnel: bool,
375    /// libtorch variant subpath under `<path>/libtorch/` on this host.
376    /// E.g. `precompiled/cu128` for a Blackwell host on PT 2.10 cu128,
377    /// `builds/sm61-sm120` for a Pascal host on a from-source build.
378    /// Convention: the convention path
379    /// `<host.path>/libtorch/<arch>` is what the rank exec reads at
380    /// runtime; the controller-side build mirrors it via
381    /// `<cluster.controller.path or controller-host.path>/libtorch/<arch>`.
382    /// Both paths point at the same physical libtorch via the shared
383    /// project-root mount.
384    ///
385    /// Optional; when unset, fdl-cli falls back to the host's
386    /// project-root `.active` file (single-host default behaviour
387    /// for non-cluster runs).
388    ///
389    /// `fdl probe`'s GPU compat check derives the supported sm
390    /// architectures by parsing the basename of this value (e.g.
391    /// `builds/sm61-sm120` → `6.1 12.0`) AND/OR reading the variant's
392    /// `.arch` metadata file at
393    /// `<path>/libtorch/<arch>/.arch`. The former wins when the
394    /// basename encodes archs; the latter covers `precompiled/cuXXX`
395    /// where the basename names the CUDA version, not GPU archs.
396    #[serde(default, skip_serializing_if = "Option::is_none")]
397    pub arch: Option<String>,
398    /// Shared-storage path visible to this host. flodl assumes a
399    /// shared filesystem (NAS / SMB / virtiofs / S3-FUSE / SSHFS)
400    /// reachable at the same logical path on every node — training
401    /// data, model checkpoints, and per-rank logs all live here. When
402    /// absent, the convention default [`DEFAULT_DATA_PATH`] applies.
403    /// `fdl probe` verifies the path exists + is readable on each
404    /// host before training can fan out.
405    #[serde(default, skip_serializing_if = "Option::is_none")]
406    pub data_path: Option<String>,
407    /// Names the docker compose service that provides this host's
408    /// runtime environment (e.g. `cuda`, `dev`). It does NOT wrap the
409    /// training exec: cluster fan-out runs each rank's binary directly
410    /// on the host over SSH (the pre-flight build is what runs in a
411    /// container, in the *controller's* [`ClusterController`] `docker`).
412    /// This field is a probe-time signal only: when set, `fdl probe`
413    /// skips host-level NCCL discovery — NCCL ships inside the image,
414    /// not on the host — and reports "provided via Docker image
415    /// `<svc>`" instead of erroring on a missing `libnccl.so`. The
416    /// host's libtorch (resolved via the `<path>/libtorch/<arch>`
417    /// convention) is still validated because it's the bind-mount
418    /// target, not container state. Per-host (not global) because mixed
419    /// deployments are common: controller in Docker, worker bare-metal
420    /// (or vice-versa). Library ignores this field; consumed only by
421    /// fdl-cli's probe / deploy paths.
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub docker: Option<String>,
424    /// Host-scoped env vars exported into every rank child spawned on
425    /// this host. Mapping `NAME: VALUE` (string→string). Useful for
426    /// host-specific tuning that doesn't belong at cluster scope
427    /// (e.g. one host needs a different `NCCL_SOCKET_IFNAME` override,
428    /// or a custom `LD_LIBRARY_PATH` due to a non-standard CUDA
429    /// install). Overrides matching keys from
430    /// [`ClusterConfig::env`].
431    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
432    pub env: std::collections::BTreeMap<String, String>,
433}
434
435/// Convention default for [`ClusterWorker::data_path`] /
436/// [`ClusterController::data_path`] when the entry does not declare
437/// one. Maps to the cross-node mount that holds training data +
438/// checkpoints + per-rank logs.
439pub const DEFAULT_DATA_PATH: &str = "/flodl/data";
440
441impl ClusterWorker {
442    /// Effective shared-data path: `data_path` if set, else
443    /// [`DEFAULT_DATA_PATH`].
444    pub fn effective_data_path(&self) -> &str {
445        self.data_path.as_deref().unwrap_or(DEFAULT_DATA_PATH)
446    }
447}
448
449impl ClusterConfig {
450    /// Total ranks across the cluster.
451    pub fn world_size(&self) -> usize {
452        self.workers.iter().map(|w| w.ranks.len()).sum()
453    }
454
455    /// Whether the cluster spans more than one physical worker.
456    /// Single-worker clusters don't require `NCCL_SOCKET_IFNAME`.
457    pub fn spans_multiple_hosts(&self) -> bool {
458        self.workers.len() > 1
459    }
460
461    /// Pre-flight validation. Mirrors the library check so failures
462    /// surface before SSH dispatch instead of from a stack trace on a
463    /// remote host:
464    ///
465    /// - `controller.host` non-empty, `controller.path` non-empty
466    /// - `workers` non-empty
467    /// - per worker: `host` non-empty, `nccl_socket_ifname` non-empty
468    ///   (when cluster spans multiple workers), `path` non-empty
469    ///
470    /// Ranks are NOT user input — they're computed by
471    /// [`Self::populate_ranks`] from probed device counts. When this
472    /// runs pre-probe (ranks empty), the rank-shape check is skipped;
473    /// when called post-probe (ranks populated), the
474    /// `0..world_size` + length-match-vs-local_devices invariant is
475    /// enforced.
476    pub fn validate(&self) -> Result<(), String> {
477        if self.controller.host.trim().is_empty() {
478            return Err("cluster.controller.host must be non-empty".into());
479        }
480        if self.controller.path.trim().is_empty() {
481            return Err("cluster.controller.path must be non-empty".into());
482        }
483        if self.workers.is_empty() {
484            return Err("cluster.workers must be non-empty".into());
485        }
486        // Reserved-env-key check: a user env map must not carry a key the
487        // launcher owns per-rank. The launcher applies user env after its
488        // own built-ins (shell last-wins), so an override would silently
489        // break device mapping / rank identity / the HMAC envelope. The
490        // reserved predicate lives in the library (single source of truth
491        // for the launcher-owned names).
492        for k in self.env.keys() {
493            if crate::cluster::is_reserved_cluster_env_key(k) {
494                return Err(format!(
495                    "cluster.env: key {k:?} is reserved (launcher-owned) and \
496                     cannot be set via env — it would override the launcher's \
497                     per-rank value"
498                ));
499            }
500        }
501        for (i, w) in self.workers.iter().enumerate() {
502            for k in w.env.keys() {
503                if crate::cluster::is_reserved_cluster_env_key(k) {
504                    return Err(format!(
505                        "cluster.workers[{i}] ({:?}): env key {k:?} is reserved \
506                         (launcher-owned) and cannot be set via env — it would \
507                         override the launcher's per-rank value",
508                        w.host,
509                    ));
510                }
511            }
512        }
513        let multi_host = self.spans_multiple_hosts();
514        for (i, w) in self.workers.iter().enumerate() {
515            if w.host.trim().is_empty() {
516                return Err(format!("cluster.workers[{i}].host must be non-empty"));
517            }
518            if multi_host && w.nccl_socket_ifname.trim().is_empty() {
519                return Err(format!(
520                    "cluster.workers[{i}] ({:?}): nccl_socket_ifname must be \
521                     non-empty when the cluster spans multiple workers",
522                    w.host
523                ));
524            }
525            if w.path.trim().is_empty() {
526                return Err(format!(
527                    "cluster.workers[{i}] ({:?}): path (project checkout dir) \
528                     must be non-empty",
529                    w.host
530                ));
531            }
532        }
533        // Post-probe shape checks: only when ranks have been populated.
534        // Pre-probe (all empty) skips this branch.
535        if self.workers.iter().all(|w| !w.ranks.is_empty()) {
536            for (i, w) in self.workers.iter().enumerate() {
537                if let Some(devs) = w.local_devices.as_explicit() {
538                    if w.ranks.len() != devs.len() {
539                        return Err(format!(
540                            "cluster.workers[{i}] ({:?}): ranks ({}) and local_devices ({}) length mismatch",
541                            w.host,
542                            w.ranks.len(),
543                            devs.len()
544                        ));
545                    }
546                }
547            }
548            let mut all: Vec<usize> = self
549                .workers
550                .iter()
551                .flat_map(|w| w.ranks.iter().copied())
552                .collect();
553            let ws = all.len();
554            all.sort_unstable();
555            let expected: Vec<usize> = (0..ws).collect();
556            if all != expected {
557                return Err(format!(
558                    "cluster: ranks across workers must be exactly 0..{ws} with no \
559                     duplicates or gaps, got sorted-unique sequence {all:?}"
560                ));
561            }
562        }
563        Ok(())
564    }
565
566    /// Populate `workers[i].ranks` from probed device counts.
567    /// Sequential assignment by worker order: worker 0 owns
568    /// `[0..counts[0])`, worker 1 owns
569    /// `[counts[0]..counts[0]+counts[1])`, etc.
570    ///
571    /// Errors when `device_counts.len() != workers.len()` or any
572    /// count is 0. Workers' existing `ranks` are unconditionally
573    /// overwritten — they're not user input, the probe is
574    /// authoritative.
575    ///
576    /// Caller orchestration (see `prepare_cluster_env`):
577    /// 1. parse YAML → ClusterConfig (ranks empty by serde default)
578    /// 2. probe device counts per worker
579    /// 3. call `populate_ranks` to fill in
580    /// 4. validate (now ranks are non-empty → shape checks run)
581    /// 5. serialize and ship via FLODL_INTERNAL_FULL_CLUSTER_JSON
582    pub fn populate_ranks(&mut self, device_counts: &[usize]) -> Result<(), String> {
583        if device_counts.len() != self.workers.len() {
584            return Err(format!(
585                "populate_ranks: device_counts len {} != workers len {}",
586                device_counts.len(),
587                self.workers.len(),
588            ));
589        }
590        let mut next_rank = 0usize;
591        for (i, w) in self.workers.iter_mut().enumerate() {
592            let count = device_counts[i];
593            if count == 0 {
594                return Err(format!(
595                    "populate_ranks: worker[{i}] ({:?}) reported 0 devices",
596                    w.host,
597                ));
598            }
599            w.ranks = (next_rank..next_rank + count).collect();
600            next_rank += count;
601        }
602        Ok(())
603    }
604
605    /// Canonical JSON of the full topology. Used for debug-dumping the
606    /// controller-side view; per-worker envelopes go through
607    /// [`Self::local_envelope_for`] instead.
608    pub fn canonical_json(&self) -> Result<String, String> {
609        serde_json::to_string_pretty(self)
610            .map_err(|e| format!("cluster: JSON serialization failed: {e}"))
611    }
612
613    /// Build the slim per-worker envelope the library reads via
614    /// `flodl::distributed::LocalCluster::from_env`.
615    ///
616    /// The envelope strips launcher-only fields (`ssh*`), embeds derived
617    /// world metadata (`world_size`, `num_workers`), and carries only
618    /// the requested worker's slice. The launcher hex-encodes the
619    /// resulting JSON into `FLODL_INTERNAL_CLUSTER_JSON` per ssh invocation, so
620    /// each remote process sees only itself + the controller
621    /// coordinates.
622    pub fn local_envelope_for(&self, worker: &ClusterWorker) -> Value {
623        let mut worker_obj = serde_json::Map::new();
624        worker_obj.insert("host".into(), Value::String(worker.host.clone()));
625        worker_obj.insert(
626            "ranks".into(),
627            Value::Array(worker.ranks.iter().map(|r| Value::from(*r)).collect()),
628        );
629        worker_obj.insert(
630            "local_devices".into(),
631            match &worker.local_devices {
632                LocalDevices::All => Value::String("all".into()),
633                LocalDevices::Explicit(v) => {
634                    Value::Array(v.iter().map(|d| Value::from(*d)).collect())
635                }
636            },
637        );
638        worker_obj.insert(
639            "nccl_socket_ifname".into(),
640            Value::String(worker.nccl_socket_ifname.clone()),
641        );
642        worker_obj.insert("path".into(), Value::String(worker.path.clone()));
643        if let Some(a) = &worker.arch {
644            worker_obj.insert("arch".into(), Value::String(a.clone()));
645        }
646        // SSH fields (port/user/identity_file/options) are launcher-only.
647        // The slim per-rank envelope read by `LocalCluster::from_env`
648        // doesn't need them — the rank-side library has nothing to do
649        // with SSH. They're already on the FULL envelope via serde on
650        // `ClusterConfig` so the launcher picks them up there.
651        // Shared-data path: always serialize the effective value
652        // (declared or convention default) so the rank-side envelope
653        // surfaces a non-ambiguous path. Library validates existence
654        // via `fdl probe` before training; ship the path verbatim.
655        worker_obj.insert(
656            "data_path".into(),
657            Value::String(worker.effective_data_path().into()),
658        );
659
660        let mut controller_obj = serde_json::Map::new();
661        controller_obj.insert("host".into(), Value::String(self.controller.host.clone()));
662        controller_obj.insert("port".into(), Value::from(self.controller.port));
663
664        let mut envelope = serde_json::Map::new();
665        envelope.insert("controller".into(), Value::Object(controller_obj));
666        envelope.insert("world_size".into(), Value::from(self.world_size()));
667        envelope.insert("num_workers".into(), Value::from(self.workers.len()));
668        envelope.insert("worker".into(), Value::Object(worker_obj));
669        Value::Object(envelope)
670    }
671
672    /// Look up the SSH target for a worker, defaulting to `host` if
673    /// `ssh:` is not set. Used by the launcher; library callers don't
674    /// need this.
675    pub fn ssh_target<'a>(&'a self, worker: &'a ClusterWorker) -> &'a str {
676        worker
677            .ssh
678            .as_ref()
679            .and_then(|s| s.target.as_deref())
680            .unwrap_or(&worker.host)
681    }
682}