gabriel / muse public
test_core_bisect.py python
289 lines 9.7 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 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(parent_ids, snap_id, message, committed_at.isoformat())
52 rec = CommitRecord(
53 commit_id=commit_id,
54 repo_id="test",
55 branch="main",
56 snapshot_id=snap_id,
57 message=message,
58 committed_at=committed_at,
59 parent_commit_id=parent,
60 )
61 write_commit(tmp_path, rec)
62 commit_ids.append(commit_id)
63 parent = commit_id
64
65 (muse / "refs" / "heads" / "main").write_text(commit_ids[-1])
66 return commit_ids
67
68
69 # ---------------------------------------------------------------------------
70 # start_bisect
71 # ---------------------------------------------------------------------------
72
73
74 def test_start_bisect_creates_state(tmp_path: pathlib.Path) -> None:
75 commits = _make_linear_repo(tmp_path)
76 bad_id = commits[-1]
77 good_id = commits[0]
78 result = start_bisect(tmp_path, bad_id, [good_id])
79 assert is_bisect_active(tmp_path)
80 assert isinstance(result, BisectResult)
81
82
83 def test_start_bisect_suggests_midpoint(tmp_path: pathlib.Path) -> None:
84 commits = _make_linear_repo(tmp_path, n=8)
85 result = start_bisect(tmp_path, commits[-1], [commits[0]])
86 assert result.next_to_test is not None
87 assert not result.done
88
89
90 def test_start_bisect_steps_remaining_positive(tmp_path: pathlib.Path) -> None:
91 commits = _make_linear_repo(tmp_path, n=16)
92 result = start_bisect(tmp_path, commits[-1], [commits[0]])
93 assert result.steps_remaining > 0
94
95
96 def test_start_bisect_with_multiple_good(tmp_path: pathlib.Path) -> None:
97 commits = _make_linear_repo(tmp_path, n=10)
98 result = start_bisect(tmp_path, commits[-1], [commits[0], commits[2]])
99 assert result.next_to_test is not None
100
101
102 # ---------------------------------------------------------------------------
103 # mark_good / mark_bad
104 # ---------------------------------------------------------------------------
105
106
107 def test_mark_good_advances_bisect(tmp_path: pathlib.Path) -> None:
108 commits = _make_linear_repo(tmp_path, n=8)
109 start_bisect(tmp_path, commits[-1], [commits[0]])
110 from muse.core.bisect import _load_state
111 state = _load_state(tmp_path)
112 assert state is not None
113 remaining = state.get("remaining", [])
114 mid = remaining[len(remaining) // 2]
115 result = mark_good(tmp_path, mid)
116 assert isinstance(result, BisectResult)
117 assert result.verdict == "good"
118
119
120 def test_mark_bad_advances_bisect(tmp_path: pathlib.Path) -> None:
121 commits = _make_linear_repo(tmp_path, n=8)
122 start_bisect(tmp_path, commits[-1], [commits[0]])
123 from muse.core.bisect import _load_state
124 state = _load_state(tmp_path)
125 assert state is not None
126 remaining = state.get("remaining", [])
127 mid = remaining[len(remaining) // 2]
128 result = mark_bad(tmp_path, mid)
129 assert result.verdict == "bad"
130
131
132 def test_mark_good_reduces_remaining(tmp_path: pathlib.Path) -> None:
133 commits = _make_linear_repo(tmp_path, n=16)
134 start_bisect(tmp_path, commits[-1], [commits[0]])
135 from muse.core.bisect import _load_state
136 state = _load_state(tmp_path)
137 assert state is not None
138 remaining_before = len(state.get("remaining", []))
139 mid = state["remaining"][len(state["remaining"]) // 2]
140 result = mark_good(tmp_path, mid)
141 assert result.remaining_count < remaining_before
142
143
144 # ---------------------------------------------------------------------------
145 # skip_commit
146 # ---------------------------------------------------------------------------
147
148
149 def test_skip_commit(tmp_path: pathlib.Path) -> None:
150 commits = _make_linear_repo(tmp_path, n=8)
151 start_bisect(tmp_path, commits[-1], [commits[0]])
152 from muse.core.bisect import _load_state
153 state = _load_state(tmp_path)
154 assert state is not None
155 remaining = state.get("remaining", [])
156 mid = remaining[len(remaining) // 2]
157 result = skip_commit(tmp_path, mid)
158 assert result.verdict == "skip"
159
160
161 # ---------------------------------------------------------------------------
162 # reset_bisect
163 # ---------------------------------------------------------------------------
164
165
166 def test_reset_bisect_removes_state(tmp_path: pathlib.Path) -> None:
167 commits = _make_linear_repo(tmp_path)
168 start_bisect(tmp_path, commits[-1], [commits[0]])
169 assert is_bisect_active(tmp_path)
170 reset_bisect(tmp_path)
171 assert not is_bisect_active(tmp_path)
172
173
174 def test_reset_idempotent(tmp_path: pathlib.Path) -> None:
175 reset_bisect(tmp_path) # Should not raise even with no active session.
176
177
178 # ---------------------------------------------------------------------------
179 # bisect log
180 # ---------------------------------------------------------------------------
181
182
183 def test_bisect_log_records_start(tmp_path: pathlib.Path) -> None:
184 commits = _make_linear_repo(tmp_path)
185 start_bisect(tmp_path, commits[-1], [commits[0]])
186 log = get_bisect_log(tmp_path)
187 assert len(log) >= 2 # bad + at least one good
188
189
190 def test_bisect_log_records_verdicts(tmp_path: pathlib.Path) -> None:
191 commits = _make_linear_repo(tmp_path, n=8)
192 start_bisect(tmp_path, commits[-1], [commits[0]])
193 from muse.core.bisect import _load_state
194 state = _load_state(tmp_path)
195 assert state is not None
196 remaining = state.get("remaining", [])
197 mark_good(tmp_path, remaining[len(remaining) // 2])
198 log = get_bisect_log(tmp_path)
199 assert any("good" in entry for entry in log)
200
201
202 def test_bisect_log_empty_when_inactive(tmp_path: pathlib.Path) -> None:
203 assert get_bisect_log(tmp_path) == []
204
205
206 # ---------------------------------------------------------------------------
207 # is_bisect_active
208 # ---------------------------------------------------------------------------
209
210
211 def test_is_bisect_active_false_initially(tmp_path: pathlib.Path) -> None:
212 _make_linear_repo(tmp_path)
213 assert not is_bisect_active(tmp_path)
214
215
216 def test_is_bisect_active_true_after_start(tmp_path: pathlib.Path) -> None:
217 commits = _make_linear_repo(tmp_path)
218 start_bisect(tmp_path, commits[-1], [commits[0]])
219 assert is_bisect_active(tmp_path)
220
221
222 # ---------------------------------------------------------------------------
223 # Full convergence test
224 # ---------------------------------------------------------------------------
225
226
227 def test_bisect_converges_to_first_bad(tmp_path: pathlib.Path) -> None:
228 """Bisect should isolate commit 6 (0-indexed 5) as first bad in 8-commit chain."""
229 commits = _make_linear_repo(tmp_path, n=8)
230 bad_idx = 5
231
232 start_bisect(tmp_path, commits[-1], [commits[0]])
233
234 steps = 0
235 max_steps = 20
236 while steps < max_steps:
237 from muse.core.bisect import _load_state
238 state = _load_state(tmp_path)
239 assert state is not None
240 remaining = state.get("remaining", [])
241 if not remaining:
242 break
243 mid = remaining[len(remaining) // 2]
244 mid_idx = commits.index(mid)
245 if mid_idx < bad_idx:
246 mark_good(tmp_path, mid)
247 else:
248 mark_bad(tmp_path, mid)
249 steps += 1
250
251 from muse.core.bisect import _load_state
252 final = _load_state(tmp_path)
253 assert final is not None
254 first_bad = final.get("bad_id", "")
255 assert first_bad in commits[bad_idx:]
256
257
258 # ---------------------------------------------------------------------------
259 # Stress: many commits
260 # ---------------------------------------------------------------------------
261
262
263 def test_bisect_stress_100_commits(tmp_path: pathlib.Path) -> None:
264 """Bisect should converge in at most log2(100) ≈ 7 steps for 100 commits."""
265 import math
266
267 commits = _make_linear_repo(tmp_path, n=100)
268 bad_idx = 60
269 start_bisect(tmp_path, commits[-1], [commits[0]])
270
271 steps = 0
272 max_steps = int(math.log2(100)) + 5
273 from muse.core.bisect import _load_state
274 while steps < max_steps:
275 state = _load_state(tmp_path)
276 if state is None:
277 break
278 remaining = state.get("remaining", [])
279 if not remaining:
280 break
281 mid = remaining[len(remaining) // 2]
282 mid_idx = commits.index(mid)
283 if mid_idx < bad_idx:
284 mark_good(tmp_path, mid)
285 else:
286 mark_bad(tmp_path, mid)
287 steps += 1
288
289 assert steps <= max_steps
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago