1use crate::accelerator::ExecutionBackend;
9use crate::cluster::ClusterState;
10use crate::cluster::NodeStatus;
11use crate::host::{AccelerationMode, RuntimeProfile};
12use crate::protocol::NodeResources;
13
14pub const DELIVERY_RATIO_INT8_THRESHOLD: f32 = 0.95;
16pub const DELIVERY_RATIO_INT4_THRESHOLD: f32 = 0.80;
17
18#[derive(Clone, Debug, PartialEq)]
20pub struct LayerSpec {
21 pub index: usize,
23 pub vram_gb: f32,
25 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#[derive(Clone, Debug, PartialEq)]
41pub struct LayerAssignment {
42 pub node_id: String,
44 pub start_layer: usize,
46 pub end_layer: usize,
48 pub used_vram_gb: f32,
50 pub num_layers: usize,
52}
53
54impl LayerAssignment {
55 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 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
78pub enum QuantizationMode {
79 #[default]
81 None,
82 Int8,
84 Int4,
86}
87
88#[derive(Clone, Debug, Default)]
90pub struct PlacementPlan {
91 pub assignments: Vec<LayerAssignment>,
93 pub quantization_mode: QuantizationMode,
95 pub total_layers: usize,
97 pub participating_nodes: Vec<String>,
99}
100
101impl PlacementPlan {
102 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
154pub struct PlanningTuning {
155 pub max_layers_per_assignment: usize,
157}
158
159impl PlanningTuning {
160 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
183pub 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
194pub 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 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 remaining_capacity -= layer.vram_gb;
245 current_vram += layer.vram_gb;
246 }
247
248 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
263pub 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
302fn 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 let mut chunk_start = 0usize;
329 let mut chunk_vram = 0.0f32;
330
331 for (i, layer) in layers.iter().enumerate() {
332 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 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 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
393pub 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
403fn 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
428pub 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 let assignments = assign_layers_sequentially(&nodes, layers)?;
441
442 let total_delivery_ratio = calculate_average_delivery_ratio(cluster);
443
444 let quantization_mode = select_quantization_mode(total_delivery_ratio);
446
447 Ok(PlacementPlan::new(assignments, quantization_mode))
448}
449
450pub 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 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 let quantization_mode = select_quantization_mode(total_delivery_ratio);
473
474 Ok(PlacementPlan::new(assignments, quantization_mode))
475}
476
477pub fn rebalance_assignments(cluster: &ClusterState, plan: &mut PlacementPlan) -> bool {
479 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 continue;
492 }
493
494 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 potential_layers > 2 {
503 needs_rebalance = true;
504 break;
505 }
506 }
507 }
508 }
509
510 needs_rebalance
511}
512
513#[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 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 metrics.set_streaming_layers(start_layer, end_layer);
531
532 Some(metrics)
533}
534
535pub fn calculate_cluster_health(cluster: &ClusterState) -> (f32, usize, Vec<String>) {
537 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 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#[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; Self {
589 source_node: source,
590 target_node: target,
591 layers,
592 est_migration_time_ms: est_time,
593 }
594 }
595
596 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
615pub 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, max_p95_latency_ms: 25.0,
626 }
627 }
628}
629
630impl RebalanceTrigger {
631 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 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 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 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 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 cluster.get_metrics_mut("node-1", |m| {
857 m.delivery_ratio = 0.50;
858 m.available_vram_gb = 16.0;
859 });
860
861 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 assert!(
876 planner.is_none(),
877 "overtaxed nodes must never be selected as available rebalance targets"
878 );
879 }
880}