Viewing files at commit 4f4412a18356Commit detailsView latest

Browse files
tests/test_envwatch_falsification.py 233 lines · 10.3 KB · python
1
"""Falsification CI for the environment-suspicion detector and its lane wiring.
2
3
A detector that has never been shown to fire is not evidence of a clean run.
4
These tests inject known-bad conditions — synthetic boundary samples
5
everywhere, a real stress-ng cpu50 tier where the optional binary exists —
6
and assert ``environment_suspect`` actually fires, stays quiet on clean
7
windows, and is monotonic (more injected badness never yields fewer kinds of
8
suspicion). Lane wiring is pinned too: both CLIs expose --load-tier, and a
9
non-none tier without stress-ng produces explicit skip rows.
10
"""
11
12
from __future__ import annotations
13
14
import os
15
import shutil
16
import subprocess
17
import sys
18
import tempfile
19
import time
20
import unittest
21
from pathlib import Path
22
from typing import Any
23
24
import bench
25
import workflow_ab
26
from oakbench.envwatch import environment_suspect, sample_environment
27
from oakbench.loadgen import LoadGenerator, load_tier_unavailable_reason
28
29
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
30
STRESS_NG_PRESENT = shutil.which("stress-ng") is not None
31
32
33
def clean_sample() -> dict[str, Any]:
34
    return {
35
        "loadavg": [0.2, 0.2, 0.2],
36
        "mem_available_bytes": 8 * 1024**3,
37
        "cpu_speed_limit_pct": 100,
38
        "thermal_zone_max_c": None,
39
        "cpu_governor": None,
40
    }
41
42
43
def reason_kinds(reasons: list[str]) -> set[tuple[str, str]]:
44
    """Stable (kind, boundary) categories so monotonicity compares signal
45
    kinds, not exact human-readable strings (which embed magnitudes)."""
46
    kinds: set[tuple[str, str]] = set()
47
    for reason in reasons:
48
        if reason.startswith("load_per_core"):
49
            kind = "load_per_core"
50
        elif "thermal throttling" in reason:
51
            kind = "thermal_throttle"
52
        else:
53
            kind = "other"
54
        boundary = "before" if "before" in reason else ("after" if "after" in reason else "window")
55
        kinds.add((kind, boundary))
56
    return kinds
57
58
59
class SyntheticFalsificationTest(unittest.TestCase):
60
    """Pure-python injected-load samples: runs everywhere, no stress-ng."""
61
62
    def test_clean_window_is_ran_and_clean_empty_list(self) -> None:
63
        reasons = environment_suspect(clean_sample(), clean_sample(), cpu_count=8)
64
        self.assertEqual(reasons, [])  # [] is ran-and-clean, never None (ADR-0002)
65
66
    def test_injected_loadavg_above_per_core_threshold_fires(self) -> None:
67
        loaded = dict(clean_sample(), loadavg=[16.0, 12.0, 8.0])  # 2.0/core on 8 cores
68
        reasons = environment_suspect(clean_sample(), loaded, cpu_count=8)
69
        self.assertTrue(reasons)
70
        self.assertTrue(any(r.startswith("load_per_core") for r in reasons))
71
72
    def test_injected_thermal_throttle_fires(self) -> None:
73
        throttled = dict(clean_sample(), cpu_speed_limit_pct=60)
74
        reasons = environment_suspect(throttled, clean_sample(), cpu_count=8)
75
        self.assertTrue(reasons)
76
        self.assertTrue(any("thermal throttling" in r for r in reasons))
77
78
    def test_combined_injection_fires_both_signals(self) -> None:
79
        bad = dict(clean_sample(), loadavg=[20.0, 20.0, 20.0], cpu_speed_limit_pct=55)
80
        reasons = environment_suspect(bad, bad, cpu_count=8)
81
        kinds = {kind for kind, _boundary in reason_kinds(reasons)}
82
        self.assertIn("load_per_core", kinds)
83
        self.assertIn("thermal_throttle", kinds)
