gabriel / muse public
test_core_bisect.py python
295 lines 9.8 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 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
25
26 # ---------------------------------------------------------------------------
27 # Repo fixture
28 # ---------------------------------------------------------------------------
29
30 _BASE_DT = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
31
32
33 def _make_linear_repo(tmp_path: pathlib.Path, n: int = 8) -> list[str]:
34 """Create n commits in a linear chain; return commit IDs oldest-first."""
35 muse = tmp_path / ".muse"
36 for d in ("objects", "commits", "snapshots", "refs/heads"):
37 (muse / d).mkdir(parents=True, exist_ok=True)
38 (muse / "repo.json").write_text(json.dumps({"repo_id": "test"}))
39 (muse / "HEAD").write_text("ref: refs/heads/main\n")
40
41 commit_ids: list[str] = []
42 parent: str | None = None
43 for i in range(n):
44 manifest: Manifest = {f"file_{i}.txt": format(i, "064x")}
45 snap_id = compute_snapshot_id(manifest)
46 snap = SnapshotRecord(snapshot_id=snap_id, manifest=manifest)
47 write_snapshot(tmp_path, snap)
48 committed_at = _BASE_DT + datetime.timedelta(hours=i)
49 message = f"commit {i + 1}"
50 parent_ids = [parent] if parent else []
51 commit_id = compute_commit_id(
52 repo_id="test",
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 commit_id=commit_id,
60 repo_id="test",
61 created_on_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 (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 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 141 days ago