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