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