1use crate::cluster::ClusterState;
9use crate::cluster::NodeStatus;
10use crate::host::{AccelerationMode, RuntimeProfile};
11use crate::protocol::NodeResources;
12
13pub const DELIVERY_RATIO_INT8_THRESHOLD: f32 = 0.95;
15pub const DELIVERY_RATIO_INT4_THRESHOLD: f32 = 0.80;
16
17#[derive(Clone, Debug, PartialEq)]
19pub struct LayerSpec {
20 pub index: usize,
22 pub vram_gb: f32,
24 pub num_weights: u32,
26}
27
28impl Default for LayerSpec {
29 fn default() -> Self {
30 Self {
31 index: 0,
32 vram_gb: 1.0,
33 num_weights: 0,
34 }
35 }
36}
37
38#[derive(Clone, Debug, PartialEq)]
40pub struct LayerAssignment {
41 pub node_id: String,
43 pub start_layer: usize,
45 pub end_layer: usize,
47 pub used_vram_gb: f32,
49 pub num_layers: usize,
51}
52
53impl LayerAssignment {
54 pub fn new(node_id: String, start_layer: usize, end_layer: usize, vram_gb: f32) -> Self {
56 Self {
57 node_id,
58 start_layer,
59 end_layer,
60 used_vram_gb: vram_gb,
61 num_layers: end_layer - start_layer,
62 }
63 }
64
65 pub fn avg_vram_per_layer(&self) -> f32 {
67 if self.num_layers == 0 {
68 0.0
69 } else {
70 self.used_vram_gb / self.num_layers as f32
71 }
72 }
73}
74
75#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
77pub enum QuantizationMode {
78 #[default]
80 None,
81 Int8,
83 Int4,
85}
86
87#[derive(Clone, Debug, Default)]
89pub struct PlacementPlan {
90 pub assignments: Vec<LayerAssignment>,
92 pub quantization_mode: QuantizationMode,
94 pub total_layers: usize,
96 pub participating_nodes: Vec<String>,
98}
99
100impl PlacementPlan {
101 pub fn new(assignments: Vec<LayerAssignment>, quantization_mode: QuantizationMode) -> Self {
107 let mut participating_nodes = Vec::with_capacity(assignments.len());
108 let mut total_layers = 0usize;
109
110 for a in &assignments {
111 total_layers += a.num_layers;
112 if participating_nodes.last() != Some(&a.node_id)
116 && !participating_nodes.contains(&a.node_id)
117 {
118 participating_nodes.push(a.node_id.clone());
119 }
120 }
121
122 Self {
123 assignments,
124 quantization_mode,
125 total_layers,
126 participating_nodes,
127 }
128 }
129
130 pub fn summary(&self) -> String {
132 let mode_str = match self.quantization_mode {
133 QuantizationMode::None => "Full Precision",
134 QuantizationMode::Int8 => "8-bit Quantized",
135 QuantizationMode::Int4 => "4-bit Quantized",
136 };
137
138 format!(
141 "Placement Plan ({})\n\
142 =================\n\
143 Total layers: {}\n\
144 Quantization: {}\n\
145 Nodes: {}\n",
146 mode_str,
147 self.total_layers,
148 mode_str,
149 self.participating_nodes.join(", ")
150 )
151 }
152}
153
154#[derive(Clone, Copy, Debug, PartialEq, Eq)]
156pub struct PlanningTuning {
157 pub max_layers_per_assignment: usize,
159}
160
161impl PlanningTuning {
162 pub fn from_runtime_profile(profile: &RuntimeProfile, total_layers: usize) -> Self {
167 let worker_count = profile.recommended_workers.max(1);
168 let (accelerator_bias, vector_bias) = match profile.acceleration_mode {
169 AccelerationMode::Gpu => (2, 2),
170 AccelerationMode::Avx512 => (1, 2),
171 _ => (0, 1),
172 };
173 let target_chunks = (worker_count + accelerator_bias + vector_bias - 1).max(1);
174 let chunk_size = if total_layers == 0 {
175 1
176 } else {
177 total_layers.div_ceil(target_chunks).max(1)
178 };
179
180 Self {
181 max_layers_per_assignment: chunk_size,
182 }
183 }
184}
185
186pub fn select_quantization_mode(delivery_ratio: f32) -> QuantizationMode {
188 if delivery_ratio >= DELIVERY_RATIO_INT8_THRESHOLD {
189 QuantizationMode::None
190 } else if delivery_ratio >= DELIVERY_RATIO_INT4_THRESHOLD {
191 QuantizationMode::Int8
192 } else {
193 QuantizationMode::Int4
194 }
195}
196
197pub fn assign_layers_sequentially(
203 nodes: &[NodeResources],
204 layers: &[LayerSpec],
205) -> Result<Vec<LayerAssignment>, String> {
206 if nodes.is_empty() {
207 return Err("at least one node is required".into());
208 }
209 if layers.is_empty() {
210 return Ok(Vec::new());
211 }
212
213 let mut assignments = Vec::with_capacity(nodes.len());
214 let mut node_idx = 0usize;
215 let mut current_node = &nodes[0];
216 let mut remaining_capacity = current_node.vram_gb;
217 let mut current_start = 0usize;
218 let mut current_start_layer = layers[0].index;
219 let mut last_layer_index = layers[0].index;
220 let mut current_vram = 0.0f32;
221
222 for (i, layer) in layers.iter().enumerate() {
223 while layer.vram_gb > remaining_capacity {
224 if i > current_start {
226 let start_layer = current_start_layer;
227 let end_layer = last_layer_index + 1;
228 assignments.push(LayerAssignment::new(
229 current_node.id.clone(),
230 start_layer,
231 end_layer,
232 current_vram,
233 ));
234 }
235
236 node_idx += 1;
237 if node_idx >= nodes.len() {
238 return Err(format!(
239 "insufficient cluster VRAM for layer {} (needs {:.2} GB)",
240 layer.index, layer.vram_gb
241 ));
242 }
243 current_node = &nodes[node_idx];
244 remaining_capacity = current_node.vram_gb;
245 current_start = i;
246 current_start_layer = layer.index;
247 current_vram = 0.0;
248 }
249
250 remaining_capacity -= layer.vram_gb;
252 current_vram += layer.vram_gb;
253 last_layer_index = layer.index;
254 }
255
256 if current_start < layers.len() {
258 let start_layer = current_start_layer;
259 let end_layer = last_layer_index + 1;
260 assignments.push(LayerAssignment::new(
261 current_node.id.clone(),
262 start_layer,
263 end_layer,
264 current_vram,
265 ));
266 }
267
268 Ok(assignments)
269}
270
271pub fn chunk_assignments_for_workers(
273 assignments: Vec<LayerAssignment>,
274 max_layers_per_assignment: usize,
275) -> Vec<LayerAssignment> {
276 let chunk_size = max_layers_per_assignment.max(1);
277
278 let needs_chunking = assignments.iter().any(|a| a.num_layers > chunk_size);
279 if !needs_chunking {
280 return assignments;
281 }
282
283 let mut chunked = Vec::with_capacity(assignments.len() * 2);
284
285 for mut assignment in assignments {
286 if assignment.num_layers <= chunk_size {
287 chunked.push(assignment);
288 continue;
289 }
290
291 let avg_vram = assignment.avg_vram_per_layer();
292 let mut start_layer = assignment.start_layer;
293
294 while start_layer < assignment.end_layer {
295 let end_layer = (start_layer + chunk_size).min(assignment.end_layer);
296 let num_layers = end_layer - start_layer;
297 let node_id = if end_layer == assignment.end_layer {
300 std::mem::take(&mut assignment.node_id)
301 } else {
302 assignment.node_id.clone()
303 };
304 chunked.push(LayerAssignment::new(
305 node_id,
306 start_layer,
307 end_layer,
308 avg_vram * num_layers as f32,
309 ));
310 start_layer = end_layer;
311 }
312 }
313
314 chunked
315}
316
317fn assign_layers_chunked(
326 nodes: &[NodeResources],
327 layers: &[LayerSpec],
328 max_layers_per_assignment: usize,
329) -> Result<Vec<LayerAssignment>, String> {
330 if nodes.is_empty() {
331 return Err("at least one node is required".into());
332 }
333 if layers.is_empty() {
334 return Ok(Vec::new());
335 }
336
337 let chunk_size = max_layers_per_assignment.max(1);
338 let est_assignments = (layers.len().div_ceil(chunk_size)).max(nodes.len());
339 let mut assignments = Vec::with_capacity(est_assignments);
340 let mut node_idx = 0usize;
341 let mut current_node = &nodes[0];
342 let mut remaining_vram = current_node.vram_gb;
343 let mut chunk_start = 0usize;
345 let mut chunk_start_layer = layers[0].index;
346 let mut last_layer_index = layers[0].index;
347 let mut chunk_vram = 0.0f32;
348
349 for (i, layer) in layers.iter().enumerate() {
350 while layer.vram_gb > remaining_vram {
352 if i > chunk_start {
353 let start_layer = chunk_start_layer;
354 let end_layer = last_layer_index + 1;
355 assignments.push(LayerAssignment::new(
356 current_node.id.clone(),
357 start_layer,
358 end_layer,
359 chunk_vram,
360 ));
361 }
362 node_idx += 1;
363 if node_idx >= nodes.len() {
364 return Err(format!(
365 "insufficient cluster VRAM for layer {} (needs {:.2} GB)",
366 layer.index, layer.vram_gb
367 ));
368 }
369 current_node = &nodes[node_idx];
370 remaining_vram = current_node.vram_gb;
371 chunk_start = i;
372 chunk_vram = 0.0;
373 }
374
375 if i == chunk_start {
377 chunk_start_layer = layer.index;
378 }
379
380 remaining_vram -= layer.vram_gb;
381 chunk_vram += layer.vram_gb;
382 last_layer_index = layer.index;
383
384 let chunk_len = i + 1 - chunk_start;
386 if chunk_len >= chunk_size {
387 let start_layer = chunk_start_layer;
388 let end_layer = layer.index + 1;
389 assignments.push(LayerAssignment::new(
390 current_node.id.clone(),
391 start_layer,
392 end_layer,
393 chunk_vram,
394 ));
395 chunk_start = i + 1;
396 chunk_vram = 0.0;
397 }
398 }
399
400 if chunk_start < layers.len() {
405 let start_layer = chunk_start_layer;
406 let end_layer = last_layer_index + 1;
407 assignments.push(LayerAssignment::new(
408 current_node.id.clone(),
409 start_layer,
410 end_layer,
411 chunk_vram,
412 ));
413 }
414
415 Ok(assignments)
416}
417
418pub fn assign_layers_with_runtime_profile(
420 nodes: &[NodeResources],
421 layers: &[LayerSpec],
422 profile: &RuntimeProfile,
423) -> Result<Vec<LayerAssignment>, String> {
424 let tuning = PlanningTuning::from_runtime_profile(profile, layers.len());
425 assign_layers_chunked(nodes, layers, tuning.max_layers_per_assignment)
426}
427
428fn calculate_average_delivery_ratio(cluster: &ClusterState) -> f32 {
430 let (sum_delivery, active_count) = {
431 let metrics = cluster
432 .metrics
433 .lock()
434 .unwrap_or_else(|poison| poison.into_inner());
435 let mut sum = 0.0f32;
436 let mut count = 0usize;
437 for metric in metrics.values() {
438 if metric.status == NodeStatus::Active {
439 sum += metric.delivery_ratio;
440 count += 1;
441 }
442 }
443 (sum, count)
444 };
445
446 if active_count > 0 {
447 sum_delivery / active_count as f32
448 } else {
449 0.0
450 }
451}
452
453pub fn assign_layers_with_fault_tolerance(
455 cluster: &ClusterState,
456 layers: &[LayerSpec],
457) -> Result<PlacementPlan, String> {
458 let nodes = cluster.nodes_snapshot();
459
460 if nodes.is_empty() {
461 return Err("no nodes available".into());
462 }
463
464 let assignments = assign_layers_sequentially(&nodes, layers)?;
466
467 let total_delivery_ratio = calculate_average_delivery_ratio(cluster);
468
469 let quantization_mode = select_quantization_mode(total_delivery_ratio);
471
472 Ok(PlacementPlan::new(assignments, quantization_mode))
473}
474
475pub fn assign_layers_with_fault_tolerance_and_runtime(
480 cluster: &ClusterState,
481 layers: &[LayerSpec],
482 profile: &RuntimeProfile,
483) -> Result<PlacementPlan, String> {
484 let nodes = cluster.nodes_snapshot();
485
486 if nodes.is_empty() {
487 return Err("no nodes available".into());
488 }
489
490 let tuning = PlanningTuning::from_runtime_profile(profile, layers.len());
491 let assignments = assign_layers_chunked(&nodes, layers, tuning.max_layers_per_assignment)?;
493
494 let total_delivery_ratio = calculate_average_delivery_ratio(cluster);
495
496 let quantization_mode = select_quantization_mode(total_delivery_ratio);
498
499 Ok(PlacementPlan::new(assignments, quantization_mode))
500}
501
502pub fn rebalance_assignments(cluster: &ClusterState, plan: &mut PlacementPlan) -> bool {
504 let mut needs_rebalance = false;
506
507 let metrics = cluster
508 .metrics
509 .lock()
510 .unwrap_or_else(|poison| poison.into_inner());
511
512 for assignment in &mut plan.assignments {
513 if let Some(metric) = metrics.get(&assignment.node_id) {
514 if metric.status != NodeStatus::Active {
515 continue;
517 }
518
519 let available = metric.available_vram_gb;
521 let avg_layer_size = assignment.avg_vram_per_layer();
522
523 if available > 0.0 && avg_layer_size > 0.0 {
524 let potential_layers = (available / avg_layer_size) as usize;
525
526 if potential_layers > 2 {
528 needs_rebalance = true;
529 break;
530 }
531 }
532 }
533 }
534
535 needs_rebalance
536}
537
538#[cfg(test)]
540pub fn simulate_layer_streaming(
541 node_id: &str,
542 cluster: &ClusterState,
543 start_layer: usize,
544 end_layer: usize,
545) -> Option<crate::cluster::NodeMetrics> {
546 let mut metrics = cluster.get_metrics(node_id)?;
547
548 let num_layers = end_layer - start_layer;
550 let avg_vram = (metrics.available_vram_gb + metrics.vram_gb) / 2.0;
551 let vram_per_layer = avg_vram / num_layers as f32;
552 metrics.record_vram_usage(vram_per_layer * num_layers as f32);
553
554 metrics.set_streaming_layers(start_layer, end_layer);
556
557 Some(metrics)
558}
559
560pub fn calculate_cluster_health(cluster: &ClusterState) -> (f32, usize, Vec<String>) {
562 let metrics = cluster
564 .metrics
565 .lock()
566 .unwrap_or_else(|poison| poison.into_inner());
567
568 if metrics.is_empty() {
569 return (0.0, 0, vec![]);
570 }
571
572 let mut sum_delivery_ratio = 0.0f32;
573 let mut active_count = 0usize;
574 let mut failed_nodes = Vec::new();
575
576 for (node_id, metric) in metrics.iter() {
578 match metric.status {
579 NodeStatus::Active => {
580 sum_delivery_ratio += metric.delivery_ratio;
581 active_count += 1;
582 }
583 NodeStatus::Failed => {
584 failed_nodes.push(node_id.clone());
585 }
586 _ => {}
587 }
588 }
589
590 let avg_delivery_ratio = if active_count > 0 {
591 sum_delivery_ratio / active_count as f32
592 } else {
593 0.0
594 };
595
596 let failed_count = failed_nodes.len();
597
598 (avg_delivery_ratio, failed_count, failed_nodes)
599}
600
601#[derive(Debug, Clone)]
603pub struct MigrationPlanner {
604 pub source_node: String,
605 pub target_node: String,
606 pub layers: Vec<usize>,
607 pub est_migration_time_ms: f32,
608}
609
610impl MigrationPlanner {
611 pub fn new(source: String, target: String, layers: Vec<usize>) -> Self {
612 let est_time = layers.len() as f32 * 45.0; Self {
614 source_node: source,
615 target_node: target,
616 layers,
617 est_migration_time_ms: est_time,
618 }
619 }
620
621 pub fn generate_handoff_plan(&self) -> Vec<String> {
623 let mut steps = Vec::new();
624 let target_node = &self.target_node;
625 let source_node = &self.source_node;
626 steps.push(format!(
627 "PREPARE: Target node {target_node} allocating VRAM"
628 ));
629 steps.push(format!(
630 "STREAM: Moving layers {:?} to {target_node}",
631 self.layers
632 ));
633 steps.push(format!("VERIFY: Integrity check on {target_node}"));
634 steps.push(format!("COMMIT: Switch routing to {target_node}"));
635 steps.push(format!("CLEANUP: Free VRAM on source node {source_node}"));
636 steps
637 }
638}
639
640pub struct RebalanceTrigger {
642 pub min_imbalance_ratio: f32,
643 pub max_p95_latency_ms: f32,
644}
645
646impl Default for RebalanceTrigger {
647 fn default() -> Self {
648 Self {
649 min_imbalance_ratio: 0.25, max_p95_latency_ms: 25.0,
651 }
652 }
653}
654
655impl RebalanceTrigger {
656 pub fn evaluate(
662 &self,
663 cluster: &ClusterState,
664 current_plan: &PlacementPlan,
665 ) -> Option<MigrationPlanner> {
666 let (overtaxed, available) = {
667 let metrics = cluster
668 .metrics
669 .lock()
670 .unwrap_or_else(|poison| poison.into_inner());
671
672 let mut active_count = 0usize;
673 let mut overtaxed_ref: Option<&str> = None;
674 let mut available_ref: Option<&str> = None;
675
676 for m in metrics.values() {
677 if m.status == NodeStatus::Active {
678 active_count += 1;
679 let is_overtaxed = m.delivery_ratio < 0.90 || m.avg_latency_us > 15.0;
680 if is_overtaxed {
681 if overtaxed_ref.is_none() {
682 overtaxed_ref = Some(&m.name);
683 }
684 } else if available_ref.is_none() && m.available_vram_gb > 8.0 {
685 available_ref = Some(&m.name);
686 }
687 }
688 }
689
690 if active_count < 2 {
691 return None;
692 }
693
694 (
695 overtaxed_ref.map(|s| s.to_string()),
696 available_ref.map(|s| s.to_string()),
697 )
698 };
699
700 if let (Some(source), Some(target)) = (overtaxed, available) {
701 if source == target {
702 return None;
703 }
704
705 let layers_to_move = current_plan
707 .assignments
708 .iter()
709 .find(|a| a.node_id == source)
710 .map(|a| (a.start_layer..a.end_layer).take(2).collect::<Vec<_>>())
711 .unwrap_or_default();
712
713 if !layers_to_move.is_empty() {
714 return Some(MigrationPlanner::new(source, target, layers_to_move));
715 }
716 }
717
718 None
719 }
720}
721
722#[cfg(test)]
723mod tests {
724 use super::*;
725 use crate::host::AccelerationMode;
726
727 fn sample_layers(count: usize, vram_gb: f32) -> Vec<LayerSpec> {
728 (0..count)
729 .map(|index| LayerSpec {
730 index,
731 vram_gb,
732 num_weights: 0,
733 })
734 .collect()
735 }
736
737 #[test]
738 fn greedily_places_layers_across_nodes() {
739 let nodes = vec![
740 NodeResources::new("node-a", 24.0, 64.0, "8.9", None),
741 NodeResources::new("node-b", 12.0, 32.0, "8.6", None),
742 ];
743
744 let assignments = assign_layers_sequentially(&nodes, &sample_layers(33, 1.0)).unwrap();
745
746 assert_eq!(
747 assignments,
748 vec![
749 LayerAssignment {
750 node_id: "node-a".into(),
751 start_layer: 0,
752 end_layer: 24,
753 used_vram_gb: 24.0,
754 num_layers: 24,
755 },
756 LayerAssignment {
757 node_id: "node-b".into(),
758 start_layer: 24,
759 end_layer: 33,
760 used_vram_gb: 9.0,
761 num_layers: 9,
762 }
763 ]
764 );
765 }
766
767 #[test]
768 fn reports_insufficient_capacity() {
769 let nodes = vec![NodeResources::new("node-a", 2.0, 64.0, "8.9", None)];
770 let error = assign_layers_sequentially(&nodes, &sample_layers(3, 1.0)).unwrap_err();
771
772 assert!(error.contains("insufficient cluster VRAM"));
773 }
774
775 #[test]
776 fn selects_quantization_mode_from_delivery_ratio() {
777 assert_eq!(select_quantization_mode(0.98), QuantizationMode::None);
778 assert_eq!(select_quantization_mode(0.90), QuantizationMode::Int8);
779 assert_eq!(select_quantization_mode(0.75), QuantizationMode::Int4);
780 }
781
782 #[test]
783 fn placement_plan_summary() {
784 let plan = PlacementPlan::new(
785 vec![
786 LayerAssignment::new("node-a".into(), 0, 24, 24.0),
787 LayerAssignment::new("node-b".into(), 24, 33, 9.0),
788 ],
789 QuantizationMode::None,
790 );
791
792 let summary = plan.summary();
793 assert!(summary.contains("Total layers: 33"));
794 assert!(summary.contains("Full Precision"));
795 }
796
797 #[test]
798 fn rebalance_assignments_detects_capacity() {
799 let mut plan = PlacementPlan::new(
800 vec![LayerAssignment::new("node-a".into(), 0, 24, 24.0)],
801 QuantizationMode::None,
802 );
803
804 assert!(!rebalance_assignments(&ClusterState::default(), &mut plan));
806 }
807
808 #[test]
809 fn runtime_profile_chunks_large_assignments() {
810 let assignments = vec![LayerAssignment::new("node-a".into(), 0, 12, 12.0)];
811 let chunked = chunk_assignments_for_workers(assignments, 5);
812
813 assert_eq!(chunked.len(), 3);
814 assert_eq!(chunked[0].start_layer, 0);
815 assert_eq!(chunked[0].end_layer, 5);
816 assert_eq!(chunked[2].start_layer, 10);
817 assert_eq!(chunked[2].end_layer, 12);
818 }
819
820 #[test]
821 fn runtime_profile_assignment_keeps_trailing_zero_vram_layers() {
822 let nodes = vec![NodeResources::new("node-a", 24.0, 64.0, "8.9", None)];
827 let layers = sample_layers(3, 0.0);
828 let profile = RuntimeProfile {
829 node_resources: NodeResources::new("node-a", 24.0, 64.0, "8.9", None),
830 logical_cores: 4,
831 recommended_workers: 1,
832 acceleration_mode: AccelerationMode::Generic,
833 gpu_backend: crate::host::GpuBackend::Cpu,
834 xdp_supported: false,
835 detection_source: String::from("test"),
836 probe_mode: crate::host::ProbeMode::Fast,
837 };
838
839 let assignments = assign_layers_with_runtime_profile(&nodes, &layers, &profile).unwrap();
840 let total: usize = assignments.iter().map(|a| a.num_layers).sum();
841 assert_eq!(total, 3, "all zero-VRAM layers must still be assigned");
842 }
843
844 #[test]
845 fn fault_tolerance_handles_all_failed_nodes_without_panicking() {
846 let cluster = ClusterState::new();
850 cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
851 cluster.mark_failed("node-a");
852
853 let plan = assign_layers_with_fault_tolerance(&cluster, &sample_layers(4, 1.0)).unwrap();
854 assert_eq!(plan.quantization_mode, QuantizationMode::Int4);
855 }
856
857 #[test]
858 fn planning_tuning_scales_chunk_size_with_workers() {
859 let profile = RuntimeProfile {
860 node_resources: NodeResources::new("node-a", 24.0, 64.0, "8.9", None),
861 logical_cores: 16,
862 recommended_workers: 8,
863 acceleration_mode: AccelerationMode::Gpu,
864 gpu_backend: crate::host::GpuBackend::Cuda,
865 xdp_supported: true,
866 detection_source: String::from("test"),
867 probe_mode: crate::host::ProbeMode::Fast,
868 };
869
870 let tuning = PlanningTuning::from_runtime_profile(&profile, 40);
871 assert!(tuning.max_layers_per_assignment <= 4);
872 }
873
874 #[test]
875 fn test_rebalance_trigger_evaluate_never_selects_overtaxed_as_available() {
876 let cluster = ClusterState::new();
877 cluster.register(NodeResources::new("node-1", 24.0, 64.0, "8.9", None));
878 cluster.register(NodeResources::new("node-2", 24.0, 64.0, "8.9", None));
879
880 cluster.get_metrics_mut("node-1", |m| {
882 m.delivery_ratio = 0.50;
883 m.available_vram_gb = 16.0;
884 });
885
886 cluster.get_metrics_mut("node-2", |m| {
888 m.delivery_ratio = 0.60;
889 m.available_vram_gb = 16.0;
890 });
891
892 let trigger = RebalanceTrigger::default();
893 let plan = PlacementPlan::new(
894 vec![LayerAssignment::new("node-1".into(), 0, 10, 10.0)],
895 QuantizationMode::None,
896 );
897
898 let planner = trigger.evaluate(&cluster, &plan);
899 assert!(
901 planner.is_none(),
902 "overtaxed nodes must never be selected as available rebalance targets"
903 );
904 }
905}