Skip to main content

ghostlink_core/
planning.rs

1//! Greedy Layer Assignment with Fault Tolerance and Adaptive Quantization
2//!
3//! This module provides:
4//! - Sequential greedy layer splitting across nodes based on VRAM capacity
5//! - Adaptive quantization trigger (select_quantization_mode)
6//! - Load balancing and fault detection integration
7
8use crate::accelerator::ExecutionBackend;
9use crate::cluster::ClusterState;
10use crate::cluster::NodeStatus;
11use crate::host::{AccelerationMode, RuntimeProfile};
12use crate::protocol::NodeResources;
13
14/// Delivery ratio thresholds for adaptive quantization
15pub const DELIVERY_RATIO_INT8_THRESHOLD: f32 = 0.95;
16pub const DELIVERY_RATIO_INT4_THRESHOLD: f32 = 0.80;
17
18/// Layer specification with VRAM requirements
19#[derive(Clone, Debug, PartialEq)]
20pub struct LayerSpec {
21    /// Layer index (0-based)
22    pub index: usize,
23    /// VRAM required in GB
24    pub vram_gb: f32,
25    /// Number of weights in the layer
26    pub num_weights: u32,
27}
28
29impl Default for LayerSpec {
30    fn default() -> Self {
31        Self {
32            index: 0,
33            vram_gb: 1.0,
34            num_weights: 0,
35        }
36    }
37}
38
39/// Layer assignment to a specific node
40#[derive(Clone, Debug, PartialEq)]
41pub struct LayerAssignment {
42    /// Node ID
43    pub node_id: String,
44    /// Start layer index (inclusive)
45    pub start_layer: usize,
46    /// End layer index (exclusive)
47    pub end_layer: usize,
48    /// VRAM used on this node
49    pub used_vram_gb: f32,
50    /// Number of layers assigned
51    pub num_layers: usize,
52}
53
54impl LayerAssignment {
55    /// Create new layer assignment
56    pub fn new(node_id: String, start_layer: usize, end_layer: usize, vram_gb: f32) -> Self {
57        Self {
58            node_id,
59            start_layer,
60            end_layer,
61            used_vram_gb: vram_gb,
62            num_layers: end_layer - start_layer,
63        }
64    }
65
66    /// Get average VRAM per layer
67    pub fn avg_vram_per_layer(&self) -> f32 {
68        if self.num_layers == 0 {
69            0.0
70        } else {
71            self.used_vram_gb / self.num_layers as f32
72        }
73    }
74}
75
76/// Quantization mode enumeration
77#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
78pub enum QuantizationMode {
79    /// No quantization (full precision)
80    #[default]
81    None,
82    /// 8-bit quantization
83    Int8,
84    /// 4-bit quantization
85    Int4,
86}
87
88/// Layer placement plan across nodes
89#[derive(Clone, Debug, Default)]
90pub struct PlacementPlan {
91    /// Assignments per node
92    pub assignments: Vec<LayerAssignment>,
93    /// Selected quantization mode
94    pub quantization_mode: QuantizationMode,
95    /// Total layers assigned
96    pub total_layers: usize,
97    /// Nodes participating in plan
98    pub participating_nodes: Vec<String>,
99}
100
101impl PlacementPlan {
102    /// Create new placement plan
103    ///
104    /// OPTIMIZATION: Consolidates total_layers summation and participating_nodes collection
105    /// into a single pass over assignments. Deduplicates node IDs in-place without duplicate
106    /// string allocations when nodes have multiple chunked assignments.
107    pub fn new(assignments: Vec<LayerAssignment>, quantization_mode: QuantizationMode) -> Self {
108        let mut participating_nodes = Vec::with_capacity(assignments.len());
109        let mut total_layers = 0usize;
110
111        for a in &assignments {
112            total_layers += a.num_layers;
113            if !participating_nodes.contains(&a.node_id) {
114                participating_nodes.push(a.node_id.clone());
115            }
116        }
117
118        Self {
119            assignments,
120            quantization_mode,
121            total_layers,
122            participating_nodes,
123        }
124    }
125
126    /// Get human-readable plan summary
127    pub fn summary(&self) -> String {
128        let mode_str = match self.quantization_mode {
129            QuantizationMode::None => "Full Precision",
130            QuantizationMode::Int8 => "8-bit Quantized",
131            QuantizationMode::Int4 => "4-bit Quantized",
132        };
133
134        format!(
135            "Placement Plan ({})\n\
136             =================\n\
137             Total layers: {}\n\
138             Quantization: {}\n\
139             Nodes: {}\n",
140            mode_str,
141            self.total_layers,
142            match self.quantization_mode {
143                QuantizationMode::None => "Full Precision".to_string(),
144                QuantizationMode::Int8 => "8-bit Quantized".to_string(),
145                QuantizationMode::Int4 => "4-bit Quantized".to_string(),
146            },
147            self.participating_nodes.join(", ")
148        )
149    }
150}
151
152/// Runtime-aware planning hints derived from host auto-detection.
153#[derive(Clone, Copy, Debug, PartialEq, Eq)]
154pub struct PlanningTuning {
155    /// Preferred maximum chunk size to expose parallel work to workers.
156    pub max_layers_per_assignment: usize,
157}
158
159impl PlanningTuning {
160    /// Derive planning hints from the detected runtime profile.
161    pub fn from_runtime_profile(profile: &RuntimeProfile, total_layers: usize) -> Self {
162        let backend = ExecutionBackend::from_runtime_profile(profile);
163        let worker_count = backend.worker_count.max(1);
164        let accelerator_bias = match profile.acceleration_mode {
165            AccelerationMode::Gpu => 2,
166            AccelerationMode::Avx512 => 1,
167            _ => 0,
168        };
169        let vector_bias = (backend.vector_width_bits / 256).max(1);
170        let target_chunks = (worker_count + accelerator_bias + vector_bias - 1).max(1);
171        let chunk_size = if total_layers == 0 {
172            1
173        } else {
174            total_layers.div_ceil(target_chunks).max(1)
175        };
176
177        Self {
178            max_layers_per_assignment: chunk_size,
179        }
180    }
181}
182
183/// Select quantization mode based on cluster health metrics
184pub fn select_quantization_mode(delivery_ratio: f32) -> QuantizationMode {
185    if delivery_ratio >= DELIVERY_RATIO_INT8_THRESHOLD {
186        QuantizationMode::None
187    } else if delivery_ratio >= DELIVERY_RATIO_INT4_THRESHOLD {
188        QuantizationMode::Int8
189    } else {
190        QuantizationMode::Int4
191    }
192}
193
194/// Assign layers sequentially across nodes based on VRAM capacity.
195///
196/// OPTIMIZATION: Replaces per-layer `Option<LayerAssignment>` state tracking and repeated `.as_mut()`
197/// calls with a single-pass index range and VRAM accumulator. Pre-allocates output capacity for `nodes.len()`
198/// and constructs `LayerAssignment` objects only once per node transition or stream completion,
199/// preserving exact `LayerSpec.index` range boundaries while reducing allocation churn and instructions.
200pub fn assign_layers_sequentially(
201    nodes: &[NodeResources],
202    layers: &[LayerSpec],
203) -> Result<Vec<LayerAssignment>, String> {
204    if nodes.is_empty() {
205        return Err("at least one node is required".into());
206    }
207    if layers.is_empty() {
208        return Ok(Vec::new());
209    }
210
211    let mut assignments = Vec::with_capacity(nodes.len());
212    let mut node_idx = 0usize;
213    let mut remaining_capacity = nodes[0].vram_gb;
214    let mut current_start = 0usize;
215    let mut current_vram = 0.0f32;
216
217    for (i, layer) in layers.iter().enumerate() {
218        while layer.vram_gb > remaining_capacity {
219            // Need to move to next node: flush current accumulated layer assignment
220            if i > current_start {
221                let start_layer = layers[current_start].index;
222                let end_layer = layers[i - 1].index + 1;
223                assignments.push(LayerAssignment::new(
224                    nodes[node_idx].id.clone(),
225                    start_layer,
226                    end_layer,
227                    current_vram,
228                ));
229            }
230
231            node_idx += 1;
232            if node_idx >= nodes.len() {
233                return Err(format!(
234                    "insufficient cluster VRAM for layer {} (needs {:.2} GB)",
235                    layer.index, layer.vram_gb
236                ));
237            }
238            remaining_capacity = nodes[node_idx].vram_gb;
239            current_start = i;
240            current_vram = 0.0;
241        }
242
243        // Accumulate layer onto current node
244        remaining_capacity -= layer.vram_gb;
245        current_vram += layer.vram_gb;
246    }
247
248    // Finalize last assignment for remaining layers
249    if current_start < layers.len() {
250        let start_layer = layers[current_start].index;
251        let end_layer = layers[layers.len() - 1].index + 1;
252        assignments.push(LayerAssignment::new(
253            nodes[node_idx].id.clone(),
254            start_layer,
255            end_layer,
256            current_vram,
257        ));
258    }
259
260    Ok(assignments)
261}
262
263/// Split large node assignments into smaller contiguous chunks for worker-level parallelism.
264pub fn chunk_assignments_for_workers(
265    assignments: Vec<LayerAssignment>,
266    max_layers_per_assignment: usize,
267) -> Vec<LayerAssignment> {
268    let chunk_size = max_layers_per_assignment.max(1);
269
270    let needs_chunking = assignments.iter().any(|a| a.num_layers > chunk_size);
271    if !needs_chunking {
272        return assignments;
273    }
274
275    let mut chunked = Vec::with_capacity(assignments.len() * 2);
276
277    for assignment in assignments {
278        if assignment.num_layers <= chunk_size {
279            chunked.push(assignment);
280            continue;
281        }
282
283        let avg_vram = assignment.avg_vram_per_layer();
284        let mut start_layer = assignment.start_layer;
285
286        while start_layer < assignment.end_layer {
287            let end_layer = (start_layer + chunk_size).min(assignment.end_layer);
288            let num_layers = end_layer - start_layer;
289            chunked.push(LayerAssignment::new(
290                assignment.node_id.clone(),
291                start_layer,
292                end_layer,
293                avg_vram * num_layers as f32,
294            ));
295            start_layer = end_layer;
296        }
297    }
298
299    chunked
300}
301
302/// Assign layers sequentially across nodes, directly producing chunked assignments
303/// sized for worker-level parallelism. Avoids creating intermediate full-size
304/// `LayerAssignment` objects and their associated allocation/clone overhead.
305///
306/// OPTIMIZATION: Computes chunked layer assignments directly in a single pass over
307/// the input layer slice, bypassing intermediate `PlacementPlan`/`LayerAssignment` allocations,
308/// post-allocation chunking loops (`chunk_assignments_for_workers`), and redundant meta-calculations.
309/// Preserves exact `LayerSpec.index` range boundaries (matching `assign_layers_sequentially`).
310fn assign_layers_chunked(
311    nodes: &[NodeResources],
312    layers: &[LayerSpec],
313    max_layers_per_assignment: usize,
314) -> Result<Vec<LayerAssignment>, String> {
315    if nodes.is_empty() {
316        return Err("at least one node is required".into());
317    }
318    if layers.is_empty() {
319        return Ok(Vec::new());
320    }
321
322    let chunk_size = max_layers_per_assignment.max(1);
323    let est_assignments = (layers.len().div_ceil(chunk_size)).max(nodes.len());
324    let mut assignments = Vec::with_capacity(est_assignments);
325    let mut node_idx = 0usize;
326    let mut remaining_vram = nodes[0].vram_gb;
327    // Index of the first layer in the current chunk
328    let mut chunk_start = 0usize;
329    let mut chunk_vram = 0.0f32;
330
331    for (i, layer) in layers.iter().enumerate() {
332        // Move to next node if current node is out of capacity
333        while layer.vram_gb > remaining_vram {
334            if i > chunk_start {
335                let start_layer = layers[chunk_start].index;
336                let end_layer = layers[i - 1].index + 1;
337                assignments.push(LayerAssignment::new(
338                    nodes[node_idx].id.clone(),
339                    start_layer,
340                    end_layer,
341                    chunk_vram,
342                ));
343            }
344            node_idx += 1;
345            if node_idx >= nodes.len() {
346                return Err(format!(
347                    "insufficient cluster VRAM for layer {} (needs {:.2} GB)",
348                    layer.index, layer.vram_gb
349                ));
350            }
351            remaining_vram = nodes[node_idx].vram_gb;
352            chunk_start = i;
353            chunk_vram = 0.0;
354        }
355
356        remaining_vram -= layer.vram_gb;
357        chunk_vram += layer.vram_gb;
358
359        // Flush chunk if it reached the maximum chunk size
360        let chunk_len = i + 1 - chunk_start;
361        if chunk_len >= chunk_size {
362            let start_layer = layers[chunk_start].index;
363            let end_layer = layers[i].index + 1;
364            assignments.push(LayerAssignment::new(
365                nodes[node_idx].id.clone(),
366                start_layer,
367                end_layer,
368                chunk_vram,
369            ));
370            chunk_start = i + 1;
371            chunk_vram = 0.0;
372        }
373    }
374
375    // Finalize last chunk. Checked by pending-layer range, not accumulated
376    // VRAM: a trailing run of zero-VRAM layers (e.g. bias-only layers) would
377    // otherwise leave `chunk_vram == 0.0` and be silently dropped from the
378    // plan even though `chunk_start..layers.len()` is non-empty.
379    if chunk_start < layers.len() {
380        let start_layer = layers[chunk_start].index;
381        let end_layer = layers[layers.len() - 1].index + 1;
382        assignments.push(LayerAssignment::new(
383            nodes[node_idx].id.clone(),
384            start_layer,
385            end_layer,
386            chunk_vram,
387        ));
388    }
389
390    Ok(assignments)
391}
392
393/// Assign layers using runtime auto-detection to expose worker-parallel chunks.
394pub fn assign_layers_with_runtime_profile(
395    nodes: &[NodeResources],
396    layers: &[LayerSpec],
397    profile: &RuntimeProfile,
398) -> Result<Vec<LayerAssignment>, String> {
399    let tuning = PlanningTuning::from_runtime_profile(profile, layers.len());
400    assign_layers_chunked(nodes, layers, tuning.max_layers_per_assignment)
401}
402
403/// Helper to calculate average delivery ratio across all active nodes in a single lock pass.
404fn calculate_average_delivery_ratio(cluster: &ClusterState) -> f32 {
405    let (sum_delivery, active_count) = {
406        let metrics = cluster
407            .metrics
408            .lock()
409            .unwrap_or_else(|poison| poison.into_inner());
410        let mut sum = 0.0f32;
411        let mut count = 0usize;
412        for metric in metrics.values() {
413            if metric.status == NodeStatus::Active {
414                sum += metric.delivery_ratio;
415                count += 1;
416            }
417        }
418        (sum, count)
419    };
420
421    if active_count > 0 {
422        sum_delivery / active_count as f32
423    } else {
424        0.0
425    }
426}
427
428/// Assign layers with fault tolerance and load balancing
429pub fn assign_layers_with_fault_tolerance(
430    cluster: &ClusterState,
431    layers: &[LayerSpec],
432) -> Result<PlacementPlan, String> {
433    let nodes = cluster.nodes_snapshot();
434
435    if nodes.is_empty() {
436        return Err("no nodes available".into());
437    }
438
439    // First pass: greedy assignment
440    let assignments = assign_layers_sequentially(&nodes, layers)?;
441
442    let total_delivery_ratio = calculate_average_delivery_ratio(cluster);
443
444    // Select quantization mode based on health
445    let quantization_mode = select_quantization_mode(total_delivery_ratio);
446
447    Ok(PlacementPlan::new(assignments, quantization_mode))
448}
449
450/// Assign layers with fault tolerance and runtime-aware chunking.
451///
452/// Optimized to directly call `assign_layers_chunked`, bypassing intermediate
453/// `LayerAssignment` allocations and `chunk_assignments_for_workers` overhead.
454pub fn assign_layers_with_fault_tolerance_and_runtime(
455    cluster: &ClusterState,
456    layers: &[LayerSpec],
457    profile: &RuntimeProfile,
458) -> Result<PlacementPlan, String> {
459    let nodes = cluster.nodes_snapshot();
460
461    if nodes.is_empty() {
462        return Err("no nodes available".into());
463    }
464
465    let tuning = PlanningTuning::from_runtime_profile(profile, layers.len());
466    // Directly run the chunked layout computation
467    let assignments = assign_layers_chunked(&nodes, layers, tuning.max_layers_per_assignment)?;
468
469    let total_delivery_ratio = calculate_average_delivery_ratio(cluster);
470
471    // Select quantization mode based on health
472    let quantization_mode = select_quantization_mode(total_delivery_ratio);
473
474    Ok(PlacementPlan::new(assignments, quantization_mode))
475}
476
477/// Update layer assignments based on node health metrics
478pub fn rebalance_assignments(cluster: &ClusterState, plan: &mut PlacementPlan) -> bool {
479    // Check if any active node has high available VRAM
480    let mut needs_rebalance = false;
481
482    let metrics = cluster
483        .metrics
484        .lock()
485        .unwrap_or_else(|poison| poison.into_inner());
486
487    for assignment in &mut plan.assignments {
488        if let Some(metric) = metrics.get(&assignment.node_id) {
489            if metric.status != NodeStatus::Active {
490                // Skip failed nodes
491                continue;
492            }
493
494            // Check if this node can take more layers
495            let available = metric.available_vram_gb;
496            let avg_layer_size = assignment.avg_vram_per_layer();
497
498            if available > 0.0 && avg_layer_size > 0.0 {
499                let potential_layers = (available / avg_layer_size) as usize;
500
501                // If node has significant capacity, mark for rebalancing
502                if potential_layers > 2 {
503                    needs_rebalance = true;
504                    break;
505                }
506            }
507        }
508    }
509
510    needs_rebalance
511}
512
513/// Simulate layer streaming on a node with metrics updates
514#[cfg(test)]
515pub fn simulate_layer_streaming(
516    node_id: &str,
517    cluster: &ClusterState,
518    start_layer: usize,
519    end_layer: usize,
520) -> Option<crate::cluster::NodeMetrics> {
521    let mut metrics = cluster.get_metrics(node_id)?;
522
523    // Record VRAM usage
524    let num_layers = end_layer - start_layer;
525    let avg_vram = (metrics.available_vram_gb + metrics.vram_gb) / 2.0;
526    let vram_per_layer = avg_vram / num_layers as f32;
527    metrics.record_vram_usage(vram_per_layer * num_layers as f32);
528
529    // Set streaming layers
530    metrics.set_streaming_layers(start_layer, end_layer);
531
532    Some(metrics)
533}
534
535/// Calculate network health across the cluster
536pub fn calculate_cluster_health(cluster: &ClusterState) -> (f32, usize, Vec<String>) {
537    // Acquire the lock on metrics map exactly ONCE to avoid redundant mutex lock/unlock and clones
538    let metrics = cluster
539        .metrics
540        .lock()
541        .unwrap_or_else(|poison| poison.into_inner());
542
543    if metrics.is_empty() {
544        return (0.0, 0, vec![]);
545    }
546
547    let mut sum_delivery_ratio = 0.0f32;
548    let mut active_count = 0usize;
549    let mut failed_nodes = Vec::new();
550
551    // Iterate once over all live node metrics to aggregate statistics and collect failed nodes
552    for (node_id, metric) in metrics.iter() {
553        match metric.status {
554            NodeStatus::Active => {
555                sum_delivery_ratio += metric.delivery_ratio;
556                active_count += 1;
557            }
558            NodeStatus::Failed => {
559                failed_nodes.push(node_id.clone());
560            }
561            _ => {}
562        }
563    }
564
565    let avg_delivery_ratio = if active_count > 0 {
566        sum_delivery_ratio / active_count as f32
567    } else {
568        0.0
569    };
570
571    let failed_count = failed_nodes.len();
572
573    (avg_delivery_ratio, failed_count, failed_nodes)
574}
575
576/// Dynamic migration planner for moving layers between nodes under load.
577#[derive(Debug, Clone)]
578pub struct MigrationPlanner {
579    pub source_node: String,
580    pub target_node: String,
581    pub layers: Vec<usize>,
582    pub est_migration_time_ms: f32,
583}
584
585impl MigrationPlanner {
586    pub fn new(source: String, target: String, layers: Vec<usize>) -> Self {
587        let est_time = layers.len() as f32 * 45.0; // Baseline 45ms per layer migration
588        Self {
589            source_node: source,
590            target_node: target,
591            layers,
592            est_migration_time_ms: est_time,
593        }
594    }
595
596    /// Generate a safe handoff sequence for a migration.
597    pub fn generate_handoff_plan(&self) -> Vec<String> {
598        let mut steps = Vec::new();
599        let target_node = &self.target_node;
600        let source_node = &self.source_node;
601        steps.push(format!(
602            "PREPARE: Target node {target_node} allocating VRAM"
603        ));
604        steps.push(format!(
605            "STREAM: Moving layers {:?} to {target_node}",
606            self.layers
607        ));
608        steps.push(format!("VERIFY: Integrity check on {target_node}"));
609        steps.push(format!("COMMIT: Switch routing to {target_node}"));
610        steps.push(format!("CLEANUP: Free VRAM on source node {source_node}"));
611        steps
612    }
613}
614
615/// Trigger mechanism for dynamic rebalancing based on load/health.
616pub struct RebalanceTrigger {
617    pub min_imbalance_ratio: f32,
618    pub max_p95_latency_ms: f32,
619}
620
621impl Default for RebalanceTrigger {
622    fn default() -> Self {
623        Self {
624            min_imbalance_ratio: 0.25, // 25% drift
625            max_p95_latency_ms: 25.0,
626        }
627    }
628}
629
630impl RebalanceTrigger {
631    /// Evaluates if cluster migration is needed due to load or latency imbalance.
632    ///
633    /// OPTIMIZATION: Consolidates node counting and overtaxed/available node search into a
634    /// single pass over node metrics under lock. Borrows string references (&str) during traversal,
635    /// allocating owned Strings only when a migration candidate pair is confirmed.
636    pub fn evaluate(
637        &self,
638        cluster: &ClusterState,
639        current_plan: &PlacementPlan,
640    ) -> Option<MigrationPlanner> {
641        let (overtaxed, available) = {
642            let metrics = cluster
643                .metrics
644                .lock()
645                .unwrap_or_else(|poison| poison.into_inner());
646
647            let mut active_count = 0usize;
648            let mut overtaxed_ref: Option<&str> = None;
649            let mut available_ref: Option<&str> = None;
650
651            for m in metrics.values() {
652                if m.status == NodeStatus::Active {
653                    active_count += 1;
654                    let is_overtaxed = m.delivery_ratio < 0.90 || m.avg_latency_us > 15.0;
655                    if is_overtaxed {
656                        if overtaxed_ref.is_none() {
657                            overtaxed_ref = Some(&m.name);
658                        }
659                    } else if available_ref.is_none() && m.available_vram_gb > 8.0 {
660                        available_ref = Some(&m.name);
661                    }
662                }
663            }
664
665            if active_count < 2 {
666                return None;
667            }
668
669            (
670                overtaxed_ref.map(|s| s.to_string()),
671                available_ref.map(|s| s.to_string()),
672            )
673        };
674
675        if let (Some(source), Some(target)) = (overtaxed, available) {
676            if source == target {
677                return None;
678            }
679
680            // Find layers to move from overtaxed node
681            let layers_to_move = current_plan
682                .assignments
683                .iter()
684                .find(|a| a.node_id == source)
685                .map(|a| (a.start_layer..a.end_layer).take(2).collect::<Vec<_>>())
686                .unwrap_or_default();
687
688            if !layers_to_move.is_empty() {
689                return Some(MigrationPlanner::new(source, target, layers_to_move));
690            }
691        }
692
693        None
694    }
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700    use crate::host::AccelerationMode;
701
702    fn sample_layers(count: usize, vram_gb: f32) -> Vec<LayerSpec> {
703        (0..count)
704            .map(|index| LayerSpec {
705                index,
706                vram_gb,
707                num_weights: 0,
708            })
709            .collect()
710    }
711
712    #[test]
713    fn greedily_places_layers_across_nodes() {
714        let nodes = vec![
715            NodeResources::new("node-a", 24.0, 64.0, "8.9", None),
716            NodeResources::new("node-b", 12.0, 32.0, "8.6", None),
717        ];
718
719        let assignments = assign_layers_sequentially(&nodes, &sample_layers(33, 1.0)).unwrap();
720
721        assert_eq!(
722            assignments,
723            vec![
724                LayerAssignment {
725                    node_id: "node-a".into(),
726                    start_layer: 0,
727                    end_layer: 24,
728                    used_vram_gb: 24.0,
729                    num_layers: 24,
730                },
731                LayerAssignment {
732                    node_id: "node-b".into(),
733                    start_layer: 24,
734                    end_layer: 33,
735                    used_vram_gb: 9.0,
736                    num_layers: 9,
737                }
738            ]
739        );
740    }
741
742    #[test]
743    fn reports_insufficient_capacity() {
744        let nodes = vec![NodeResources::new("node-a", 2.0, 64.0, "8.9", None)];
745        let error = assign_layers_sequentially(&nodes, &sample_layers(3, 1.0)).unwrap_err();
746
747        assert!(error.contains("insufficient cluster VRAM"));
748    }
749
750    #[test]
751    fn selects_quantization_mode_from_delivery_ratio() {
752        assert_eq!(select_quantization_mode(0.98), QuantizationMode::None);
753        assert_eq!(select_quantization_mode(0.90), QuantizationMode::Int8);
754        assert_eq!(select_quantization_mode(0.75), QuantizationMode::Int4);
755    }
756
757    #[test]
758    fn placement_plan_summary() {
759        let plan = PlacementPlan::new(
760            vec![
761                LayerAssignment::new("node-a".into(), 0, 24, 24.0),
762                LayerAssignment::new("node-b".into(), 24, 33, 9.0),
763            ],
764            QuantizationMode::None,
765        );
766
767        let summary = plan.summary();
768        assert!(summary.contains("Total layers: 33"));
769        assert!(summary.contains("Full Precision"));
770    }
771
772    #[test]
773    fn rebalance_assignments_detects_capacity() {
774        let mut plan = PlacementPlan::new(
775            vec![LayerAssignment::new("node-a".into(), 0, 24, 24.0)],
776            QuantizationMode::None,
777        );
778
779        // Would need actual cluster state to test rebalancing
780        assert!(!rebalance_assignments(&ClusterState::default(), &mut plan));
781    }
782
783    #[test]
784    fn runtime_profile_chunks_large_assignments() {
785        let assignments = vec![LayerAssignment::new("node-a".into(), 0, 12, 12.0)];
786        let chunked = chunk_assignments_for_workers(assignments, 5);
787
788        assert_eq!(chunked.len(), 3);
789        assert_eq!(chunked[0].start_layer, 0);
790        assert_eq!(chunked[0].end_layer, 5);
791        assert_eq!(chunked[2].start_layer, 10);
792        assert_eq!(chunked[2].end_layer, 12);
793    }
794
795    #[test]
796    fn runtime_profile_assignment_keeps_trailing_zero_vram_layers() {
797        // Regression: assign_layers_chunked finalized its last pending chunk
798        // by checking `chunk_vram > 0.0`, so a trailing run of zero-VRAM
799        // layers left a non-empty pending chunk with chunk_vram == 0.0 and
800        // silently dropped those layers from the plan.
801        let nodes = vec![NodeResources::new("node-a", 24.0, 64.0, "8.9", None)];
802        let layers = sample_layers(3, 0.0);
803        let profile = RuntimeProfile {
804            node_resources: NodeResources::new("node-a", 24.0, 64.0, "8.9", None),
805            logical_cores: 4,
806            recommended_workers: 1,
807            acceleration_mode: AccelerationMode::Generic,
808            gpu_backend: crate::host::GpuBackend::Cpu,
809            xdp_supported: false,
810            detection_source: String::from("test"),
811            probe_mode: crate::host::ProbeMode::Fast,
812        };
813
814        let assignments = assign_layers_with_runtime_profile(&nodes, &layers, &profile).unwrap();
815        let total: usize = assignments.iter().map(|a| a.num_layers).sum();
816        assert_eq!(total, 3, "all zero-VRAM layers must still be assigned");
817    }
818
819    #[test]
820    fn fault_tolerance_handles_all_failed_nodes_without_panicking() {
821        // Regression: dividing active_nodes' summed delivery ratio by
822        // active_nodes().len() produced NaN (or a TOCTOU-inconsistent
823        // average) when every registered node's metrics were marked Failed.
824        let cluster = ClusterState::new();
825        cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
826        cluster.mark_failed("node-a");
827
828        let plan = assign_layers_with_fault_tolerance(&cluster, &sample_layers(4, 1.0)).unwrap();
829        assert_eq!(plan.quantization_mode, QuantizationMode::Int4);
830    }
831
832    #[test]
833    fn planning_tuning_scales_chunk_size_with_workers() {
834        let profile = RuntimeProfile {
835            node_resources: NodeResources::new("node-a", 24.0, 64.0, "8.9", None),
836            logical_cores: 16,
837            recommended_workers: 8,
838            acceleration_mode: AccelerationMode::Gpu,
839            gpu_backend: crate::host::GpuBackend::Cuda,
840            xdp_supported: true,
841            detection_source: String::from("test"),
842            probe_mode: crate::host::ProbeMode::Fast,
843        };
844
845        let tuning = PlanningTuning::from_runtime_profile(&profile, 40);
846        assert!(tuning.max_layers_per_assignment <= 4);
847    }
848
849    #[test]
850    fn test_rebalance_trigger_evaluate_never_selects_overtaxed_as_available() {
851        let cluster = ClusterState::new();
852        cluster.register(NodeResources::new("node-1", 24.0, 64.0, "8.9", None));
853        cluster.register(NodeResources::new("node-2", 24.0, 64.0, "8.9", None));
854
855        // Node 1 is overtaxed (delivery ratio < 0.90)
856        cluster.get_metrics_mut("node-1", |m| {
857            m.delivery_ratio = 0.50;
858            m.available_vram_gb = 16.0;
859        });
860
861        // Node 2 is also overtaxed (delivery ratio < 0.90) with high VRAM
862        cluster.get_metrics_mut("node-2", |m| {
863            m.delivery_ratio = 0.60;
864            m.available_vram_gb = 16.0;
865        });
866
867        let trigger = RebalanceTrigger::default();
868        let plan = PlacementPlan::new(
869            vec![LayerAssignment::new("node-1".into(), 0, 10, 10.0)],
870            QuantizationMode::None,
871        );
872
873        let planner = trigger.evaluate(&cluster, &plan);
874        // Since both nodes are overtaxed, neither should be chosen as an "available" target
875        assert!(
876            planner.is_none(),
877            "overtaxed nodes must never be selected as available rebalance targets"
878        );
879    }
880}