@@ -156,6 +156,26 @@ def __init__(
156156 ):
157157 raise ValueError ("max_flow_count must be set for EQUAL_BALANCED placement." )
158158
159+ # Enforce ECMP semantics: When using shortest-path ECMP (equal-balanced with multipath
160+ # over equal-cost edges), the number of flow objects must not be used to control
161+ # distribution. Disallow max_flow_count != 1 in this mode; multiple flows are reserved
162+ # for TE profiles only.
163+ ecmp_selects = {
164+ base .EdgeSelect .ALL_MIN_COST ,
165+ base .EdgeSelect .ALL_MIN_COST_WITH_CAP_REMAINING ,
166+ }
167+ if (
168+ self .flow_placement == FlowPlacement .EQUAL_BALANCED
169+ and self .multipath is True
170+ and self .edge_select in ecmp_selects
171+ and self .max_flow_count is not None
172+ and self .max_flow_count != 1
173+ and not self .static_paths
174+ ):
175+ raise ValueError (
176+ "For SHORTEST_PATHS_ECMP, max_flow_count must be 1. Non-1 is reserved for TE profiles."
177+ )
178+
159179 def deep_copy (self ) -> FlowPolicy :
160180 """Return a deep copy of this policy including flows."""
161181 return copy .deepcopy (self )
@@ -545,6 +565,106 @@ def place_demand(
545565 # Remove from internal registry; nothing to remove from graph for stale ids
546566 self .flows .pop (flow_index , None )
547567
568+ # Fast path for SP-ECMP only when shortest-path restriction is active (max_path_cost_factor==1.0)
569+ # and when using ECMP selection (all-min with capacity awareness allowed) and multipath.
570+ if (
571+ self .multipath
572+ and self .flow_placement == FlowPlacement .EQUAL_BALANCED
573+ and self .edge_select
574+ in {
575+ base .EdgeSelect .ALL_MIN_COST ,
576+ base .EdgeSelect .ALL_MIN_COST_WITH_CAP_REMAINING ,
577+ }
578+ and target_flow_volume is None
579+ and not self .static_paths
580+ ):
581+ # Build a multipath SPF predecessor mapping covering all equal-cost shortest hops
582+ # Count SPF call in metrics
583+ self ._metrics_totals ["spf_calls_total" ] += 1.0
584+ cost , pred = spf .spf (
585+ flow_graph ,
586+ src_node = src_node ,
587+ edge_select = self .edge_select ,
588+ edge_select_func = None ,
589+ multipath = True ,
590+ excluded_edges = None ,
591+ excluded_nodes = None ,
592+ dst_node = dst_node ,
593+ )
594+ # If destination is unreachable under current constraints, do nothing.
595+ if dst_node not in pred :
596+ self .last_metrics = {
597+ "placed" : 0.0 ,
598+ "remaining" : float (volume ),
599+ "iterations" : 1.0 ,
600+ "flows_created" : 0.0 ,
601+ "spf_calls" : 1.0 ,
602+ "reopt_calls" : 0.0 ,
603+ "cutoff_triggered" : 0.0 ,
604+ "initial_request" : float (volume ),
605+ }
606+ return 0.0 , float (volume )
607+
608+ # Enforce maximum path cost constraints, if specified.
609+ dst_cost = cost [dst_node ]
610+ if self .best_path_cost is None or dst_cost < self .best_path_cost :
611+ self .best_path_cost = dst_cost
612+ if self .max_path_cost is not None or self .max_path_cost_factor is not None :
613+ max_path_cost_factor = self .max_path_cost_factor or 1.0
614+ max_path_cost = self .max_path_cost or float ("inf" )
615+ if dst_cost > min (
616+ max_path_cost , self .best_path_cost * max_path_cost_factor
617+ ):
618+ self .last_metrics = {
619+ "placed" : 0.0 ,
620+ "remaining" : float (volume ),
621+ "iterations" : 1.0 ,
622+ "flows_created" : 0.0 ,
623+ "spf_calls" : 1.0 ,
624+ "reopt_calls" : 0.0 ,
625+ "cutoff_triggered" : 0.0 ,
626+ "initial_request" : float (volume ),
627+ }
628+ return 0.0 , float (volume )
629+
630+ # Clear any existing flows for deterministic single-shot placement
631+ self .remove_demand (flow_graph )
632+ self .flows .clear ()
633+ # Place once across the DAG using equal-balanced strategy
634+ from ngraph .algorithms .placement import (
635+ place_flow_on_graph , # local import to avoid cycles
636+ )
637+
638+ fi = self ._build_flow_index (
639+ src_node , dst_node , flow_class , self ._get_next_flow_id ()
640+ )
641+ meta = place_flow_on_graph (
642+ flow_graph = flow_graph ,
643+ src_node = src_node ,
644+ dst_node = dst_node ,
645+ pred = pred ,
646+ flow = volume ,
647+ flow_index = fi ,
648+ flow_placement = FlowPlacement .EQUAL_BALANCED ,
649+ )
650+ # Track this as a single flow for API consistency
651+ path_bundle = PathBundle (src_node , dst_node , pred , dst_cost )
652+ self .flows [fi ] = Flow (path_bundle , fi )
653+ self .flows [fi ].placed_flow = meta .placed_flow
654+
655+ # Metrics snapshot
656+ self .last_metrics = {
657+ "placed" : float (meta .placed_flow ),
658+ "remaining" : float (meta .remaining_flow ),
659+ "iterations" : 1.0 ,
660+ "flows_created" : 1.0 ,
661+ "spf_calls" : 1.0 ,
662+ "reopt_calls" : 0.0 ,
663+ "cutoff_triggered" : 0.0 ,
664+ "initial_request" : float (volume ),
665+ }
666+ return float (meta .placed_flow ), float (meta .remaining_flow )
667+
548668 if not self .flows :
549669 self ._create_flows (flow_graph , src_node , dst_node , flow_class , min_flow )
550670
@@ -690,7 +810,12 @@ def place_demand(
690810 )
691811
692812 # For EQUAL_BALANCED placement, rebalance flows to maintain equal volumes.
693- if self .flow_placement == FlowPlacement .EQUAL_BALANCED and len (self .flows ) > 0 :
813+ # Avoid recursion: do not trigger rebalance within a rebalance call (when target_per_flow is set).
814+ if (
815+ target_flow_volume is None
816+ and self .flow_placement == FlowPlacement .EQUAL_BALANCED
817+ and len (self .flows ) > 0
818+ ):
694819 target_flow_volume_eq = self .placed_demand / float (len (self .flows ))
695820 # If flows are not already near balanced, rebalance them.
696821 if any (
0 commit comments