|
1 | 1 | from dataclasses import dataclass, field |
2 | | -from random import random |
| 2 | +from typing import Any, Dict, List, Literal |
| 3 | +from random import random, sample |
3 | 4 |
|
4 | 5 |
|
5 | | -@dataclass(slots=True) |
| 6 | +@dataclass |
| 7 | +class FailureCondition: |
| 8 | + """ |
| 9 | + A single condition for matching an entity's attribute with an operator and value. |
| 10 | +
|
| 11 | + Example usage: |
| 12 | +
|
| 13 | + .. code-block:: yaml |
| 14 | +
|
| 15 | + conditions: |
| 16 | + - attr: "capacity" |
| 17 | + operator: "<" |
| 18 | + value: 100 |
| 19 | +
|
| 20 | + :param attr: |
| 21 | + The name of the attribute to inspect, e.g. "type", "capacity". |
| 22 | + :param operator: |
| 23 | + The comparison operator: "==", "!=", "<", "<=", ">", ">=". |
| 24 | + :param value: |
| 25 | + The value to compare against, e.g. "node", 100, True, etc. |
| 26 | + """ |
| 27 | + |
| 28 | + attr: str # e.g. "type", "capacity", "region" |
| 29 | + operator: str # "==", "!=", "<", "<=", ">", ">=" |
| 30 | + value: Any # e.g. "node", 100, "east_coast" |
| 31 | + |
| 32 | + |
| 33 | +@dataclass |
| 34 | +class FailureRule: |
| 35 | + """ |
| 36 | + A single rule defining how to match entities and then select them for failure. |
| 37 | +
|
| 38 | + - conditions: list of conditions |
| 39 | + - logic: how to combine conditions ("and", "or", "any") |
| 40 | + - rule_type: how to pick from matched entities ("random", "choice", "all") |
| 41 | + - probability: used by "random" (a float in [0,1]) |
| 42 | + - count: used by "choice" (e.g. pick 2) |
| 43 | +
|
| 44 | + :param conditions: |
| 45 | + A list of :class:`FailureCondition` to filter matching entities. |
| 46 | + :param logic: |
| 47 | + How to combine the conditions for matching: "and", "or", or "any". |
| 48 | + - "and": all conditions must be true |
| 49 | + - "or": at least one condition is true |
| 50 | + - "any": skip condition checks; everything is matched |
| 51 | + :param rule_type: |
| 52 | + The selection strategy. One of: |
| 53 | + - "random": pick each matched entity with `probability` |
| 54 | + - "choice": pick exactly `count` from matched |
| 55 | + - "all": pick all matched |
| 56 | + :param probability: |
| 57 | + Probability of selecting any matched entity (used only if rule_type="random"). |
| 58 | + :param count: |
| 59 | + Number of matched entities to pick (used only if rule_type="choice"). |
| 60 | + """ |
| 61 | + |
| 62 | + conditions: List[FailureCondition] = field(default_factory=list) |
| 63 | + logic: Literal["and", "or", "any"] = "and" |
| 64 | + rule_type: Literal["random", "choice", "all"] = "all" |
| 65 | + probability: float = 1.0 |
| 66 | + count: int = 1 |
| 67 | + |
| 68 | + |
| 69 | +@dataclass |
6 | 70 | class FailurePolicy: |
7 | 71 | """ |
8 | | - Mapping from element tag to failure probability. |
| 72 | + A container for multiple FailureRules and arbitrary metadata in `attrs`. |
| 73 | +
|
| 74 | + The method :meth:`apply_failures` merges nodes and links into a single |
| 75 | + dictionary (by their unique ID), and then applies each rule in turn, |
| 76 | + building a union of all failed entities. |
| 77 | +
|
| 78 | + :param rules: |
| 79 | + A list of :class:`FailureRule` objects to apply. |
| 80 | + :param attrs: |
| 81 | + A dictionary for storing policy-wide metadata (e.g. "name", "description"). |
9 | 82 | """ |
10 | 83 |
|
11 | | - failure_probabilities: dict[str, float] = field(default_factory=dict) |
12 | | - distribution: str = "uniform" |
| 84 | + rules: List[FailureRule] = field(default_factory=list) |
| 85 | + attrs: Dict[str, Any] = field(default_factory=dict) |
13 | 86 |
|
14 | | - def test_failure(self, tag: str) -> bool: |
15 | | - if self.distribution == "uniform": |
16 | | - return random() < self.failure_probabilities.get(tag, 0) |
| 87 | + def apply_failures( |
| 88 | + self, nodes: Dict[str, Dict[str, Any]], links: Dict[str, Dict[str, Any]] |
| 89 | + ) -> List[str]: |
| 90 | + """ |
| 91 | + Identify which entities (nodes or links) fail according to the |
| 92 | + defined rules. |
| 93 | +
|
| 94 | + :param nodes: |
| 95 | + A mapping of node_name -> node.attrs, where node.attrs has at least |
| 96 | + a "type" = "node". |
| 97 | + :param links: |
| 98 | + A mapping of link_id -> link.attrs, where link.attrs has at least |
| 99 | + a "type" = "link". |
| 100 | + :returns: |
| 101 | + A list of failed entity IDs. For nodes, that ID is typically the |
| 102 | + node's name. For links, it's the link's ID. |
| 103 | + """ |
| 104 | + # Merge nodes and links into a single map of entity_id -> entity_attrs |
| 105 | + # e.g. { "SEA": { "type": "node", ...}, "SEA-DEN-xxx": { "type": "link", ...} } |
| 106 | + all_entities = {**nodes, **links} |
| 107 | + |
| 108 | + failed_entities = set() |
| 109 | + |
| 110 | + # Evaluate each rule to find matched entities and union them |
| 111 | + for rule in self.rules: |
| 112 | + matched = self._match_entities(all_entities, rule.conditions, rule.logic) |
| 113 | + selected = self._select_entities(matched, all_entities, rule) |
| 114 | + failed_entities.update(selected) |
| 115 | + |
| 116 | + return list(failed_entities) |
| 117 | + |
| 118 | + def _match_entities( |
| 119 | + self, |
| 120 | + all_entities: Dict[str, Dict[str, Any]], |
| 121 | + conditions: List[FailureCondition], |
| 122 | + logic: str, |
| 123 | + ) -> List[str]: |
| 124 | + """ |
| 125 | + Find which entities (by ID) satisfy the given list of conditions |
| 126 | + combined by 'and'/'or' logic (or 'any' to skip checks). |
| 127 | +
|
| 128 | + :param all_entities: |
| 129 | + Mapping of entity_id -> attribute dict. |
| 130 | + :param conditions: |
| 131 | + List of :class:`FailureCondition` to apply. |
| 132 | + :param logic: |
| 133 | + "and", "or", or "any". |
| 134 | + :returns: |
| 135 | + A list of entity IDs that match. |
| 136 | + """ |
| 137 | + matched = [] |
| 138 | + for entity_id, attr_dict in all_entities.items(): |
| 139 | + if self._evaluate_conditions(attr_dict, conditions, logic): |
| 140 | + matched.append(entity_id) |
| 141 | + return matched |
| 142 | + |
| 143 | + @staticmethod |
| 144 | + def _evaluate_conditions( |
| 145 | + entity_attrs: Dict[str, Any], conditions: List[FailureCondition], logic: str |
| 146 | + ) -> bool: |
| 147 | + """ |
| 148 | + Check if the given entity (via entity_attrs) meets all/any of the conditions. |
| 149 | +
|
| 150 | + :param entity_attrs: |
| 151 | + The dictionary of attributes for a single entity (node or link). |
| 152 | + :param conditions: |
| 153 | + A list of conditions to evaluate. |
| 154 | + :param logic: |
| 155 | + "and" -> all must be true |
| 156 | + "or" -> at least one true |
| 157 | + "any" -> skip condition checks (always true) |
| 158 | + :returns: |
| 159 | + True if conditions pass for the specified logic, else False. |
| 160 | + """ |
| 161 | + if logic == "any": |
| 162 | + return True # means "select everything" |
| 163 | + if not conditions: |
| 164 | + return False # no conditions => no match, unless logic='any' |
| 165 | + |
| 166 | + results = [] |
| 167 | + for cond in conditions: |
| 168 | + results.append(_evaluate_condition(entity_attrs, cond)) |
| 169 | + |
| 170 | + if logic == "and": |
| 171 | + return all(results) |
| 172 | + elif logic == "or": |
| 173 | + return any(results) |
17 | 174 | else: |
18 | | - raise ValueError(f"Unsupported distribution: {self.distribution}") |
| 175 | + raise ValueError(f"Unsupported logic: {logic}") |
| 176 | + |
| 177 | + @staticmethod |
| 178 | + def _select_entities( |
| 179 | + entity_ids: List[str], |
| 180 | + all_entities: Dict[str, Dict[str, Any]], |
| 181 | + rule: FailureRule, |
| 182 | + ) -> List[str]: |
| 183 | + """ |
| 184 | + Select which entity IDs will fail from the matched set, based on rule_type. |
| 185 | +
|
| 186 | + :param entity_ids: |
| 187 | + IDs that matched the rule's conditions. |
| 188 | + :param all_entities: |
| 189 | + The full entity dictionary (not strictly needed for some rule_types). |
| 190 | + :param rule: |
| 191 | + The FailureRule specifying how to pick the final subset. |
| 192 | + :returns: |
| 193 | + The final list of entity IDs that fail from this rule. |
| 194 | + """ |
| 195 | + if rule.rule_type == "random": |
| 196 | + return [e for e in entity_ids if random() < rule.probability] |
| 197 | + elif rule.rule_type == "choice": |
| 198 | + count = min(rule.count, len(entity_ids)) |
| 199 | + # Use sorted(...) to ensure consistent picks when testing |
| 200 | + return sample(sorted(entity_ids), k=count) |
| 201 | + elif rule.rule_type == "all": |
| 202 | + return entity_ids |
| 203 | + else: |
| 204 | + raise ValueError(f"Unsupported rule_type: {rule.rule_type}") |
| 205 | + |
| 206 | + |
| 207 | +def _evaluate_condition(entity: Dict[str, Any], cond: FailureCondition) -> bool: |
| 208 | + """ |
| 209 | + Evaluate one condition (attr, operator, value) against an entity's attrs. |
| 210 | +
|
| 211 | + :param entity: |
| 212 | + The entity's attribute dictionary (node.attrs or link.attrs). |
| 213 | + :param cond: |
| 214 | + A single :class:`FailureCondition` specifying 'attr', 'operator', 'value'. |
| 215 | + :returns: |
| 216 | + True if the condition passes, else False. |
| 217 | + :raises ValueError: |
| 218 | + If the condition's operator is not recognized. |
| 219 | + """ |
| 220 | + derived_value = entity.get(cond.attr, None) |
| 221 | + op = cond.operator |
| 222 | + if op == "==": |
| 223 | + return derived_value == cond.value |
| 224 | + elif op == "!=": |
| 225 | + return derived_value != cond.value |
| 226 | + elif op == "<": |
| 227 | + return derived_value < cond.value |
| 228 | + elif op == "<=": |
| 229 | + return derived_value <= cond.value |
| 230 | + elif op == ">": |
| 231 | + return derived_value > cond.value |
| 232 | + elif op == ">=": |
| 233 | + return derived_value >= cond.value |
| 234 | + else: |
| 235 | + raise ValueError(f"Unsupported operator: {op}") |
0 commit comments