cortex-cycle-budget 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,87 @@
1
+ """
2
+ cortex_cycle_budget — Static cycle-count estimation for ARM Cortex-M binaries.
3
+
4
+ Supports Cortex-M0, M4, M7, and M33 instruction timing models. Estimates
5
+ cycle consumption for user-defined critical paths through an ELF binary by
6
+ parsing ``arm-none-eabi-objdump`` output.
7
+
8
+ Public API:
9
+ parse_disassembly — disassembly → Function list
10
+ analyze_stages — Function list + config → PathStage list
11
+ walk_call_graph — recursive cycle attribution
12
+ generate_report — full markdown report
13
+ generate_pr_comment — concise PR comment markdown
14
+ generate_cpu_budget_table — budget table only (markdown)
15
+ generate_annotated_disasm — annotated disassembly markdown
16
+ generate_json_metrics — machine-readable metrics dict
17
+ validate_config — raise ConfigError on invalid configs
18
+ EXCEPTION_OVERHEAD — per-variant ISR entry/exit cycles
19
+ TIMING — resolved timing table for the default variant
20
+ BASE_TIMING — variant-agnostic timing table
21
+ VARIANT_OVERRIDES — per-variant overrides applied on top of BASE_TIMING
22
+ SUPPORTED_VARIANTS — frozenset of supported Cortex-M variants
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from cortex_cycle_budget.analysis import analyze_stages, walk_call_graph
28
+ from cortex_cycle_budget.config import validate_config
29
+ from cortex_cycle_budget.models import (
30
+ ConfigError,
31
+ Function,
32
+ Instruction,
33
+ PathStage,
34
+ StageMatchError,
35
+ )
36
+ from cortex_cycle_budget.parser import parse_disassembly
37
+ from cortex_cycle_budget.reports import (
38
+ generate_annotated_disasm,
39
+ generate_cpu_budget_table,
40
+ generate_json_metrics,
41
+ generate_pr_comment,
42
+ generate_report,
43
+ )
44
+ from cortex_cycle_budget.timing_model import (
45
+ BASE_TIMING,
46
+ EXCEPTION_OVERHEAD,
47
+ SUPPORTED_VARIANTS,
48
+ TIMING,
49
+ VARIANT_OVERRIDES,
50
+ classify_instruction,
51
+ get_timing,
52
+ normalize_mnemonic,
53
+ resolve_timing_table,
54
+ )
55
+ from cortex_cycle_budget.tooling import log, run_objdump, run_size
56
+
57
+ __version__ = "0.2.0"
58
+
59
+ __all__ = [
60
+ "BASE_TIMING",
61
+ "EXCEPTION_OVERHEAD",
62
+ "SUPPORTED_VARIANTS",
63
+ "TIMING",
64
+ "VARIANT_OVERRIDES",
65
+ "ConfigError",
66
+ "Function",
67
+ "Instruction",
68
+ "PathStage",
69
+ "StageMatchError",
70
+ "__version__",
71
+ "analyze_stages",
72
+ "classify_instruction",
73
+ "generate_annotated_disasm",
74
+ "generate_cpu_budget_table",
75
+ "generate_json_metrics",
76
+ "generate_pr_comment",
77
+ "generate_report",
78
+ "get_timing",
79
+ "log",
80
+ "normalize_mnemonic",
81
+ "parse_disassembly",
82
+ "resolve_timing_table",
83
+ "run_objdump",
84
+ "run_size",
85
+ "validate_config",
86
+ "walk_call_graph",
87
+ ]
@@ -0,0 +1,10 @@
1
+ """``python -m cortex_cycle_budget`` → single-mode CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from cortex_cycle_budget.cli_single import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
@@ -0,0 +1,203 @@
1
+ """Call-graph walker and stage analysis (config → :class:`PathStage` list)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ from cortex_cycle_budget.models import (
9
+ Function,
10
+ PathStage,
11
+ StageMatchError,
12
+ )
13
+ from cortex_cycle_budget.timing_model import EXCEPTION_OVERHEAD
14
+ from cortex_cycle_budget.tooling import log
15
+
16
+ # ──────────────────────────────────────────────────────────────────────────────
17
+ # Call-graph walker
18
+ # ──────────────────────────────────────────────────────────────────────────────
19
+
20
+ def _resolve_fn(name: str, lookup: dict[str, Function]) -> Function | None:
21
+ fn = lookup.get(name)
22
+ if fn is not None:
23
+ return fn
24
+ for k, f in lookup.items():
25
+ if name in k:
26
+ return f
27
+ return None
28
+
29
+
30
+ def walk_call_graph(
31
+ entry_fn: Function,
32
+ lookup: dict[str, Function],
33
+ ) -> dict[str, dict[str, int]]:
34
+ """Walk the call graph from *entry_fn*, counting each callee per invocation.
35
+
36
+ Returns a mapping of demangled function name → aggregate metrics
37
+ (``min``, ``max``, ``instructions``, ``code_size``, instruction-mix
38
+ counters, and ``call_count``).
39
+ """
40
+ breakdown: dict[str, dict[str, int]] = {}
41
+
42
+ def _walk(fn_name: str, visited_stack: frozenset[str]) -> None:
43
+ fn = _resolve_fn(fn_name, lookup)
44
+ if fn is None:
45
+ return
46
+ canonical = fn.demangled
47
+ if canonical in visited_stack:
48
+ return
49
+ visited_stack = visited_stack | {canonical}
50
+
51
+ if canonical not in breakdown:
52
+ breakdown[canonical] = {
53
+ "min": 0,
54
+ "max": 0,
55
+ "instructions": fn.instruction_count,
56
+ "code_size": fn.code_size,
57
+ "fpu_ops": fn.fpu_ops,
58
+ "load_store": fn.load_store_ops,
59
+ "branch": fn.branch_ops,
60
+ "alu": fn.alu_ops,
61
+ "mul_div": fn.mul_div_ops,
62
+ "call_count": 0,
63
+ }
64
+
65
+ breakdown[canonical]["call_count"] += 1
66
+ breakdown[canonical]["min"] += fn.total_min_cycles
67
+ breakdown[canonical]["max"] += fn.total_max_cycles
68
+
69
+ for callee in fn.calls:
70
+ _walk(callee, visited_stack)
71
+
72
+ _walk(entry_fn.demangled, frozenset())
73
+ return breakdown
74
+
75
+
76
+ # ──────────────────────────────────────────────────────────────────────────────
77
+ # Stage analysis
78
+ # ──────────────────────────────────────────────────────────────────────────────
79
+
80
+ def analyze_stages(
81
+ config: dict[str, Any],
82
+ all_fns: list[Function],
83
+ lookup: dict[str, Function],
84
+ *,
85
+ strict: bool = True,
86
+ ) -> list[PathStage]:
87
+ """Resolve the configured ``path_stages`` against the disassembled binary.
88
+
89
+ Args:
90
+ config: Parsed JSON configuration (must include ``path_stages``).
91
+ all_fns: Output of :func:`parse_disassembly`.
92
+ lookup: ``{fn.demangled: fn}`` mapping over *all_fns*.
93
+ strict: When ``True``, raise :class:`StageMatchError` if any required
94
+ stage matches no functions.
95
+
96
+ Raises:
97
+ StageMatchError: in strict mode, when at least one required stage
98
+ fails to match.
99
+ """
100
+ cortex = config.get("cortex", "m4")
101
+ overhead = EXCEPTION_OVERHEAD.get(cortex, EXCEPTION_OVERHEAD["m4"])
102
+ stages: list[PathStage] = []
103
+ errors: list[str] = []
104
+
105
+ for stage_cfg in config["path_stages"]:
106
+ stage_type = stage_cfg.get("type")
107
+ required = stage_cfg.get("required", True)
108
+
109
+ if stage_type == "exception_entry":
110
+ stages.append(PathStage(
111
+ label=stage_cfg["label"],
112
+ min_cycles=overhead["entry_min"],
113
+ max_cycles=overhead["entry_max"],
114
+ is_overhead=True,
115
+ ))
116
+ continue
117
+
118
+ if stage_type == "exception_exit":
119
+ stages.append(PathStage(
120
+ label=stage_cfg["label"],
121
+ min_cycles=overhead["exit_min"],
122
+ max_cycles=overhead["exit_max"],
123
+ is_overhead=True,
124
+ ))
125
+ continue
126
+
127
+ patterns = stage_cfg.get("patterns", [])
128
+ entry_pattern = stage_cfg.get("entry")
129
+
130
+ matched: dict[str, Function] = {}
131
+ for fn in all_fns:
132
+ for pat in patterns:
133
+ try:
134
+ if re.search(pat, fn.demangled):
135
+ matched[fn.demangled] = fn
136
+ break
137
+ except re.error as exc:
138
+ msg = (
139
+ f"Stage '{stage_cfg['label']}': invalid regex "
140
+ f"pattern '{pat}': {exc}"
141
+ )
142
+ errors.append(msg)
143
+ log(f" ERROR: {msg}")
144
+ break
145
+
146
+ stage = PathStage(label=stage_cfg["label"])
147
+
148
+ if entry_pattern:
149
+ entry_fn = None
150
+ for name, fn in matched.items():
151
+ if re.search(entry_pattern, name):
152
+ entry_fn = fn
153
+ break
154
+ if entry_fn is not None:
155
+ breakdown = walk_call_graph(entry_fn, lookup)
156
+ stage.min_cycles = sum(v["min"] for v in breakdown.values())
157
+ stage.max_cycles = sum(v["max"] for v in breakdown.values())
158
+ stage.instruction_count = sum(v["instructions"] for v in breakdown.values())
159
+ stage.code_size = sum(v["code_size"] for v in breakdown.values())
160
+ stage.fpu_ops = sum(v["fpu_ops"] for v in breakdown.values())
161
+ stage.functions = sorted(breakdown.keys())
162
+ else:
163
+ msg = (
164
+ f"Stage '{stage_cfg['label']}': entry function matching "
165
+ f"'{entry_pattern}' not found in binary"
166
+ )
167
+ if required:
168
+ errors.append(msg)
169
+ log(f" ERROR: {msg}")
170
+ else:
171
+ if not matched and required:
172
+ msg = (
173
+ f"Stage '{stage_cfg['label']}': no functions matched any "
174
+ f"of the {len(patterns)} configured patterns"
175
+ )
176
+ errors.append(msg)
177
+ log(f" ERROR: {msg}")
178
+ for fn in matched.values():
179
+ stage.min_cycles += fn.total_min_cycles
180
+ stage.max_cycles += fn.total_max_cycles
181
+ stage.instruction_count += fn.instruction_count
182
+ stage.code_size += fn.code_size
183
+ stage.fpu_ops += fn.fpu_ops
184
+ stage.functions = sorted(matched.keys())
185
+
186
+ stages.append(stage)
187
+
188
+ if strict and errors:
189
+ log("")
190
+ log("Stage matching failed — the configuration does not align with the binary.")
191
+ log("Unmatched stages:")
192
+ for e in errors:
193
+ log(f" • {e}")
194
+ log("")
195
+ log("Ensure the path_stages patterns in your config JSON match the ")
196
+ log("demangled symbol names in the ELF. You can inspect them with:")
197
+ log(" arm-none-eabi-objdump -d -C <elf> | grep '^[0-9a-f].*<.*>:$'")
198
+ raise StageMatchError(
199
+ f"{len(errors)} path stage(s) matched no functions in the binary. "
200
+ f"See stderr for details."
201
+ )
202
+
203
+ return stages
@@ -0,0 +1,331 @@
1
+ """Multi-mode CLI: ``cortex-cycle-budget-multi``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import subprocess
9
+ import sys
10
+ from typing import Any
11
+
12
+ from cortex_cycle_budget.analysis import analyze_stages
13
+ from cortex_cycle_budget.config import validate_config
14
+ from cortex_cycle_budget.models import ConfigError, StageMatchError
15
+ from cortex_cycle_budget.parser import parse_disassembly
16
+ from cortex_cycle_budget.reports import (
17
+ generate_annotated_disasm,
18
+ generate_cpu_budget_table,
19
+ generate_json_metrics,
20
+ generate_report,
21
+ )
22
+ from cortex_cycle_budget.timing_model import (
23
+ EXCEPTION_OVERHEAD,
24
+ SUPPORTED_VARIANTS,
25
+ resolve_timing_table,
26
+ )
27
+ from cortex_cycle_budget.tooling import log, run_objdump, run_size
28
+
29
+ PR_COMMENT_MARKER = "<!-- cortex-cycle-budget-comment -->"
30
+
31
+
32
+ def build_parser() -> argparse.ArgumentParser:
33
+ parser = argparse.ArgumentParser(
34
+ prog="cortex-cycle-budget-multi",
35
+ description="Multi-configuration ARM Cortex-M cycle analysis.",
36
+ )
37
+ parser.add_argument(
38
+ "analyses_config",
39
+ help="JSON file containing an array of analysis entries",
40
+ )
41
+ parser.add_argument("--target", required=True, help="Target board identifier")
42
+ parser.add_argument("--build-config", required=True, help="Build configuration")
43
+ parser.add_argument("--clock-mhz", required=True, type=int, help="CPU clock in MHz")
44
+ parser.add_argument(
45
+ "--cortex",
46
+ default="m4",
47
+ choices=sorted(SUPPORTED_VARIANTS),
48
+ help="Cortex-M variant (default: m4)",
49
+ )
50
+ parser.add_argument("--objdump", default="arm-none-eabi-objdump")
51
+ parser.add_argument("--size-tool", default="arm-none-eabi-size")
52
+ parser.add_argument("--output-dir", default="cycle-analysis-combined")
53
+ parser.add_argument(
54
+ "--no-strict",
55
+ action="store_true",
56
+ help="Do not fail when path stages match no functions",
57
+ )
58
+ parser.add_argument(
59
+ "--fail-over-max-cycles",
60
+ type=int,
61
+ default=None,
62
+ help="Exit with non-zero status if any mode's max cycles exceeds this threshold.",
63
+ )
64
+ return parser
65
+
66
+
67
+ def _run_single_analysis(
68
+ entry: dict[str, Any],
69
+ shared_args: argparse.Namespace,
70
+ ) -> tuple[str, str, int]:
71
+ """Run analysis for one entry. Returns (cpu_budget_md, full_report_md, max_cycles)."""
72
+ label = entry["label"]
73
+ elf_path = entry["elf_path"]
74
+ config_path = entry["config_path"]
75
+ output_dir = entry["output_dir"]
76
+
77
+ log(f"\n--- Analyzing: {label} ---")
78
+
79
+ if not os.path.isfile(elf_path):
80
+ log(f"ERROR: ELF not found: {elf_path}")
81
+ sys.exit(1)
82
+
83
+ if not os.path.isfile(config_path):
84
+ log(f"ERROR: Config not found: {config_path}")
85
+ sys.exit(1)
86
+
87
+ try:
88
+ with open(config_path) as f:
89
+ config = json.load(f)
90
+ except json.JSONDecodeError as exc:
91
+ log(f"ERROR: Failed to parse config JSON '{config_path}': {exc}")
92
+ sys.exit(1)
93
+
94
+ config["target"] = shared_args.target
95
+ config["build_config"] = shared_args.build_config
96
+ config["clock_mhz"] = shared_args.clock_mhz
97
+ config["cortex"] = shared_args.cortex
98
+
99
+ try:
100
+ validate_config(config)
101
+ except ConfigError as exc:
102
+ log(f"ERROR: {exc}")
103
+ sys.exit(1)
104
+
105
+ os.makedirs(output_dir, exist_ok=True)
106
+
107
+ try:
108
+ disasm = run_objdump(elf_path, shared_args.objdump)
109
+ except FileNotFoundError:
110
+ log(f"ERROR: objdump tool not found: '{shared_args.objdump}'")
111
+ log("Install the ARM toolchain or pass --objdump with the correct path.")
112
+ sys.exit(1)
113
+ except subprocess.CalledProcessError as exc:
114
+ log(f"ERROR: objdump failed (exit {exc.returncode}): {exc.stderr}")
115
+ sys.exit(1)
116
+
117
+ with open(os.path.join(output_dir, "full_disassembly.txt"), "w") as f:
118
+ f.write(disasm)
119
+
120
+ size_out = run_size(elf_path, shared_args.size_tool)
121
+ if size_out:
122
+ with open(os.path.join(output_dir, "size_report.txt"), "w") as f:
123
+ f.write(size_out)
124
+
125
+ timing = resolve_timing_table(shared_args.cortex)
126
+ all_fns = parse_disassembly(disasm, timing=timing)
127
+ lookup = {fn.demangled: fn for fn in all_fns}
128
+ log(f"Parsed {len(all_fns)} functions")
129
+
130
+ if len(all_fns) == 0:
131
+ log("ERROR: No functions found in the disassembly.")
132
+ log("The ELF may be stripped, empty, or not an ARM binary.")
133
+ sys.exit(1)
134
+
135
+ try:
136
+ stages = analyze_stages(
137
+ config, all_fns, lookup, strict=not getattr(shared_args, "no_strict", False),
138
+ )
139
+ except StageMatchError as exc:
140
+ log(f"ERROR: {exc}")
141
+ sys.exit(1)
142
+
143
+ report = generate_report(config, stages, all_fns, lookup)
144
+ with open(os.path.join(output_dir, "cycle_estimation_report.md"), "w") as f:
145
+ f.write(report)
146
+
147
+ budget = generate_cpu_budget_table(config, stages)
148
+ with open(os.path.join(output_dir, "cpu_budget.md"), "w") as f:
149
+ f.write(budget)
150
+
151
+ annotated = generate_annotated_disasm(config, all_fns, stages)
152
+ with open(os.path.join(output_dir, "annotated_disassembly.md"), "w") as f:
153
+ f.write(annotated)
154
+
155
+ metrics = generate_json_metrics(config, stages)
156
+ with open(os.path.join(output_dir, "cycle_metrics.json"), "w") as f:
157
+ json.dump(metrics, f, indent=2)
158
+
159
+ total_min = sum(s.min_cycles for s in stages)
160
+ total_max = sum(s.max_cycles for s in stages)
161
+ log(f"{label}: {total_min}–{total_max} cycles, {len(stages)} stages")
162
+
163
+ return budget, report, total_max
164
+
165
+
166
+ def _build_collapsible_sections(
167
+ analyses: list[dict[str, Any]],
168
+ budgets: list[str],
169
+ reports: list[str],
170
+ target: str,
171
+ ) -> list[str]:
172
+ lines: list[str] = []
173
+ for i, (entry, budget, report) in enumerate(zip(analyses, budgets, reports, strict=False)):
174
+ label = entry["label"]
175
+ lines.extend((
176
+ f"### {label} | {target}",
177
+ "",
178
+ "**CPU Utilization Budget**",
179
+ "",
180
+ budget,
181
+ "",
182
+ "<details>",
183
+ "<summary>Click to expand full details</summary>",
184
+ "",
185
+ report,
186
+ "",
187
+ "</details>",
188
+ "",
189
+ ))
190
+ if i < len(analyses) - 1:
191
+ lines.extend(("---", ""))
192
+ return lines
193
+
194
+
195
+ def generate_combined_pr_comment(
196
+ analyses: list[dict[str, Any]],
197
+ budgets: list[str],
198
+ reports: list[str],
199
+ target: str,
200
+ ) -> str:
201
+ lines = [PR_COMMENT_MARKER, "## 🏎️ Cycle Analysis Results", ""]
202
+ lines.extend(_build_collapsible_sections(analyses, budgets, reports, target))
203
+ return "\n".join(lines)
204
+
205
+
206
+ def generate_combined_step_summary(
207
+ analyses: list[dict[str, Any]],
208
+ budgets: list[str],
209
+ reports: list[str],
210
+ target: str,
211
+ ) -> str:
212
+ lines = ["## 🏎️ Cycle Analysis Results", ""]
213
+ lines.extend(_build_collapsible_sections(analyses, budgets, reports, target))
214
+ return "\n".join(lines)
215
+
216
+
217
+ def generate_combined_metrics(analyses: list[dict[str, Any]]) -> dict[str, Any]:
218
+ modes: list[dict[str, Any]] = []
219
+ overall_max = 0
220
+
221
+ for entry in analyses:
222
+ metrics_path = os.path.join(entry["output_dir"], "cycle_metrics.json")
223
+ if not os.path.isfile(metrics_path):
224
+ continue
225
+ with open(metrics_path) as f:
226
+ m = json.load(f)
227
+ m["mode_label"] = entry["label"]
228
+ modes.append(m)
229
+ overall_max = max(overall_max, m["summary"]["estimated_max_cycles"])
230
+
231
+ return {"modes": modes, "overall_max_cycles": overall_max}
232
+
233
+
234
+ def main(argv: list[str] | None = None) -> int:
235
+ args = build_parser().parse_args(argv)
236
+
237
+ if not os.path.isfile(args.analyses_config):
238
+ log(f"ERROR: Analyses config not found: {args.analyses_config}")
239
+ return 1
240
+
241
+ if args.clock_mhz <= 0:
242
+ log(f"ERROR: --clock-mhz must be a positive integer, got {args.clock_mhz}")
243
+ return 1
244
+
245
+ if args.cortex not in EXCEPTION_OVERHEAD:
246
+ valid = ", ".join(sorted(EXCEPTION_OVERHEAD))
247
+ log(f"ERROR: --cortex '{args.cortex}' is not supported. Valid values: {valid}")
248
+ return 1
249
+
250
+ try:
251
+ with open(args.analyses_config) as f:
252
+ analyses = json.load(f)
253
+ except json.JSONDecodeError as exc:
254
+ log(f"ERROR: Failed to parse analyses config JSON: {exc}")
255
+ return 1
256
+
257
+ if not isinstance(analyses, list) or len(analyses) == 0:
258
+ log("ERROR: Analyses config must be a non-empty JSON array")
259
+ return 1
260
+
261
+ os.makedirs(args.output_dir, exist_ok=True)
262
+
263
+ budgets: list[str] = []
264
+ reports: list[str] = []
265
+ per_mode_max: list[tuple[str, int]] = []
266
+
267
+ for entry in analyses:
268
+ budget, report, max_cycles = _run_single_analysis(entry, args)
269
+ budgets.append(budget)
270
+ reports.append(report)
271
+ per_mode_max.append((entry["label"], max_cycles))
272
+
273
+ comment = generate_combined_pr_comment(analyses, budgets, reports, args.target)
274
+ comment_path = os.path.join(args.output_dir, "combined_pr_comment.md")
275
+ with open(comment_path, "w") as f:
276
+ f.write(comment)
277
+ log(f"\nCombined PR comment: {comment_path}")
278
+
279
+ summary = generate_combined_step_summary(analyses, budgets, reports, args.target)
280
+ summary_path = os.path.join(args.output_dir, "combined_step_summary.md")
281
+ with open(summary_path, "w") as f:
282
+ f.write(summary)
283
+ log(f"Combined step summary: {summary_path}")
284
+
285
+ combined_metrics = generate_combined_metrics(analyses)
286
+ metrics_path = os.path.join(args.output_dir, "combined_metrics.json")
287
+ with open(metrics_path, "w") as f:
288
+ json.dump(combined_metrics, f, indent=2)
289
+ log(f"Combined metrics: {metrics_path}")
290
+
291
+ clock = args.clock_mhz
292
+ avail_20k = clock * 1_000_000 // 20_000
293
+ print(f"\n{'=' * 65}")
294
+ print(f" Multi-Config Cycle Analysis ({args.target} {args.build_config}, "
295
+ f"Cortex-{args.cortex.upper()})")
296
+ print(f"{'=' * 65}")
297
+ for entry in analyses:
298
+ mp = os.path.join(entry["output_dir"], "cycle_metrics.json")
299
+ if not os.path.isfile(mp):
300
+ continue
301
+ with open(mp) as f:
302
+ m = json.load(f)
303
+ total_min = m["summary"]["estimated_min_cycles"]
304
+ total_max = m["summary"]["estimated_max_cycles"]
305
+ pct = total_max / avail_20k * 100 if avail_20k > 0 else 0.0
306
+ print(
307
+ f" {entry['label']:<22s} {total_min:>4d}–{total_max:<4d} cycles "
308
+ f"({pct:.1f}% max @ 20 kHz)"
309
+ )
310
+ print(f"{'=' * 65}")
311
+
312
+ if args.fail_over_max_cycles is not None:
313
+ violators = [
314
+ (label, mx)
315
+ for label, mx in per_mode_max
316
+ if mx > args.fail_over_max_cycles
317
+ ]
318
+ if violators:
319
+ log(
320
+ f"ERROR: {len(violators)} mode(s) exceed the cycle budget "
321
+ f"({args.fail_over_max_cycles}):"
322
+ )
323
+ for label, mx in violators:
324
+ log(f" • {label}: {mx} cycles")
325
+ return 2
326
+
327
+ return 0
328
+
329
+
330
+ if __name__ == "__main__":
331
+ sys.exit(main())