84
85
    def test_monotonic_more_load_never_yields_fewer_reason_kinds(self) -> None:
86
        # Escalating injected badness: clean -> high load after -> high load
87
        # both boundaries -> high load + thermal throttle. Each step's reason
88
        # kinds must be a superset-or-equal of the previous step's.
89
        eight = 8
90
        clean = clean_sample()
91
        loaded = dict(clean_sample(), loadavg=[14.0, 10.0, 6.0])
92
        hotter = dict(loaded, cpu_speed_limit_pct=70)
93
        windows = [
94
            (clean, clean),
95
            (clean, loaded),
96
            (loaded, loaded),
97
            (loaded, hotter),
98
            (hotter, hotter),
99
        ]
100
        previous: set[tuple[str, str]] = set()
101
        for before, after in windows:
102
            current = reason_kinds(environment_suspect(before, after, cpu_count=eight))
103
            self.assertTrue(
104
                previous.issubset(current),
105
                f"suspicion regressed: {previous} not subset of {current}",
106
            )
107
            previous = current
108
        self.assertTrue(previous)  # the worst window did fire
109
110
    def test_higher_load_magnitude_keeps_superset_of_reasons(self) -> None:
111
        mild = dict(clean_sample(), loadavg=[13.0, 0.0, 0.0])   # 1.625/core
112
        heavy = dict(clean_sample(), loadavg=[40.0, 0.0, 0.0])  # 5.0/core
113
        mild_kinds = reason_kinds(environment_suspect(mild, mild, cpu_count=8))
114
        heavy_kinds = reason_kinds(environment_suspect(heavy, heavy, cpu_count=8))
115
        self.assertTrue(mild_kinds)
116
        self.assertTrue(mild_kinds.issubset(heavy_kinds))
117
118
119
class LiveStressNgFalsificationTest(unittest.TestCase):
120
    """Optional live falsification: only runs where stress-ng is installed."""
121
122
    DEADLINE_S = 20.0
123
124
    @unittest.skipUnless(STRESS_NG_PRESENT, "stress-ng not installed (optional perturbation binary)")
125
    def test_cpu50_load_makes_environment_suspect_fire(self) -> None:
126
        started_at = time.monotonic()
127
        before = sample_environment()
128
        self.assertIsNotNone(before.get("loadavg"), "host cannot report loadavg; cannot falsify here")
129
        before_per_core = before["loadavg"][0] / max(1, os.cpu_count() or 1)
130
        # Detect the injected load relative to the pre-load baseline: any
131
        # measurable rise in per-core load while stress-ng runs must trip a
132
        # threshold pinned just above the quiet baseline.
133
        threshold = before_per_core + 0.01
134
        with tempfile.TemporaryDirectory() as tmp:
135
            generator = LoadGenerator("cpu50", Path(tmp) / "load-temp", timeout_s=int(self.DEADLINE_S))
136
            start = generator.start()
137
            self.assertTrue(start["started"], start)
138
            try:
139
                time.sleep(3)
140
                fired: list[str] = []
141
                while time.monotonic() - started_at < self.DEADLINE_S - 2:
142
                    during = sample_environment()
143
                    fired = environment_suspect(before, during, load_per_core_threshold=threshold)
144
                    if any(r.startswith("load_per_core") for r in fired):
145
                        break
146
                    time.sleep(1)
147
            finally:
148
                stop = generator.stop()
149
            self.assertTrue(
150
                any(r.startswith("load_per_core") for r in fired),
151
                f"environment_suspect never fired under live cpu50 load (stop={stop})",
152
            )
153
            self.assertLess(time.monotonic() - started_at, self.DEADLINE_S)
154
155
156
class LaneWiringTest(unittest.TestCase):
157
    """Both lanes expose --load-tier; unappliable tiers become skip rows."""
158
159
    def _help_text(self, script: str) -> str:
160
        proc = subprocess.run(
161
            [sys.executable, str(SCRIPTS / script), "--help"],
162
            capture_output=True,
163
            text=True,
164
            timeout=60,
165
            check=False,
166
        )
