| 1 | """Environment boundary sampling for measured runs.
|
| 2 |
|
| 3 | Sample the host before and after a measurement window, then ask
|
| 4 | ``environment_suspect`` whether the window looks clean. All fields are
|
| 5 | nullable: a signal the host cannot provide is None, never a guess (ADR-0002).
|
| 6 | Suspect rows are flagged, never deleted.
|
| 7 | """
|
| 8 |
|
| 9 | from __future__ import annotations
|
| 10 |
|
| 11 | import os
|
| 12 | import platform
|
| 13 | import re
|
| 14 | import subprocess
|
| 15 | from pathlib import Path
|
| 16 | from typing import Any
|
| 17 |
|
| 18 | from oakbench.runner import _command_text, _text_file, cpu_governor
|
| 19 |
|
| 20 | LINUX_MEMINFO = Path("/proc/meminfo")
|
| 21 | LINUX_THERMAL = Path("/sys/class/thermal")
|
| 22 |
|
| 23 |
|
| 24 | def _loadavg() -> list[float] | None:
|
| 25 | try:
|
| 26 | return [float(value) for value in os.getloadavg()]
|
| 27 | except OSError:
|
| 28 | return None
|
| 29 |
|
| 30 |
|
| 31 | def parse_meminfo_available_bytes(text: str) -> int | None:
|
| 32 | for line in text.splitlines():
|
| 33 | if line.startswith("MemAvailable:"):
|
| 34 | parts = line.split()
|
| 35 | if len(parts) >= 2 and parts[1].isdigit():
|
| 36 | return int(parts[1]) * 1024
|
| 37 | return None
|
| 38 |
|
| 39 |
|
| 40 | def parse_vm_stat_free_bytes(text: str) -> int | None:
|
| 41 | page_size_match = re.search(r"page size of (\d+) bytes", text)
|
| 42 | free_match = re.search(r"Pages free:\s+(\d+)", text)
|
| 43 | if not page_size_match or not free_match:
|
| 44 | return None
|
| 45 | return int(free_match.group(1)) * int(page_size_match.group(1))
|
| 46 |
|
| 47 |
|
| 48 | def parse_pmset_cpu_speed_limit(text: str) -> int | None:
|
| 49 | match = re.search(r"CPU_Speed_Limit\s*=\s*(\d+)", text)
|
| 50 | return int(match.group(1)) if match else None
|
| 51 |
|
| 52 |
|
| 53 | def _mem_available_bytes(system: str) -> int | None:
|
| 54 | if system == "Linux":
|
| 55 | text = _text_file(LINUX_MEMINFO)
|
| 56 | return parse_meminfo_available_bytes(text) if text else None
|
| 57 | if system == "Darwin":
|
| 58 | text = _command_text(["/usr/bin/vm_stat"])
|
| 59 | return parse_vm_stat_free_bytes(text) if text else None
|
| 60 | return None
|
| 61 |
|
| 62 |
|
| 63 | def _cpu_speed_limit_pct(system: str) -> int | None:
|
| 64 | if system != "Darwin":
|
| 65 | return None
|
| 66 | text = _command_text(["pmset", "-g", "therm"])
|
| 67 | return parse_pmset_cpu_speed_limit(text) if text else None
|
| 68 |
|
| 69 |
|
| 70 | def _thermal_zone_max_c(system: str) -> float | None:
|
| 71 | if system != "Linux":
|
| 72 | return None
|
| 73 | readings = [
|
| 74 | int(value) for path in LINUX_THERMAL.glob("thermal_zone*/temp") if (value := _text_file(path)) and value.lstrip("-").isdigit()
|
| 75 | ]
|
| 76 | return max(readings) / 1000.0 if readings else None
|
| 77 |
|
| 78 |
|
| 79 | def sample_environment() -> dict[str, Any]:
|
| 80 | """One boundary sample. Timestamp is the caller's job."""
|
| 81 | system = platform.system()
|
| 82 | return {
|
| 83 | "loadavg": _loadavg(),
|
| 84 | "mem_available_bytes": _mem_available_bytes(system),
|
| 85 | "cpu_speed_limit_pct": _cpu_speed_limit_pct(system),
|
| 86 | "thermal_zone_max_c": _thermal_zone_max_c(system),
|
| 87 | "cpu_governor": cpu_governor(),
|
| 88 | "system": system,
|
| 89 | }
|
| 90 |
|
| 91 |
|
| 92 | def _load_per_core(sample: dict[str, Any], cores: int) -> float | None:
|
| 93 | loadavg = sample.get("loadavg")
|
| 94 | if not loadavg or cores <= 0:
|
| 95 | return None
|
| 96 | return float(loadavg[0]) / cores
|
| 97 |
|
| 98 |
|
| 99 | def environment_suspect(
|
| 100 | before: dict[str, Any],
|
| 101 | after: dict[str, Any],
|
| 102 | *,
|
| 103 | load_per_core_threshold: float = 1.5,
|
| 104 | cpu_count: int | None = None,
|
| 105 | ) -> list[str]:
|
| 106 | """Human-readable suspicion reasons for a measurement window (empty = clean)."""
|
| 107 | cores = cpu_count if cpu_count is not None else (os.cpu_count() or 1)
|
| 108 | reasons: list[str] = []
|
| 109 | for label, sample in (("before", before), ("after", after)):
|
| 110 | per_core = _load_per_core(sample, cores)
|
| 111 | if per_core is not None and per_core > load_per_core_threshold:
|
| 112 | reasons.append(
|
| 113 | f"load_per_core {per_core:.2f} above threshold {load_per_core_threshold:g} at {label} boundary"
|
| 114 | )
|
| 115 | for label, sample in (("before", before), ("after", after)):
|
| 116 | limit = sample.get("cpu_speed_limit_pct")
|
| 117 | if limit is not None and limit < 100:
|
| 118 | reasons.append(f"thermal throttling at {label} boundary: CPU_Speed_Limit={limit}")
|
| 119 | governors = {
|
| 120 | str(sample.get("cpu_governor"))
|
| 121 | for sample in (before, after)
|
| 122 | if sample.get("cpu_governor") is not None and sample.get("cpu_governor") != "performance"
|
| 123 | }
|
| 124 | for governor in sorted(governors):
|
| 125 | reasons.append(f"cpu_governor is '{governor}', not 'performance' (informational)")
|
| 126 | return reasons
|