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.contains(&a.node_id) {
113 participating_nodes.push(a.node_id.clone());
114 }
115 }
116
117 Self {
118 assignments,
119 quantization_mode,
120 total_layers,
121 participating_nodes,
122 }
123 }
124
125 pub fn summary(&self) -> String {
127 let mode_str = match self.quantization_mode {
128 QuantizationMode::None => "Full Precision",
129 QuantizationMode::Int8 => "8-bit Quantized",
130 QuantizationMode::Int4 => "4-bit Quantized",
131 };
132
133 format!(
136 "Placement Plan ({})\n\
137 =================\n\
138 Total layers: {}\n\
139 Quantization: {}\n\
140 Nodes: {}\n",
141 mode_str,
142 self.total_layers,
143 mode_str,
144 self.participating_nodes.join(", ")
145 )
146 }
147}
148
149#[derive(Clone, Copy, Debug, PartialEq, Eq)]
151pub struct PlanningTuning {
152 pub max_layers_per_assignment: usize,
154}
155
156impl PlanningTuning {
157 pub fn from_runtime_profile(profile: &RuntimeProfile, total_layers: usize) -> Self {
162 let worker_count = profile.recommended_workers.max(1);
163 let (accelerator_bias, vector_bias) = match profile.acceleration_mode {
164 AccelerationMode::Gpu => (2, 2),
165 AccelerationMode::Avx512 => (1, 2),
166 _ => (0, 1),
167 };
168 let target_chunks = (worker_count + accelerator_bias + vector_bias - 1).max(1);
169 let chunk_size = if total_layers == 0 {
170 1
171 } else {
172 total_layers.div_ceil(target_chunks).max(1)
173 };
174
175 Self {
176 max_layers_per_assignment: chunk_size,
177 }
178 }
179}
180
181pub fn select_quantization_mode(delivery_ratio: f32) -> QuantizationMode {
183 if delivery_ratio >= DELIVERY_RATIO_INT8_THRESHOLD {
184 QuantizationMode::None
185 } else if delivery_ratio >= DELIVERY_RATIO_INT4_THRESHOLD {
186 QuantizationMode::Int8
187 } else {
188 QuantizationMode::Int4
189 }
190}
191
192pub fn assign_layers_sequentially(
198 nodes: &[NodeResources],
199 layers: &[LayerSpec],
200) -> Result<Vec<LayerAssignment>, String> {
201 if nodes.is_empty() {
202 return Err("at least one node is required".into());
203 }
204 if layers.is_empty() {
205 return Ok(Vec::new());
206 }
207
208 let mut assignments = Vec::with_capacity(nodes.len());
209 let mut node_idx = 0usize;
210 let mut current_node = &nodes[0];
211 let mut remaining_capacity = current_node.vram_gb;
212 let mut current_start = 0usize;
213 let mut current_start_layer = layers[0].index;
214 let mut last_layer_index = layers[0].index;
215 let mut current_vram = 0.0f32;
216
217 for (i, layer) in layers.iter().enumerate() {
218 while layer.vram_gb > remaining_capacity {
219 if i > current_start {
221 let start_layer = current_start_layer;
222 let end_layer = last_layer_index + 1;
223 assignments.push(LayerAssignment::new(
224 current_node.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 current_node = &nodes[node_idx];
239 remaining_capacity = current_node.vram_gb;
240 current_start = i;
241 current_start_layer = layer.index;
242 current_vram = 0.0;
243 }
244
245 remaining_capacity -= layer.vram_gb;
247 current_vram += layer.vram_gb;
248 last_layer_index = layer.index;
249 }
250
251 if current_start < layers.len() {
253 let start_layer = current_start_layer;
254 let end_layer = last_layer_index + 1;
255 assignments.push(LayerAssignment::new(
256 current_node.id.clone(),
257 start_layer,
258 end_layer,
259 current_vram,
260 ));
261 }
262
263 Ok(assignments)
264}
265
266pub fn chunk_assignments_for_workers(
268 assignments: Vec<LayerAssignment>,
269 max_layers_per_assignment: usize,
270) -> Vec<LayerAssignment> {
271 let chunk_size = max_layers_per_assignment.max(1);
272
273 let needs_chunking = assignments.iter().any(|a| a.num_layers > chunk_size);
274 if !needs_chunking {
275 return assignments;
276 }
277
278 let mut chunked = Vec::with_capacity(assignments.len() * 2);
279
280 for assignment in assignments {
281 if assignment.num_layers <= chunk_size {
282 chunked.push(assignment);
283 continue;
284 }
285
286 let avg_vram = assignment.avg_vram_per_layer();
287 let mut start_layer = assignment.start_layer;
288
289 while start_layer < assignment.end_layer {
290 let end_layer = (start_layer + chunk_size).min(assignment.end_layer);
291 let num_layers = end_layer - start_layer;
292 chunked.push(LayerAssignment::new(
293 assignment.node_id.clone(),
294 start_layer,
295 end_layer,
296 avg_vram * num_layers as f32,
297 ));
298 start_layer = end_layer;
299 }
300 }
301
302 chunked
303}
304
305fn assign_layers_chunked(
314 nodes: &[NodeResources],
315 layers: &[LayerSpec],
316 max_layers_per_assignment: usize,
317) -> Result<Vec<LayerAssignment>, String> {
318 if nodes.is_empty() {
319 return Err("at least one node is required".into());
320 }
321 if layers.is_empty() {
322 return Ok(Vec::new());
323 }
324
325 let chunk_size = max_layers_per_assignment.max(1);
326 let est_assignments = (layers.len().div_ceil(chunk_size)).max(nodes.len());
327 let mut assignments = Vec::with_capacity(est_assignments);
328 let mut node_idx = 0usize;
329 let mut current_node = &nodes[0];
330 let mut remaining_vram = current_node.vram_gb;
331 let mut chunk_start = 0usize;
333 let mut chunk_start_layer = layers[0].index;
334 let mut last_layer_index = layers[0].index;
335 let mut chunk_vram = 0.0f32;
336
337 for (i, layer) in layers.iter().enumerate() {
338 while layer.vram_gb > remaining_vram {
340 if i > chunk_start {
341 let start_layer = chunk_start_layer;
342 let end_layer = last_layer_index + 1;
343 assignments.push(LayerAssignment::new(
344 current_node.id.clone(),
345 start_layer,
346 end_layer,
347 chunk_vram,
348 ));
349 }
350 node_idx += 1;
351 if node_idx >= nodes.len() {
352 return Err(format!(
353 "insufficient cluster VRAM for layer {} (needs {:.2} GB)",
354 layer.index, layer.vram_gb
355 ));
356 }
357 current_node = &nodes[node_idx];
358 remaining_vram = current_node.vram_gb;
359 chunk_start = i;
360 chunk_vram = 0.0;
361 }
362
363 if i == chunk_start {
365 chunk_start_layer = layer.index;
366 }
367
368 remaining_vram -= layer.vram_gb;
369 chunk_vram += layer.vram_gb;
370 last_layer_index = layer.index;
371
372 let chunk_len = i + 1 - chunk_start;
374 if chunk_len >= chunk_size {
375 let start_layer = chunk_start_layer;
376 let end_layer = layer.index + 1;
377 assignments.push(LayerAssignment::new(
378 current_node.id.clone(),
379 start_layer,
380 end_layer,
381 chunk_vram,
382 ));
383 chunk_start = i + 1;
384 chunk_vram = 0.0;
385 }
386 }
387
388 if chunk_start < layers.len() {
393 let start_layer = chunk_start_layer;
394 let end_layer = last_layer_index + 1;
395 assignments.push(LayerAssignment::new(
396 current_node.id.clone(),
397 start_layer,
398 end_layer,
399 chunk_vram,
400 ));
401 }
402
403 Ok(assignments)
404}
405
406pub fn assign_layers_with_runtime_profile(
408 nodes: &[NodeResources],
409 layers: &[LayerSpec],
410 profile: &RuntimeProfile,
411) -> Result<Vec<LayerAssignment>, String> {
412 let tuning = PlanningTuning::from_runtime_profile(profile, layers.len());
413 assign_layers_chunked(nodes, layers, tuning.max_layers_per_assignment)
414}
415
416fn calculate_average_delivery_ratio(cluster: &ClusterState) -> f32 {
418 let (sum_delivery, active_count) = {
419 let metrics = cluster
420 .metrics
421 .lock()
422 .unwrap_or_else(|poison| poison.into_inner());
423 let mut sum = 0.0f32;
424 let mut count = 0usize;
425 for metric in metrics.values() {
426 if metric.status == NodeStatus::Active {
427 sum += metric.delivery_ratio;
428 count += 1;
429 }
430 }
431 (sum, count)
432 };
433
434 if active_count > 0 {
435 sum_delivery / active_count as f32
436 } else {
437 0.0
438 }
439}
440
441pub fn assign_layers_with_fault_tolerance(
443 cluster: &ClusterState,
444 layers: &[LayerSpec],
445) -> Result<PlacementPlan, String> {
446 let nodes = cluster.nodes_snapshot();
447
448 if nodes.is_empty() {
449 return Err("no nodes available".into());
450 }
451
452 let assignments = assign_layers_sequentially(&nodes, layers)?;
454
455 let total_delivery_ratio = calculate_average_delivery_ratio(cluster);
456
457 let quantization_mode = select_quantization_mode(total_delivery_ratio);
459
460 Ok(PlacementPlan::new(assignments, quantization_mode))
461}
462
463pub fn assign_layers_with_fault_tolerance_and_runtime(
468 cluster: &ClusterState,
469 layers: &[LayerSpec],
470 profile: &RuntimeProfile,
471) -> Result<PlacementPlan, String> {
472 let nodes = cluster.nodes_snapshot();
473
474 if nodes.is_empty() {
475 return Err("no nodes available".into());
476 }
477
478 let tuning = PlanningTuning::from_runtime_profile(profile, layers.len());
479 let assignments = assign_layers_chunked(&nodes, layers, tuning.max_layers_per_assignment)?;
481
482 let total_delivery_ratio = calculate_average_delivery_ratio(cluster);
483
484 let quantization_mode = select_quantization_mode(total_delivery_ratio);
486
487 Ok(PlacementPlan::new(assignments, quantization_mode))
488}
489
490pub fn rebalance_assignments(cluster: &ClusterState, plan: &mut PlacementPlan) -> bool {
492 let mut needs_rebalance = false;
494
495 let metrics = cluster
496 .metrics
497 .lock()
498 .unwrap_or_else(|poison| poison.into_inner());
499
500 for assignment in &mut plan.assignments {
501 if let Some(metric) = metrics.get(&assignment.node_id) {
502 if metric.status != NodeStatus::Active {
503 continue;
505 }
506
507 let available = metric.available_vram_gb;
509 let avg_layer_size = assignment.avg_vram_per_layer();
510
511 if available > 0.0 && avg_layer_size > 0.0 {
512 let potential_layers = (available / avg_layer_size) as usize;
513
514 if potential_layers > 2 {
516 needs_rebalance = true;
517 break;
518 }
519 }
520 }
521 }
522
523 needs_rebalance
524}
525
526#[cfg(test)]
528pub fn simulate_layer_streaming(
529 node_id: &str,
530 cluster: &ClusterState,
531 start_layer: usize,
532 end_layer: usize,
533) -> Option<crate::cluster::NodeMetrics> {
534 let mut metrics = cluster.get_metrics(node_id)?;
535
536 let num_layers = end_layer - start_layer;
538 let avg_vram = (metrics.available_vram_gb + metrics.vram_gb) / 2.0;
539 let vram_per_layer = avg_vram / num_layers as f32;
540 metrics.record_vram_usage(vram_per_layer * num_layers as f32);
541
542 metrics.set_streaming_layers(start_layer, end_layer);
544
545 Some(metrics)
546}
547
548pub fn calculate_cluster_health(cluster: &ClusterState) -> (f32, usize, Vec<String>) {
550 let metrics = cluster
552 .metrics
553 .lock()
554 .unwrap_or_else(|poison| poison.into_inner());
555
556 if metrics.is_empty() {
557 return (0.0, 0, vec![]);
558 }
559
560 let mut sum_delivery_ratio = 0.0f32;
561 let mut active_count = 0usize;
562 let mut failed_nodes = Vec::new();
563
564 for (node_id, metric) in metrics.iter() {
566 match metric.status {
567 NodeStatus::Active => {
568 sum_delivery_ratio += metric.delivery_ratio;
569 active_count += 1;
570 }
571 NodeStatus::Failed => {
572 failed_nodes.push(node_id.clone());
573 }
574 _ => {}
575 }
576 }
577
578 let avg_delivery_ratio = if active_count > 0 {
579 sum_delivery_ratio / active_count as f32
580 } else {
581 0.0
582 };
583
584 let failed_count = failed_nodes.len();
585
586 (avg_delivery_ratio, failed_count, failed_nodes)
587}
588
589#[derive(Debug, Clone)]
591pub struct MigrationPlanner {
592 pub source_node: String,
593 pub target_node: String,
594 pub layers: Vec<usize>,
595 pub est_migration_time_ms: f32,
596}
597
598impl MigrationPlanner {
599 pub fn new(source: String, target: String, layers: Vec<usize>) -> Self {
600 let est_time = layers.len() as f32 * 45.0; Self {
602 source_node: source,
603 target_node: target,
604 layers,
605 est_migration_time_ms: est_time,
606 }
607 }
608
609 pub fn generate_handoff_plan(&self) -> Vec<String> {
611 let mut steps = Vec::new();
612 let target_node = &self.target_node;
613 let source_node = &self.source_node;
614 steps.push(format!(
615 "PREPARE: Target node {target_node} allocating VRAM"
616 ));
617 steps.push(format!(
618 "STREAM: Moving layers {:?} to {target_node}",
619 self.layers
620 ));
621 steps.push(format!("VERIFY: Integrity check on {target_node}"));
622 steps.push(format!("COMMIT: Switch routing to {target_node}"));
623 steps.push(format!("CLEANUP: Free VRAM on source node {source_node}"));
624 steps
625 }
626}
627
628pub struct RebalanceTrigger {
630 pub min_imbalance_ratio: f32,
631 pub max_p95_latency_ms: f32,
632}
633
634impl Default for RebalanceTrigger {
635 fn default() -> Self {
636 Self {
637 min_imbalance_ratio: 0.25, max_p95_latency_ms: 25.0,
639 }
640 }
641}
642
643impl RebalanceTrigger {
644 pub fn evaluate(
650 &self,
651 cluster: &ClusterState,
652 current_plan: &PlacementPlan,
653 ) -> Option<MigrationPlanner> {
654 let (overtaxed, available) = {
655 let metrics = cluster
656 .metrics
657 .lock()
658 .unwrap_or_else(|poison| poison.into_inner());
659
660 let mut active_count = 0usize;
661 let mut overtaxed_ref: Option<&str> = None;
662 let mut available_ref: Option<&str> = None;
663
664 for m in metrics.values() {
665 if m.status == NodeStatus::Active {
666 active_count += 1;
667 let is_overtaxed = m.delivery_ratio < 0.90 || m.avg_latency_us > 15.0;
668 if is_overtaxed {
669 if overtaxed_ref.is_none() {
670 overtaxed_ref = Some(&m.name);
671 }
672 } else if available_ref.is_none() && m.available_vram_gb > 8.0 {
673 available_ref = Some(&m.name);
674 }
675 }
676 }
677
678 if active_count < 2 {
679 return None;
680 }
681
682 (
683 overtaxed_ref.map(|s| s.to_string()),
684 available_ref.map(|s| s.to_string()),
685 )
686 };
687
688 if let (Some(source), Some(target)) = (overtaxed, available) {
689 if source == target {
690 return None;
691 }
692
693 let layers_to_move = current_plan
695 .assignments
696 .iter()
697 .find(|a| a.node_id == source)
698 .map(|a| (a.start_layer..a.end_layer).take(2).collect::<Vec<_>>())
699 .unwrap_or_default();
700
701 if !layers_to_move.is_empty() {
702 return Some(MigrationPlanner::new(source, target, layers_to_move));
703 }
704 }
705
706 None
707 }
708}
709
710#[cfg(test)]
711mod tests {
712 use super::*;
713 use crate::host::AccelerationMode;
714
715 fn sample_layers(count: usize, vram_gb: f32) -> Vec<LayerSpec> {
716 (0..count)
717 .map(|index| LayerSpec {
718 index,
719 vram_gb,
720 num_weights: 0,
721 })
722 .collect()
723 }
724
725 #[test]
726 fn greedily_places_layers_across_nodes() {
727 let nodes = vec![
728 NodeResources::new("node-a", 24.0, 64.0, "8.9", None),
729 NodeResources::new("node-b", 12.0, 32.0, "8.6", None),
730 ];
731
732 let assignments = assign_layers_sequentially(&nodes, &sample_layers(33, 1.0)).unwrap();
733
734 assert_eq!(
735 assignments,
736 vec![
737 LayerAssignment {
738 node_id: "node-a".into(),
739 start_layer: 0,
740 end_layer: 24,
741 used_vram_gb: 24.0,
742 num_layers: 24,
743 },
744 LayerAssignment {
745 node_id: "node-b".into(),
746 start_layer: 24,
747 end_layer: 33,
748 used_vram_gb: 9.0,
749 num_layers: 9,
750 }
751 ]
752 );
753 }
754
755 #[test]
756 fn reports_insufficient_capacity() {
757 let nodes = vec![NodeResources::new("node-a", 2.0, 64.0, "8.9", None)];
758 let error = assign_layers_sequentially(&nodes, &sample_layers(3, 1.0)).unwrap_err();
759
760 assert!(error.contains("insufficient cluster VRAM"));
761 }
762
763 #[test]
764 fn selects_quantization_mode_from_delivery_ratio() {
765 assert_eq!(select_quantization_mode(0.98), QuantizationMode::None);
766 assert_eq!(select_quantization_mode(0.90), QuantizationMode::Int8);
767 assert_eq!(select_quantization_mode(0.75), QuantizationMode::Int4);
768 }
769
770 #[test]
771 fn placement_plan_summary() {
772 let plan = PlacementPlan::new(
773 vec![
774 LayerAssignment::new("node-a".into(), 0, 24, 24.0),
775 LayerAssignment::new("node-b".into(), 24, 33, 9.0),
776 ],
777 QuantizationMode::None,
778 );
779
780 let summary = plan.summary();
781 assert!(summary.contains("Total layers: 33"));
782 assert!(summary.contains("Full Precision"));
783 }
784
785 #[test]
786 fn rebalance_assignments_detects_capacity() {
787 let mut plan = PlacementPlan::new(
788 vec![LayerAssignment::new("node-a".into(), 0, 24, 24.0)],
789 QuantizationMode::None,
790 );
791
792 assert!(!rebalance_assignments(&ClusterState::default(), &mut plan));
794 }
795
796 #[test]
797 fn runtime_profile_chunks_large_assignments() {
798 let assignments = vec![LayerAssignment::new("node-a".into(), 0, 12, 12.0)];
799 let chunked = chunk_assignments_for_workers(assignments, 5);
800
801 assert_eq!(chunked.len(), 3);
802 assert_eq!(chunked[0].start_layer, 0);
803 assert_eq!(chunked[0].end_layer, 5);
804 assert_eq!(chunked[2].start_layer, 10);
805 assert_eq!(chunked[2].end_layer, 12);
806 }
807
808 #[test]
809 fn runtime_profile_assignment_keeps_trailing_zero_vram_layers() {
810 let nodes = vec![NodeResources::new("node-a", 24.0, 64.0, "8.9", None)];
815 let layers = sample_layers(3, 0.0);
816 let profile = RuntimeProfile {
817 node_resources: NodeResources::new("node-a", 24.0, 64.0, "8.9", None),
818 logical_cores: 4,
819 recommended_workers: 1,
820 acceleration_mode: AccelerationMode::Generic,
821 gpu_backend: crate::host::GpuBackend::Cpu,
822 xdp_supported: false,
823 detection_source: String::from("test"),
824 probe_mode: crate::host::ProbeMode::Fast,
825 };
826
827 let assignments = assign_layers_with_runtime_profile(&nodes, &layers, &profile).unwrap();
828 let total: usize = assignments.iter().map(|a| a.num_layers).sum();
829 assert_eq!(total, 3, "all zero-VRAM layers must still be assigned");
830 }
831
832 #[test]
833 fn fault_tolerance_handles_all_failed_nodes_without_panicking() {
834 let cluster = ClusterState::new();
838 cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
839 cluster.mark_failed("node-a");
840
841 let plan = assign_layers_with_fault_tolerance(&cluster, &sample_layers(4, 1.0)).unwrap();
842 assert_eq!(plan.quantization_mode, QuantizationMode::Int4);
843 }
844
845 #[test]
846 fn planning_tuning_scales_chunk_size_with_workers() {
847 let profile = RuntimeProfile {
848 node_resources: NodeResources::new("node-a", 24.0, 64.0, "8.9", None),
849 logical_cores: 16,
850 recommended_workers: 8,
851 acceleration_mode: AccelerationMode::Gpu,
852 gpu_backend: crate::host::GpuBackend::Cuda,
853 xdp_supported: true,
854 detection_source: String::from("test"),
855 probe_mode: crate::host::ProbeMode::Fast,
856 };
857
858 let tuning = PlanningTuning::from_runtime_profile(&profile, 40);
859 assert!(tuning.max_layers_per_assignment <= 4);
860 }
861
862 #[test]
863 fn test_rebalance_trigger_evaluate_never_selects_overtaxed_as_available() {
864 let cluster = ClusterState::new();
865 cluster.register(NodeResources::new("node-1", 24.0, 64.0, "8.9", None));
866 cluster.register(NodeResources::new("node-2", 24.0, 64.0, "8.9", None));
867
868 cluster.get_metrics_mut("node-1", |m| {
870 m.delivery_ratio = 0.50;
871 m.available_vram_gb = 16.0;
872 });
873
874 cluster.get_metrics_mut("node-2", |m| {
876 m.delivery_ratio = 0.60;
877 m.available_vram_gb = 16.0;
878 });
879
880 let trigger = RebalanceTrigger::default();
881 let plan = PlacementPlan::new(
882 vec![LayerAssignment::new("node-1".into(), 0, 10, 10.0)],
883 QuantizationMode::None,
884 );
885
886 let planner = trigger.evaluate(&cluster, &plan);
887 assert!(
889 planner.is_none(),
890 "overtaxed nodes must never be selected as available rebalance targets"
891 );
892 }
893}