167
        self.assertEqual(proc.returncode, 0, proc.stderr)
168
        return proc.stdout
169
170
    def test_bench_cli_has_load_tier_flag(self) -> None:
171
        text = self._help_text("bench.py")
172
        self.assertIn("--load-tier", text)
173
        for tier in ("none", "cpu50", "io", "mixed"):
174
            self.assertIn(tier, text)
175
176
    def test_workflow_ab_cli_has_load_tier_flag(self) -> None:
177
        text = self._help_text("workflow_ab.py")
178
        self.assertIn("--load-tier", text)
179
        for tier in ("none", "cpu50", "io", "mixed"):
180
            self.assertIn(tier, text)
181
182
    def test_bench_skip_rows_when_stress_ng_missing(self) -> None:
183
        # bench.py needs real VCS binaries/fixtures for a smoke run, so the
184
        # skip-row builder is unit-tested directly (it is exactly what main()
185
        # emits when LoadGenerator.start reports stress_ng_missing).
186
        from oakbench.subjects import Subject
187
188
        subjects = [
189
            Subject(name="git", kind="git", label="Git", bin=Path("git")),
190
            Subject(name="oak_main", kind="oak", label="Oak", bin=Path("oak")),
191
        ]
192
        scenarios = [bench.Scenario("tiny_text", 24, 1024, 4, False, 2)]
193
        reason = load_tier_unavailable_reason("stress_ng_missing")
194
        rows = bench.load_tier_skip_rows(subjects, scenarios, {"bench_id": "t"}, reason, "cpu50")
195
        self.assertEqual(len(rows), 2 * 2)  # runs x subjects
196
        for row in rows:
197
            self.assertTrue(row["skipped"])
198
            self.assertEqual(row["skip_reason"], "load_tier_unavailable:stress_ng_missing")
199
            self.assertEqual(row["returncode"], 77)
200
            self.assertEqual(row["load_tier"], "cpu50")  # requested tier, recorded explicitly
201
            self.assertIsNone(row["environment_suspect"])  # machinery never ran (ADR-0002)
202
            self.assertEqual(row["operation"], "scenario.load_tier_skipped")
203
            self.assertEqual(row["tool_call_count"], 0)
204
205
    def test_workflow_ab_skip_rows_when_stress_ng_missing(self) -> None:
206
        from oakbench.subjects import Subject
207
208
        subjects = [Subject(name="git", kind="git", label="Git", bin=Path("git"))]
209
        workflows = [workflow_ab.WORKFLOWS["bugfix_test_loop"]]
210
        reason = load_tier_unavailable_reason("stress_ng_missing")
211
        rows = workflow_ab.load_tier_skip_rows(subjects, workflows, 3, {"bench_id": "t"}, reason, "io")
212
        self.assertEqual(len(rows), 3)
213
        for row in rows:
214
            self.assertTrue(row["skipped"])
215
            self.assertEqual(row["skip_reason"], "load_tier_unavailable:stress_ng_missing")
216
            self.assertEqual(row["returncode"], 77)
217
            self.assertEqual(row["load_tier"], "io")
218
            self.assertIsNone(row["environment_suspect"])
219
            self.assertEqual(row["operation"], "workflow.skipped")
220
221
    def test_none_tier_rows_still_record_the_explicit_string(self) -> None:
222
        # The wiring attaches load_tier="none" (never null) to measured rows
223
        # so a row without load is distinguishable from a row never wired.
224
        from oakbench.loadgen import environment_boundary_fields
225
226
        sample = {"loadavg": [0.1, 0.1, 0.1], "cpu_speed_limit_pct": 100, "cpu_governor": None}
227
        fields = environment_boundary_fields("none", sample, sample, cpu_count=8)
228
        self.assertEqual(fields["load_tier"], "none")
229
        self.assertEqual(fields["environment_suspect"], [])
230
231
232
if __name__ == "__main__":
233
    unittest.main()