Revert "fix(IPsecUtil): Delete keywords no longer used"
[csit.git] / resources / libraries / python / MLRsearch / limit_handler.py
1 # Copyright (c) 2023 Cisco and/or its affiliates.
2 # Licensed under the Apache License, Version 2.0 (the "License");
3 # you may not use this file except in compliance with the License.
4 # You may obtain a copy of the License at:
5 #
6 #     http://www.apache.org/licenses/LICENSE-2.0
7 #
8 # Unless required by applicable law or agreed to in writing, software
9 # distributed under the License is distributed on an "AS IS" BASIS,
10 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11 # See the License for the specific language governing permissions and
12 # limitations under the License.
13
14 """Module defining LimitHandler class."""
15
16 from dataclasses import dataclass
17 from typing import Callable, Optional
18
19 from .dataclass import secondary_field
20 from .discrete_interval import DiscreteInterval
21 from .discrete_load import DiscreteLoad
22 from .discrete_width import DiscreteWidth
23 from .load_rounding import LoadRounding
24
25
26 @dataclass
27 class LimitHandler:
28     """Encapsulated methods for logic around handling limits.
29
30     In multiple places within MLRsearch code, an intended load value
31     is only useful if it is far enough from possible known values.
32     All such places can be served with the handle method
33     with appropriate arguments.
34     """
35
36     rounding: LoadRounding
37     """Rounding instance to use."""
38     debug: Callable[[str], None]
39     """Injectable logging function."""
40     # The two fields below are derived, extracted from rounding as a shortcut.
41     min_load: DiscreteLoad = secondary_field()
42     """Minimal load, as prescribed by Config."""
43     max_load: DiscreteLoad = secondary_field()
44     """Maximal load, as prescribed by Config."""
45
46     def __post_init__(self) -> None:
47         """Initialize derived quantities."""
48         from_float = DiscreteLoad.float_conver(rounding=self.rounding)
49         self.min_load = from_float(self.rounding.min_load)
50         self.max_load = from_float(self.rounding.max_load)
51
52     def handle(
53         self,
54         load: DiscreteLoad,
55         width: DiscreteWidth,
56         clo: Optional[DiscreteLoad],
57         chi: Optional[DiscreteLoad],
58     ) -> Optional[DiscreteLoad]:
59         """Return new intended load after considering limits and bounds.
60
61         Not only we want to avoid measuring outside minmax interval,
62         we also want to avoid measuring too close to known limits and bounds.
63         We either round or return None, depending on hints from bound loads.
64
65         When rounding away from hard limits, we may end up being
66         too close to an already measured bound.
67         In this case, pick a midpoint between the bound and the limit.
68
69         The last two arguments are just loads (not full measurement results)
70         to allow callers to exclude some load without measuring them.
71         As a convenience, full results are also supported,
72         so that callers do not need to care about None when extracting load.
73
74         :param load: Intended load candidate, initial or from a load selector.
75         :param width: Relative width goal, considered narrow enough for now.
76         :param clo: Intended load of current relevant lower bound.
77         :param chi: Intended load of current relevant upper bound.
78         :type load: DiscreteLoad
79         :type width: DiscreteWidth
80         :type clo: Optional[DiscreteLoad]
81         :type chi: Optional[DiscreteLoad]
82         :return: Adjusted load to measure at, or None if narrow enough already.
83         :rtype: Optional[DiscreteLoad]
84         :raises RuntimeError: If unsupported corner case is detected.
85         """
86         if not load:
87             raise RuntimeError("Got None load to handle.")
88         load = load.rounded_down()
89         min_load, max_load = self.min_load, self.max_load
90         if clo and not clo.is_round:
91             raise RuntimeError(f"Clo {clo} should have been round.")
92         if chi and not chi.is_round:
93             raise RuntimeError(f"Chi {chi} should have been round.")
94         if not clo and not chi:
95             load = self._handle_load_with_excludes(
96                 load, width, min_load, max_load, min_ex=False, max_ex=False
97             )
98             # The "return load" lines are separate from load computation,
99             # so that logging can be added more easily when debugging.
100             return load
101         if chi and not clo:
102             if chi <= min_load:
103                 # Expected when hitting the min load.
104                 return None
105             if load >= chi:
106                 # This can happen when mrr2 forward rate is rounded to mrr2.
107                 return None
108             load = self._handle_load_with_excludes(
109                 load, width, min_load, chi, min_ex=False, max_ex=True
110             )
111             return load
112         if clo and not chi:
113             if clo >= max_load:
114                 raise RuntimeError("Lower load expected.")
115             if load <= clo:
116                 raise RuntimeError("Higher load expected.")
117             load = self._handle_load_with_excludes(
118                 load, width, clo, max_load, min_ex=True, max_ex=False
119             )
120             return load
121         # We have both clo and chi defined.
122         if not clo < load < chi:
123             # Happens when bisect compares with bounded extend.
124             return None
125         load = self._handle_load_with_excludes(
126             load, width, clo, chi, min_ex=True, max_ex=True
127         )
128         return load
129
130     def _handle_load_with_excludes(
131         self,
132         load: DiscreteLoad,
133         width: DiscreteWidth,
134         minimum: DiscreteLoad,
135         maximum: DiscreteLoad,
136         min_ex: bool,
137         max_ex: bool,
138     ) -> Optional[DiscreteLoad]:
139         """Adjust load if too close to limits, respecting exclusions.
140
141         This is a reusable block.
142         Limits may come from previous bounds or from hard load limits.
143         When coming from bounds, rounding to that is not allowed.
144         When coming from hard limits, rounding to the limit value
145         is allowed in general (given by the setting the _ex flag).
146
147         :param load: The candidate intended load before accounting for limits.
148         :param width: Relative width of area around the limits to avoid.
149         :param minimum: The lower limit to round around.
150         :param maximum: The upper limit to round around.
151         :param min_ex: If false, rounding to the minimum is allowed.
152         :param max_ex: If false, rounding to the maximum is allowed.
153         :type load: DiscreteLoad
154         :type width: DiscreteWidth
155         :type minimum: DiscreteLoad
156         :type maximum: DiscreteLoad
157         :type min_ex: bool
158         :type max_ex: bool
159         :returns: Adjusted load value, or None if narrow enough.
160         :rtype: Optional[DiscreteLoad]
161         :raises RuntimeError: If internal inconsistency is detected.
162         """
163         if not minimum <= load <= maximum:
164             raise RuntimeError(
165                 "Internal error: load outside limits:"
166                 f" load {load} min {minimum} max {maximum}"
167             )
168         max_width = maximum - minimum
169         if width >= max_width:
170             self.debug("Warning: Handling called with wide width.")
171             if not min_ex:
172                 self.debug("Minimum not excluded, rounding to it.")
173                 return minimum
174             if not max_ex:
175                 self.debug("Maximum not excluded, rounding to it.")
176                 return maximum
177             self.debug("Both limits excluded, narrow enough.")
178             return None
179         soft_min = minimum + width
180         soft_max = maximum - width
181         if soft_min > soft_max:
182             self.debug("Whole interval is less than two goals.")
183             middle = DiscreteInterval(minimum, maximum).middle(width)
184             soft_min = soft_max = middle
185         if load < soft_min:
186             if min_ex:
187                 self.debug("Min excluded, rounding to soft min.")
188                 return soft_min
189             self.debug("Min not excluded, rounding to minimum.")
190             return minimum
191         if load > soft_max:
192             if max_ex:
193                 self.debug("Max excluded, rounding to soft max.")
194                 return soft_max
195             self.debug("Max not excluded, rounding to maximum.")
196             return maximum
197         # Far enough from all limits, no additional adjustment is needed.
198         return load