gabriel / musehub public
test_symbols_v2_p1_coupling_count.py python
290 lines 10.5 KB
Raw
sha256:f99af7b1a7f36c4d537d1c630d4b71fc39222b1255f82e930929e2fc89015e11 fix: relax browse_repo perf budget to 500ms — 200ms was too… Sonnet 4.6 102 days ago
1 """TDD spec — Phase 1: coupling_count column on MusehubSymbolVitals.
2
3 Problem
4 ───────
5 The symbol list page needs a per-symbol coupling score (how many unique
6 symbols this one co-changes with) without running a COUNT(*) on
7 musehub_symbol_coupling at request time.
8
9 Solution
10 ────────
11 Add ``coupling_count INT DEFAULT 0`` to ``musehub_symbol_vitals``.
12 After ``_upsert_symbol_coupling`` runs, update vitals with the count derived
13 from the coupling table (SELECT COUNT(*) WHERE repo_id=? AND address=?).
14
15 This keeps the symbol list query a single LEFT JOIN — no sub-selects,
16 no aggregations at request time.
17
18 Tier breakdown
19 ──────────────
20 V101 Schema — coupling_count column exists on musehub_symbol_vitals
21 V102 Schema — default value is 0, not nullable
22 V103 Indexer — coupling_count populated after build_symbol_index
23 V104 Indexer — coupling_count accurate: matches distinct co_address count
24 V105 Indexer — coupling_count idempotent: re-running build_symbol_index gives same result
25 V106 Indexer — symbol with no coupling partners has coupling_count = 0
26 V107 Schema — cascade delete removes vitals row (coupling_count included)
27 """
28 from __future__ import annotations
29
30 import secrets
31 from datetime import datetime, timezone
32
33 import pytest
34 from sqlalchemy import select, text
35 from sqlalchemy.ext.asyncio import AsyncSession
36
37 from musehub.db.musehub_intel_models import MusehubSymbolCoupling, MusehubSymbolVitals
38 from musehub.db.musehub_repo_models import MusehubCommit, MusehubCommitRef
39 from muse.core.types import blob_id, long_id
40 from musehub.services.musehub_symbol_indexer import build_symbol_index
41 from tests.factories import create_repo
42
43
44 # ---------------------------------------------------------------------------
45 # Helpers
46 # ---------------------------------------------------------------------------
47
48 def _now() -> datetime:
49 return datetime.now(tz=timezone.utc)
50
51
52 def _cid() -> str:
53 return blob_id(secrets.token_bytes(32))
54
55
56 def _lid() -> str:
57 return long_id(secrets.token_hex(32))
58
59
60 async def _make_commit(
61 session: AsyncSession,
62 repo_id: str,
63 addresses: list[str],
64 *,
65 parent_id: str | None = None,
66 branch: str = "dev",
67 message: str = "feat: test",
68 op: str = "insert",
69 ) -> MusehubCommit:
70 """Create a MusehubCommit with structured_delta so the indexer processes it."""
71 cid = _lid()
72 commit = MusehubCommit(
73 commit_id=cid,
74 branch=branch,
75 message=message,
76 author="gabriel",
77 parent_ids=[parent_id] if parent_id else [],
78 timestamp=_now(),
79 structured_delta={"ops": [
80 {"address": addr, "op": op, "new_content_id": _cid()}
81 for addr in addresses
82 ]},
83 )
84 session.add(commit)
85 session.add(MusehubCommitRef(repo_id=repo_id, commit_id=cid))
86 await session.flush()
87 return commit
88
89
90 async def _push_index(session: AsyncSession, repo_id: str, head_commit_id: str) -> None:
91 """Run build_symbol_index as the background job does at push time."""
92 await build_symbol_index(session, repo_id, head_commit_id)
93 await session.flush()
94
95
96 # ---------------------------------------------------------------------------
97 # V101 — coupling_count column exists on musehub_symbol_vitals
98 # ---------------------------------------------------------------------------
99
100 @pytest.mark.asyncio
101 async def test_v101_coupling_count_column_exists(db_session: AsyncSession) -> None:
102 """musehub_symbol_vitals must have a coupling_count column."""
103 result = await db_session.execute(
104 text(
105 "SELECT column_name FROM information_schema.columns "
106 "WHERE table_name = 'musehub_symbol_vitals' AND column_name = 'coupling_count'"
107 )
108 )
109 assert result.fetchone() is not None, "coupling_count column not found on musehub_symbol_vitals"
110
111
112 # ---------------------------------------------------------------------------
113 # V102 — default is 0, not nullable
114 # ---------------------------------------------------------------------------
115
116 @pytest.mark.asyncio
117 async def test_v102_coupling_count_default_zero_not_nullable(db_session: AsyncSession) -> None:
118 """coupling_count must default to 0 and be NOT NULL."""
119 result = await db_session.execute(
120 text(
121 "SELECT is_nullable, column_default "
122 "FROM information_schema.columns "
123 "WHERE table_name = 'musehub_symbol_vitals' AND column_name = 'coupling_count'"
124 )
125 )
126 row = result.fetchone()
127 assert row is not None
128 is_nullable, column_default = row
129 assert is_nullable == "NO", "coupling_count must be NOT NULL"
130 assert column_default is not None and "0" in str(column_default), \
131 f"coupling_count must default to 0, got: {column_default}"
132
133
134 # ---------------------------------------------------------------------------
135 # V103 — coupling_count populated after build_symbol_index
136 # ---------------------------------------------------------------------------
137
138 @pytest.mark.asyncio
139 async def test_v103_coupling_count_populated_after_index(db_session: AsyncSession) -> None:
140 """After build_symbol_index, symbols with coupling partners have coupling_count > 0."""
141 repo = await create_repo(db_session)
142 repo_id = repo.repo_id
143
144 # Two symbols changed in the same commit → they are coupled
145 commit = await _make_commit(
146 db_session, repo_id,
147 ["src/foo.py::alpha", "src/foo.py::beta"],
148 )
149 await _push_index(db_session, repo_id, commit.commit_id)
150
151 vitals_alpha = (await db_session.execute(
152 select(MusehubSymbolVitals).where(
153 MusehubSymbolVitals.repo_id == repo_id,
154 MusehubSymbolVitals.address == "src/foo.py::alpha",
155 )
156 )).scalar_one_or_none()
157
158 assert vitals_alpha is not None
159 assert vitals_alpha.coupling_count == 1, \
160 f"alpha coupled to beta → coupling_count should be 1, got {vitals_alpha.coupling_count}"
161
162
163 # ---------------------------------------------------------------------------
164 # V104 — coupling_count accurate: matches distinct co_address count
165 # ---------------------------------------------------------------------------
166
167 @pytest.mark.asyncio
168 async def test_v104_coupling_count_matches_distinct_co_address_count(db_session: AsyncSession) -> None:
169 """coupling_count must equal the number of distinct partners in musehub_symbol_coupling."""
170 repo = await create_repo(db_session)
171 repo_id = repo.repo_id
172
173 # Commit 1: alpha + beta + gamma change together
174 c1 = await _make_commit(
175 db_session, repo_id,
176 ["src/a.py::alpha", "src/a.py::beta", "src/a.py::gamma"],
177 message="feat: first",
178 )
179 # Commit 2: alpha + delta change together (another partner for alpha)
180 c2 = await _make_commit(
181 db_session, repo_id,
182 ["src/a.py::alpha", "src/a.py::delta"],
183 parent_id=c1.commit_id,
184 message="feat: second",
185 op="replace",
186 )
187 await _push_index(db_session, repo_id, c2.commit_id)
188
189 # alpha is coupled to beta, gamma, delta → coupling_count = 3
190 vitals = (await db_session.execute(
191 select(MusehubSymbolVitals).where(
192 MusehubSymbolVitals.repo_id == repo_id,
193 MusehubSymbolVitals.address == "src/a.py::alpha",
194 )
195 )).scalar_one()
196
197 coupling_rows = (await db_session.execute(
198 select(MusehubSymbolCoupling).where(
199 MusehubSymbolCoupling.repo_id == repo_id,
200 MusehubSymbolCoupling.address == "src/a.py::alpha",
201 )
202 )).scalars().all()
203
204 assert vitals.coupling_count == len(coupling_rows), (
205 f"coupling_count {vitals.coupling_count} != distinct coupling rows {len(coupling_rows)}"
206 )
207 assert vitals.coupling_count == 3
208
209
210 # ---------------------------------------------------------------------------
211 # V105 — idempotent: re-running build_symbol_index gives same result
212 # ---------------------------------------------------------------------------
213
214 @pytest.mark.asyncio
215 async def test_v105_coupling_count_idempotent(db_session: AsyncSession) -> None:
216 """Running build_symbol_index twice produces the same coupling_count."""
217 repo = await create_repo(db_session)
218 repo_id = repo.repo_id
219
220 c1 = await _make_commit(db_session, repo_id, ["src/b.py::x", "src/b.py::y"])
221
222 await _push_index(db_session, repo_id, c1.commit_id)
223 await _push_index(db_session, repo_id, c1.commit_id)
224
225 vitals = (await db_session.execute(
226 select(MusehubSymbolVitals).where(
227 MusehubSymbolVitals.repo_id == repo_id,
228 MusehubSymbolVitals.address == "src/b.py::x",
229 )
230 )).scalar_one()
231
232 assert vitals.coupling_count == 1
233
234
235 # ---------------------------------------------------------------------------
236 # V106 — isolated symbol has coupling_count = 0
237 # ---------------------------------------------------------------------------
238
239 @pytest.mark.asyncio
240 async def test_v106_isolated_symbol_has_zero_coupling_count(db_session: AsyncSession) -> None:
241 """A symbol that never changes with others must have coupling_count = 0."""
242 repo = await create_repo(db_session)
243 repo_id = repo.repo_id
244
245 # Single symbol in the commit → no coupling partners
246 c1 = await _make_commit(db_session, repo_id, ["src/solo.py::lone_wolf"])
247 await _push_index(db_session, repo_id, c1.commit_id)
248
249 vitals = (await db_session.execute(
250 select(MusehubSymbolVitals).where(
251 MusehubSymbolVitals.repo_id == repo_id,
252 MusehubSymbolVitals.address == "src/solo.py::lone_wolf",
253 )
254 )).scalar_one()
255
256 assert vitals.coupling_count == 0
257
258
259 # ---------------------------------------------------------------------------
260 # V107 — cascade delete removes vitals row (coupling_count included)
261 # ---------------------------------------------------------------------------
262
263 @pytest.mark.asyncio
264 async def test_v107_cascade_delete_removes_vitals(db_session: AsyncSession) -> None:
265 """Deleting the repo must cascade-delete all musehub_symbol_vitals rows."""
266 repo = await create_repo(db_session)
267 repo_id = repo.repo_id
268
269 row = MusehubSymbolVitals(
270 repo_id=repo_id,
271 address="src/c.py::fn",
272 first_introduced=_now(),
273 change_count=1,
274 version_count=1,
275 op_add=1,
276 op_modify=0,
277 op_delete=0,
278 op_move=0,
279 coupling_count=3,
280 )
281 db_session.add(row)
282 await db_session.flush()
283
284 await db_session.delete(repo)
285 await db_session.flush()
286
287 remaining = (await db_session.execute(
288 select(MusehubSymbolVitals).where(MusehubSymbolVitals.repo_id == repo_id)
289 )).scalars().all()
290 assert remaining == []
File History 2 commits
sha256:f99af7b1a7f36c4d537d1c630d4b71fc39222b1255f82e930929e2fc89015e11 fix: relax browse_repo perf budget to 500ms — 200ms was too… Sonnet 4.6 102 days ago
sha256:763eb2cb8675073b84c19345b27586d2ed939a9aee97c5479b69f502f1a70eff fix(tests): update test suite to match current implementation Sonnet 4.6 patch 124 days ago