gabriel / musehub public
test_intel_fidelity.py python
339 lines 11.8 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago
1 """Fidelity tests — providers and routes must match CLI data shape.
2
3 Verifies three gaps found in the CLI-vs-DB sweep:
4
5 1. StableProvider — days_stable must be calendar days, not commit-walk index
6 2. EntangleProvider — co_change_rate must use Jaccard (co / |union|), not min
7 3. Hotspots route — must read MusehubSymbolIntel, not the legacy snapshot blob
8
9 Cases:
10 F01 StableProvider: symbol untouched for 30 calendar days → days_stable ≈ 30
11 F02 StableProvider: symbol changed today → days_stable = 0
12 F03 EntangleProvider: rate = co_changes / |union(commits_a, commits_b)|
13 F04 EntangleProvider: commits_both_active stores union cardinality
14 F05 Hotspots route: 200 with no legacy snapshot row (no longer depends on it)
15 F06 Hotspots route: symbols ranked by churn_30d descending
16 F07 Hotspots route: address and change_count present in HTML body
17 """
18 from __future__ import annotations
19
20 from datetime import datetime, timedelta, timezone
21
22 import pytest
23 import pytest_asyncio
24 import sqlalchemy as sa
25 from httpx import AsyncClient
26 from sqlalchemy.dialects.postgresql import insert as pg_insert
27 from sqlalchemy.ext.asyncio import AsyncSession
28
29 from musehub.db import musehub_models as dbm
30 from musehub.services.musehub_intel_providers import EntangleProvider, StableProvider
31 from tests.factories import create_repo
32
33 _NOW = datetime.now(tz=timezone.utc)
34
35
36 # ---------------------------------------------------------------------------
37 # Shared helpers
38 # ---------------------------------------------------------------------------
39
40 def _ts(days_ago: int) -> datetime:
41 return _NOW - timedelta(days=days_ago)
42
43
44 async def _insert_symbol_intel(
45 session: AsyncSession,
46 repo_id: str,
47 address: str,
48 churn_30d: int = 0,
49 last_changed: datetime | None = None,
50 ) -> None:
51 await session.execute(
52 pg_insert(dbm.MusehubSymbolIntel)
53 .values(
54 repo_id=repo_id,
55 address=address,
56 churn=churn_30d,
57 churn_30d=churn_30d,
58 churn_90d=0,
59 blast=0,
60 blast_direct=0,
61 blast_cross=0,
62 blast_top=[],
63 last_changed=last_changed,
64 author_count=1,
65 gravity=0.0,
66 weekly=[],
67 )
68 .on_conflict_do_update(
69 index_elements=["repo_id", "address"],
70 set_={"churn_30d": churn_30d, "last_changed": last_changed},
71 )
72 )
73
74
75 async def _insert_history_entry(
76 session: AsyncSession,
77 repo_id: str,
78 address: str,
79 commit_id: str,
80 committed_at: datetime,
81 op: str = "modify",
82 ) -> None:
83 await session.execute(
84 pg_insert(dbm.MusehubSymbolHistoryEntry)
85 .values(
86 repo_id=repo_id,
87 address=address,
88 commit_id=commit_id,
89 committed_at=committed_at,
90 op=op,
91 )
92 .on_conflict_do_nothing()
93 )
94
95
96 # ---------------------------------------------------------------------------
97 # F01 / F02 — StableProvider: calendar days, not commit-walk index
98 # ---------------------------------------------------------------------------
99
100 @pytest_asyncio.fixture
101 async def stable_repo(db_session: AsyncSession):
102 """Repo with two commits (today and 30 days ago) and two symbols."""
103 from muse.core.types import blob_id
104
105 repo = await create_repo(db_session, owner="fid", slug="stable-fid")
106 repo_id = str(repo.repo_id)
107
108 c_old_id = blob_id(b"commit-30d")
109 c_new_id = blob_id(b"commit-today")
110
111 # Chain: today's commit's parent is the 30-day-old commit
112 c_old = dbm.MusehubCommit(
113 commit_id=c_old_id,
114 repo_id=repo_id,
115 message="old",
116 author="a",
117 branch="main",
118 parent_ids=[],
119 timestamp=_ts(30),
120 )
121 c_new = dbm.MusehubCommit(
122 commit_id=c_new_id,
123 repo_id=repo_id,
124 message="new",
125 author="a",
126 branch="main",
127 parent_ids=[c_old_id],
128 timestamp=_ts(0),
129 )
130 db_session.add_all([c_old, c_new])
131
132 # symbol_a was last touched today; symbol_b was last touched 30 days ago
133 await db_session.flush()
134 await _insert_history_entry(db_session, repo_id, "src/a.py::fn_a", c_new_id, _ts(0))
135 await _insert_history_entry(db_session, repo_id, "src/b.py::fn_b", c_old_id, _ts(30))
136
137 # Both symbols must exist in MusehubSymbolIntel (provider reads current symbols from here)
138 await _insert_symbol_intel(db_session, repo_id, "src/a.py::fn_a", last_changed=_ts(0))
139 await _insert_symbol_intel(db_session, repo_id, "src/b.py::fn_b", last_changed=_ts(30))
140
141 await db_session.commit()
142 return repo, c_new_id
143
144
145 class TestStableCalendarDays:
146
147 @pytest.mark.asyncio
148 async def test_F01_symbol_30d_stale_has_days_stable_approx_30(
149 self, db_session: AsyncSession, stable_repo
150 ) -> None:
151 """Symbol last touched 30 calendar days ago → days_stable ≈ 30, not 1."""
152 repo, head = stable_repo
153 provider = StableProvider()
154 await provider.compute(db_session, str(repo.repo_id), head, {})
155
156 row = await db_session.scalar(
157 sa.select(dbm.MusehubIntelStable).where(
158 dbm.MusehubIntelStable.repo_id == str(repo.repo_id),
159 dbm.MusehubIntelStable.address == "src/b.py::fn_b",
160 )
161 )
162 assert row is not None
163 # Must be close to 30 calendar days — definitely not the commit index (1)
164 assert row.days_stable >= 28, f"Expected ~30, got {row.days_stable}"
165 assert row.days_stable <= 32, f"Expected ~30, got {row.days_stable}"
166
167 @pytest.mark.asyncio
168 async def test_F02_symbol_changed_today_has_days_stable_zero(
169 self, db_session: AsyncSession, stable_repo
170 ) -> None:
171 """Symbol changed today → days_stable = 0, not the commit index."""
172 repo, head = stable_repo
173 provider = StableProvider()
174 await provider.compute(db_session, str(repo.repo_id), head, {})
175
176 row = await db_session.scalar(
177 sa.select(dbm.MusehubIntelStable).where(
178 dbm.MusehubIntelStable.repo_id == str(repo.repo_id),
179 dbm.MusehubIntelStable.address == "src/a.py::fn_a",
180 )
181 )
182 assert row is not None
183 assert row.days_stable == 0, f"Expected 0, got {row.days_stable}"
184
185
186 # ---------------------------------------------------------------------------
187 # F03 / F04 — EntangleProvider: Jaccard co_change_rate
188 # ---------------------------------------------------------------------------
189
190 @pytest_asyncio.fixture
191 async def entangle_repo(db_session: AsyncSession):
192 """Repo whose commit graph gives a clear Jaccard vs min distinction.
193
194 symbol_a touched in: c1, c2, c3, c4, c5 → 5 commits
195 symbol_b touched in: c3, c4, c5, c6, c7 → 5 commits
196 co_changes = 3 (c3, c4, c5)
197 union = 7 (c1..c7)
198 Jaccard rate = 3/7 ≈ 0.4286
199 min rate = 3/5 = 0.6 (the wrong answer)
200 """
201 from muse.core.types import blob_id
202
203 repo = await create_repo(db_session, owner="fid", slug="entangle-fid")
204 repo_id = str(repo.repo_id)
205
206 # Build a linear chain c1 → c2 → … → c7 (c7 = HEAD)
207 commit_ids = [blob_id(f"entangle-c{i}".encode()) for i in range(1, 8)]
208 for i, cid in enumerate(commit_ids):
209 parent = [commit_ids[i - 1]] if i > 0 else []
210 db_session.add(dbm.MusehubCommit(
211 commit_id=cid,
212 repo_id=repo_id,
213 message=f"c{i+1}",
214 author="a",
215 branch="main",
216 parent_ids=parent,
217 timestamp=_ts(7 - i),
218 ))
219 await db_session.flush()
220
221 # symbol_a in c1–c5, symbol_b in c3–c7
222 sym_a = "src/a.py::fn_a"
223 sym_b = "src/b.py::fn_b"
224 ts = _ts(1)
225
226 for cid in commit_ids[:5]: # c1-c5 → symbol_a
227 await _insert_history_entry(db_session, repo_id, sym_a, cid, ts)
228 for cid in commit_ids[2:]: # c3-c7 → symbol_b
229 await _insert_history_entry(db_session, repo_id, sym_b, cid, ts)
230
231 await db_session.commit()
232 return repo, commit_ids[-1] # HEAD = c7
233
234
235 class TestEntangleJaccard:
236
237 @pytest.mark.asyncio
238 async def test_F03_co_change_rate_is_jaccard(
239 self, db_session: AsyncSession, entangle_repo
240 ) -> None:
241 """co_change_rate = co_changes / |union| (Jaccard), not co / min."""
242 repo, head = entangle_repo
243 provider = EntangleProvider()
244 await provider.compute(db_session, str(repo.repo_id), head, {})
245
246 row = await db_session.scalar(
247 sa.select(dbm.MusehubIntelEntangle).where(
248 dbm.MusehubIntelEntangle.repo_id == str(repo.repo_id),
249 )
250 )
251 assert row is not None, "Expected one entangle pair to be stored"
252
253 expected_jaccard = 3 / 7
254 expected_min_rate = 3 / 5
255
256 assert abs(row.co_change_rate - expected_jaccard) < 0.001, (
257 f"Rate {row.co_change_rate:.4f} looks like min ({expected_min_rate}) "
258 f"not Jaccard ({expected_jaccard:.4f})"
259 )
260
261 @pytest.mark.asyncio
262 async def test_F04_commits_both_active_is_union_cardinality(
263 self, db_session: AsyncSession, entangle_repo
264 ) -> None:
265 """commits_both_active stores |union(commits_a, commits_b)| = 7."""
266 repo, head = entangle_repo
267 provider = EntangleProvider()
268 await provider.compute(db_session, str(repo.repo_id), head, {})
269
270 row = await db_session.scalar(
271 sa.select(dbm.MusehubIntelEntangle).where(
272 dbm.MusehubIntelEntangle.repo_id == str(repo.repo_id),
273 )
274 )
275 assert row is not None
276 assert row.commits_both_active == 7, (
277 f"Expected union cardinality 7, got {row.commits_both_active}"
278 )
279
280
281 # ---------------------------------------------------------------------------
282 # F05 / F06 / F07 — Hotspots route: reads MusehubSymbolIntel, not snapshot
283 # ---------------------------------------------------------------------------
284
285 @pytest_asyncio.fixture
286 async def hotspots_repo(db_session: AsyncSession):
287 """Repo with symbol intel rows but NO legacy snapshot."""
288 repo = await create_repo(db_session, owner="fid", slug="hotspots-fid")
289 repo_id = str(repo.repo_id)
290
291 # Three symbols with different churn_30d values
292 for addr, churn in [
293 ("src/hot.py::fn_hot", 42),
294 ("src/med.py::fn_med", 15),
295 ("src/cold.py::fn_cold", 3),
296 ]:
297 await _insert_symbol_intel(db_session, repo_id, addr, churn_30d=churn)
298
299 await db_session.commit()
300 return repo
301
302
303 class TestHotspotsRoute:
304
305 @pytest.mark.asyncio
306 async def test_F05_hotspots_returns_200_without_legacy_snapshot(
307 self, client: AsyncClient, hotspots_repo
308 ) -> None:
309 """Route must not 500 when there is no legacy snapshot row."""
310 r = await client.get("/fid/hotspots-fid/intel/hotspots")
311 assert r.status_code == 200
312
313 @pytest.mark.asyncio
314 async def test_F06_hotspots_ranked_by_churn_30d_descending(
315 self, client: AsyncClient, hotspots_repo
316 ) -> None:
317 """Symbols appear highest-churn first (42, 15, 3)."""
318 r = await client.get("/fid/hotspots-fid/intel/hotspots")
319 assert r.status_code == 200
320 body = r.text
321 pos_hot = body.find("fn_hot")
322 pos_med = body.find("fn_med")
323 pos_cold = body.find("fn_cold")
324 assert pos_hot != -1 and pos_med != -1 and pos_cold != -1, (
325 "Not all symbols found in response"
326 )
327 assert pos_hot < pos_med < pos_cold, (
328 "Symbols not in churn-descending order"
329 )
330
331 @pytest.mark.asyncio
332 async def test_F07_hotspots_renders_address_and_change_count(
333 self, client: AsyncClient, hotspots_repo
334 ) -> None:
335 """Address and change count appear in the rendered HTML."""
336 r = await client.get("/fid/hotspots-fid/intel/hotspots")
337 assert r.status_code == 200
338 assert "src/hot.py::fn_hot" in r.text
339 assert "42" in r.text
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago