gabriel / muse public
test_core_bisect.py python
295 lines 9.8 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 125 days ago
1 """Tests for muse/core/bisect.py — binary search regression hunting."""
2
3 from __future__ import annotations
4
5 import datetime
6 import json
7 import pathlib
8
9 import pytest
10
11 from muse.core.bisect import (
12 BisectResult,
13 get_bisect_log,
14 is_bisect_active,
15 mark_bad,
16 mark_good,
17 reset_bisect,
18 skip_commit,
19 start_bisect,
20 )
21 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
22 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
23 from muse.core.types import Manifest
24 from muse.core.paths import muse_dir
25
26
27 # ---------------------------------------------------------------------------
28 # Repo fixture
29 # ---------------------------------------------------------------------------
30
31 _BASE_DT = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
32
33
34 def _make_linear_repo(tmp_path: pathlib.Path, n: int = 8) -> list[str]:
35 """Create n commits in a linear chain; return commit IDs oldest-first."""
36 dot_muse = muse_dir(tmp_path)
37 for d in ("objects", "commits", "snapshots", "refs/heads"):
38 (dot_muse / d).mkdir(parents=True, exist_ok=True)
39 (dot_muse / "repo.json").write_text(json.dumps({"repo_id": "test"}))
40 (dot_muse / "HEAD").write_text("ref: refs/heads/main\n")
41
42 commit_ids: list[str] = []
43 parent: str | None = None
44 for i in range(n):
45 manifest: Manifest = {f"file_{i}.txt": format(i, "064x")}
46 snap_id = compute_snapshot_id(manifest)
47 snap = SnapshotRecord(snapshot_id=snap_id, manifest=manifest)
48 write_snapshot(tmp_path, snap)
49 committed_at = _BASE_DT + datetime.timedelta(hours=i)
50 message = f"commit {i + 1}"
51 parent_ids = [parent] if parent else []
52 commit_id = compute_commit_id(
53 parent_ids=parent_ids,
54 snapshot_id=snap_id,
55 message=message,
56 committed_at_iso=committed_at.isoformat(),
57 )
58 rec = CommitRecord(
59 repo_id="test",
60 commit_id=commit_id,
61 branch="main",
62 snapshot_id=snap_id,
63 message=message,
64 committed_at=committed_at,
65 parent_commit_id=parent,
66 )
67 write_commit(tmp_path, rec)
68 commit_ids.append(commit_id)
69 parent = commit_id
70
71 (dot_muse / "refs" / "heads" / "main").write_text(commit_ids[-1])
72 return commit_ids
73
74
75 # ---------------------------------------------------------------------------
76 # start_bisect
77 # ---------------------------------------------------------------------------
78
79
80 def test_start_bisect_creates_state(tmp_path: pathlib.Path) -> None:
81 commits = _make_linear_repo(tmp_path)
82 bad_id = commits[-1]
83 good_id = commits[0]
84 result = start_bisect(tmp_path, bad_id, [good_id])
85 assert is_bisect_active(tmp_path)
86 assert isinstance(result, BisectResult)
87
88
89 def test_start_bisect_suggests_midpoint(tmp_path: pathlib.Path) -> None:
90 commits = _make_linear_repo(tmp_path, n=8)
91 result = start_bisect(tmp_path, commits[-1], [commits[0]])
92 assert result.next_to_test is not None
93 assert not result.done
94
95
96 def test_start_bisect_steps_remaining_positive(tmp_path: pathlib.Path) -> None:
97 commits = _make_linear_repo(tmp_path, n=16)
98 result = start_bisect(tmp_path, commits[-1], [commits[0]])
99 assert result.steps_remaining > 0
100
101
102 def test_start_bisect_with_multiple_good(tmp_path: pathlib.Path) -> None:
103 commits = _make_linear_repo(tmp_path, n=10)
104 result = start_bisect(tmp_path, commits[-1], [commits[0], commits[2]])
105 assert result.next_to_test is not None
106
107
108 # ---------------------------------------------------------------------------
109 # mark_good / mark_bad
110 # ---------------------------------------------------------------------------
111
112
113 def test_mark_good_advances_bisect(tmp_path: pathlib.Path) -> None:
114 commits = _make_linear_repo(tmp_path, n=8)
115 start_bisect(tmp_path, commits[-1], [commits[0]])
116 from muse.core.bisect import _load_state
117 state = _load_state(tmp_path)
118 assert state is not None
119 remaining = state.get("remaining", [])
120 mid = remaining[len(remaining) // 2]
121 result = mark_good(tmp_path, mid)
122 assert isinstance(result, BisectResult)
123 assert result.verdict == "good"
124
125
126 def test_mark_bad_advances_bisect(tmp_path: pathlib.Path) -> None:
127 commits = _make_linear_repo(tmp_path, n=8)
128 start_bisect(tmp_path, commits[-1], [commits[0]])
129 from muse.core.bisect import _load_state
130 state = _load_state(tmp_path)
131 assert state is not None
132 remaining = state.get("remaining", [])
133 mid = remaining[len(remaining) // 2]
134 result = mark_bad(tmp_path, mid)
135 assert result.verdict == "bad"
136
137
138 def test_mark_good_reduces_remaining(tmp_path: pathlib.Path) -> None:
139 commits = _make_linear_repo(tmp_path, n=16)
140 start_bisect(tmp_path, commits[-1], [commits[0]])
141 from muse.core.bisect import _load_state
142 state = _load_state(tmp_path)
143 assert state is not None
144 remaining_before = len(state.get("remaining", []))
145 mid = state["remaining"][len(state["remaining"]) // 2]
146 result = mark_good(tmp_path, mid)
147 assert result.remaining_count < remaining_before
148
149
150 # ---------------------------------------------------------------------------
151 # skip_commit
152 # ---------------------------------------------------------------------------
153
154
155 def test_skip_commit(tmp_path: pathlib.Path) -> None:
156 commits = _make_linear_repo(tmp_path, n=8)
157 start_bisect(tmp_path, commits[-1], [commits[0]])
158 from muse.core.bisect import _load_state
159 state = _load_state(tmp_path)
160 assert state is not None
161 remaining = state.get("remaining", [])
162 mid = remaining[len(remaining) // 2]
163 result = skip_commit(tmp_path, mid)
164 assert result.verdict == "skip"
165
166
167 # ---------------------------------------------------------------------------
168 # reset_bisect
169 # ---------------------------------------------------------------------------
170
171
172 def test_reset_bisect_removes_state(tmp_path: pathlib.Path) -> None:
173 commits = _make_linear_repo(tmp_path)
174 start_bisect(tmp_path, commits[-1], [commits[0]])
175 assert is_bisect_active(tmp_path)
176 reset_bisect(tmp_path)
177 assert not is_bisect_active(tmp_path)
178
179
180 def test_reset_idempotent(tmp_path: pathlib.Path) -> None:
181 reset_bisect(tmp_path) # Should not raise even with no active session.
182
183
184 # ---------------------------------------------------------------------------
185 # bisect log
186 # ---------------------------------------------------------------------------
187
188
189 def test_bisect_log_records_start(tmp_path: pathlib.Path) -> None:
190 commits = _make_linear_repo(tmp_path)
191 start_bisect(tmp_path, commits[-1], [commits[0]])
192 log = get_bisect_log(tmp_path)
193 assert len(log) >= 2 # bad + at least one good
194
195
196 def test_bisect_log_records_verdicts(tmp_path: pathlib.Path) -> None:
197 commits = _make_linear_repo(tmp_path, n=8)
198 start_bisect(tmp_path, commits[-1], [commits[0]])
199 from muse.core.bisect import _load_state
200 state = _load_state(tmp_path)
201 assert state is not None
202 remaining = state.get("remaining", [])
203 mark_good(tmp_path, remaining[len(remaining) // 2])
204 log = get_bisect_log(tmp_path)
205 assert any("good" in entry for entry in log)
206
207
208 def test_bisect_log_empty_when_inactive(tmp_path: pathlib.Path) -> None:
209 assert get_bisect_log(tmp_path) == []
210
211
212 # ---------------------------------------------------------------------------
213 # is_bisect_active
214 # ---------------------------------------------------------------------------
215
216
217 def test_is_bisect_active_false_initially(tmp_path: pathlib.Path) -> None:
218 _make_linear_repo(tmp_path)
219 assert not is_bisect_active(tmp_path)
220
221
222 def test_is_bisect_active_true_after_start(tmp_path: pathlib.Path) -> None:
223 commits = _make_linear_repo(tmp_path)
224 start_bisect(tmp_path, commits[-1], [commits[0]])
225 assert is_bisect_active(tmp_path)
226
227
228 # ---------------------------------------------------------------------------
229 # Full convergence test
230 # ---------------------------------------------------------------------------
231
232
233 def test_bisect_converges_to_first_bad(tmp_path: pathlib.Path) -> None:
234 """Bisect should isolate commit 6 (0-indexed 5) as first bad in 8-commit chain."""
235 commits = _make_linear_repo(tmp_path, n=8)
236 bad_idx = 5
237
238 start_bisect(tmp_path, commits[-1], [commits[0]])
239
240 steps = 0
241 max_steps = 20
242 while steps < max_steps:
243 from muse.core.bisect import _load_state
244 state = _load_state(tmp_path)
245 assert state is not None
246 remaining = state.get("remaining", [])
247 if not remaining:
248 break
249 mid = remaining[len(remaining) // 2]
250 mid_idx = commits.index(mid)
251 if mid_idx < bad_idx:
252 mark_good(tmp_path, mid)
253 else:
254 mark_bad(tmp_path, mid)
255 steps += 1
256
257 from muse.core.bisect import _load_state
258 final = _load_state(tmp_path)
259 assert final is not None
260 first_bad = final.get("bad_id", "")
261 assert first_bad in commits[bad_idx:]
262
263
264 # ---------------------------------------------------------------------------
265 # Stress: many commits
266 # ---------------------------------------------------------------------------
267
268
269 def test_bisect_stress_100_commits(tmp_path: pathlib.Path) -> None:
270 """Bisect should converge in at most log2(100) ≈ 7 steps for 100 commits."""
271 import math
272
273 commits = _make_linear_repo(tmp_path, n=100)
274 bad_idx = 60
275 start_bisect(tmp_path, commits[-1], [commits[0]])
276
277 steps = 0
278 max_steps = int(math.log2(100)) + 5
279 from muse.core.bisect import _load_state
280 while steps < max_steps:
281 state = _load_state(tmp_path)
282 if state is None:
283 break
284 remaining = state.get("remaining", [])
285 if not remaining:
286 break
287 mid = remaining[len(remaining) // 2]
288 mid_idx = commits.index(mid)
289 if mid_idx < bad_idx:
290 mark_good(tmp_path, mid)
291 else:
292 mark_bad(tmp_path, mid)
293 steps += 1
294
295 assert steps <= max_steps
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 125 days ago