gabriel / musehub public
test_phase1_blast_risk_provider.py python
542 lines 19.3 KB
Raw
sha256:0997d6250ae6476362f6fe2025af7789f46d03df3e9f34356d5e8ee79b201923 fix(issues): use issue number as pagination cursor, not cre… Sonnet 4.6 patch 8 days ago
1 """TDD spec for Phase 1 — SQL-derived BlastRiskProvider (issue #11).
2
3 BlastRiskProvider replaces the muse-CLI subprocess approach with a pure SQL
4 derivation from `musehub_symbol_intel` blast and churn columns.
5
6 Risk score formula:
7 impact_score = min(blast / 50.0, 1.0) — normalized blast radius
8 churn_score = min(churn_30d / 20.0, 1.0) — normalized 30-day churn
9 test_gap_score = 1.0 — no coverage data → worst case
10 coupling_score = min(blast_cross / 10.0, 1.0) — normalized cross-domain blast
11
12 risk_score = round(
13 impact_score * 40 +
14 churn_score * 25 +
15 test_gap_score * 20 +
16 coupling_score * 15
17 )
18
19 Risk tiers:
20 critical → risk_score >= 75
21 high → risk_score >= 50
22 medium → risk_score >= 25
23 low → risk_score < 25
24
25 tracked_kinds = {function, async_function, method, async_method, class}
26 Only symbols with blast > 0 are candidates.
27
28 Layers:
29 Unit (no DB):
30 1. Registry — "intel.code.blast_risk" in _PROVIDER_REGISTRY
31 2. Protocol — satisfies IntelProvider
32 3. Dispatch — job_types_for_push("code") includes "intel.code.blast_risk"
33 job_types_for_push("midi") excludes "intel.code.blast_risk"
34 4. Tier thresholds — _risk_tier boundaries at 75/50/25
35 5. Score formula — weights sum correctly, all-max → 100, all-zero → 0
36
37 Integration (DB):
38 6. High blast+churn → critical tier
39 7. Zero blast → excluded
40 8. Untracked kind → excluded
41 9. risk_score capped at 100
42 10. Idempotent — run twice, one row per address
43 11. Return type — [("intel.code.blast_risk", {"count": N})]
44 12. Empty repo → []
45
46 State integrity:
47 13. Re-run updates risk_score in-place (upsert, not duplicate)
48 14. Upsert does not touch symbol_intel blast/churn columns
49 15. ref column updated to latest ref on each run
50
51 Performance:
52 16. 1000 symbol rows processed in < 5 seconds
53
54 No subprocess:
55 17. compute() never calls asyncio.create_subprocess_exec
56 """
57 from __future__ import annotations
58
59 import secrets
60 import time
61 from unittest.mock import patch
62
63 import pytest
64 import pytest_asyncio
65 from sqlalchemy import select
66 from sqlalchemy.dialects.postgresql import insert as pg_insert
67 from sqlalchemy.ext.asyncio import AsyncSession
68
69 from muse.core.types import fake_id, long_id
70 from musehub.db.musehub_intel_models import MusehubIntelBlastRisk, MusehubSymbolIntel
71 from musehub.types.json_types import JSONObject
72 from tests.factories import create_repo
73
74
75 def _uid() -> str:
76 return fake_id(secrets.token_hex(16))
77
78
79 _TRACKED_KINDS = ("function", "async_function", "method", "async_method", "class")
80
81 _REF_A = long_id("a" * 64)
82 _REF_B = long_id("b" * 64)
83
84
85 # ---------------------------------------------------------------------------
86 # Helpers
87 # ---------------------------------------------------------------------------
88
89 async def _seed_symbol(
90 session: AsyncSession,
91 repo_id: str,
92 *,
93 address: str,
94 kind: str = "function",
95 blast: int = 10,
96 blast_direct: int = 5,
97 blast_cross: int = 2,
98 churn: int = 5,
99 churn_30d: int = 3,
100 churn_90d: int = 4,
101 ) -> None:
102 stmt = (
103 pg_insert(MusehubSymbolIntel)
104 .values(
105 repo_id=repo_id,
106 address=address,
107 symbol_kind=kind,
108 blast=blast,
109 blast_direct=blast_direct,
110 blast_cross=blast_cross,
111 churn=churn,
112 churn_30d=churn_30d,
113 churn_90d=churn_90d,
114 author_count=1,
115 gravity=0.0,
116 weekly=[0] * 12,
117 blast_top=[],
118 )
119 .on_conflict_do_update(
120 index_elements=["repo_id", "address"],
121 set_={
122 "symbol_kind": kind,
123 "blast": blast,
124 "blast_direct": blast_direct,
125 "blast_cross": blast_cross,
126 "churn": churn,
127 "churn_30d": churn_30d,
128 "churn_90d": churn_90d,
129 },
130 )
131 )
132 await session.execute(stmt)
133 await session.flush()
134
135
136 async def _get_risk_row(
137 session: AsyncSession, repo_id: str, address: str
138 ) -> MusehubIntelBlastRisk | None:
139 result = await session.execute(
140 select(MusehubIntelBlastRisk).where(
141 MusehubIntelBlastRisk.repo_id == repo_id,
142 MusehubIntelBlastRisk.address == address,
143 )
144 )
145 return result.scalar_one_or_none()
146
147
148 async def _run_provider(session: AsyncSession, repo_id: str, ref: str = _REF_A) -> list[tuple[str, JSONObject]]:
149 from musehub.services.musehub_intel_providers import BlastRiskProvider
150 provider = BlastRiskProvider()
151 return await provider.compute(session, repo_id, ref, {})
152
153
154 # ---------------------------------------------------------------------------
155 # Layer 1 — Registry
156 # ---------------------------------------------------------------------------
157
158 class TestBlastRiskRegistry:
159
160 def test_P1_01_blast_risk_in_provider_registry(self) -> None:
161 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
162 assert "intel.code.blast_risk" in _PROVIDER_REGISTRY
163
164 def test_P1_02_blast_risk_satisfies_intel_provider_protocol(self) -> None:
165 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY, IntelProvider
166 provider = _PROVIDER_REGISTRY["intel.code.blast_risk"]
167 assert isinstance(provider, IntelProvider)
168
169
170 # ---------------------------------------------------------------------------
171 # Layer 2 — Dispatch
172 # ---------------------------------------------------------------------------
173
174 class TestBlastRiskDispatch:
175
176 def test_P1_03_job_types_for_push_code_includes_blast_risk(self) -> None:
177 from musehub.services.musehub_intel_providers import job_types_for_push
178 assert "intel.code.blast_risk" in job_types_for_push("code")
179
180 def test_P1_04_job_types_for_push_midi_excludes_blast_risk(self) -> None:
181 from musehub.services.musehub_intel_providers import job_types_for_push
182 assert "intel.code.blast_risk" not in job_types_for_push("midi")
183
184
185 # ---------------------------------------------------------------------------
186 # Layer 3 — Tier thresholds (unit, no DB)
187 # ---------------------------------------------------------------------------
188
189 class TestRiskTierThresholds:
190
191 def test_P1_05_score_75_is_critical(self) -> None:
192 from musehub.services.musehub_intel_providers import _risk_tier
193 assert _risk_tier(75) == "critical"
194
195 def test_P1_06_score_100_is_critical(self) -> None:
196 from musehub.services.musehub_intel_providers import _risk_tier
197 assert _risk_tier(100) == "critical"
198
199 def test_P1_07_score_50_is_high(self) -> None:
200 from musehub.services.musehub_intel_providers import _risk_tier
201 assert _risk_tier(50) == "high"
202
203 def test_P1_08_score_74_is_high(self) -> None:
204 from musehub.services.musehub_intel_providers import _risk_tier
205 assert _risk_tier(74) == "high"
206
207 def test_P1_09_score_25_is_medium(self) -> None:
208 from musehub.services.musehub_intel_providers import _risk_tier
209 assert _risk_tier(25) == "medium"
210
211 def test_P1_10_score_49_is_medium(self) -> None:
212 from musehub.services.musehub_intel_providers import _risk_tier
213 assert _risk_tier(49) == "medium"
214
215 def test_P1_11_score_24_is_low(self) -> None:
216 from musehub.services.musehub_intel_providers import _risk_tier
217 assert _risk_tier(24) == "low"
218
219 def test_P1_11b_score_0_is_low(self) -> None:
220 from musehub.services.musehub_intel_providers import _risk_tier
221 assert _risk_tier(0) == "low"
222
223
224 # ---------------------------------------------------------------------------
225 # Layer 4 — Score formula (unit, no DB)
226 # ---------------------------------------------------------------------------
227
228 class TestRiskScoreFormula:
229
230 def test_P1_12_all_max_inputs_yield_100(self) -> None:
231 from musehub.services.musehub_intel_providers import _compute_risk_score
232 score = _compute_risk_score(
233 impact_score=1.0,
234 churn_score=1.0,
235 test_gap_score=1.0,
236 coupling_score=1.0,
237 )
238 assert score == 100
239
240 def test_P1_13_all_zero_inputs_yield_0(self) -> None:
241 from musehub.services.musehub_intel_providers import _compute_risk_score
242 score = _compute_risk_score(
243 impact_score=0.0,
244 churn_score=0.0,
245 test_gap_score=0.0,
246 coupling_score=0.0,
247 )
248 assert score == 0
249
250 def test_P1_14_impact_weight_is_40(self) -> None:
251 from musehub.services.musehub_intel_providers import _compute_risk_score
252 score = _compute_risk_score(
253 impact_score=1.0,
254 churn_score=0.0,
255 test_gap_score=0.0,
256 coupling_score=0.0,
257 )
258 assert score == 40
259
260 def test_P1_15_churn_weight_is_25(self) -> None:
261 from musehub.services.musehub_intel_providers import _compute_risk_score
262 score = _compute_risk_score(
263 impact_score=0.0,
264 churn_score=1.0,
265 test_gap_score=0.0,
266 coupling_score=0.0,
267 )
268 assert score == 25
269
270 def test_P1_16_test_gap_weight_is_20(self) -> None:
271 from musehub.services.musehub_intel_providers import _compute_risk_score
272 score = _compute_risk_score(
273 impact_score=0.0,
274 churn_score=0.0,
275 test_gap_score=1.0,
276 coupling_score=0.0,
277 )
278 assert score == 20
279
280 def test_P1_17_coupling_weight_is_15(self) -> None:
281 from musehub.services.musehub_intel_providers import _compute_risk_score
282 score = _compute_risk_score(
283 impact_score=0.0,
284 churn_score=0.0,
285 test_gap_score=0.0,
286 coupling_score=1.0,
287 )
288 assert score == 15
289
290
291 # ---------------------------------------------------------------------------
292 # Layer 5 — Integration: confidence tiers via DB
293 # ---------------------------------------------------------------------------
294
295 class TestBlastRiskIntegration:
296
297 @pytest.mark.asyncio
298 async def test_P1_18_high_blast_high_churn_yields_critical(
299 self, db_session: AsyncSession
300 ) -> None:
301 repo = await create_repo(db_session)
302 # blast=50 → impact=1.0, churn_30d=20 → churn=1.0 → score=100 → critical
303 await _seed_symbol(
304 db_session, repo.repo_id,
305 address="pkg/a.py::risky_fn",
306 blast=50, blast_cross=10, churn_30d=20,
307 )
308 await _run_provider(db_session, repo.repo_id)
309 row = await _get_risk_row(db_session, repo.repo_id, "pkg/a.py::risky_fn")
310 assert row is not None
311 assert row.risk == "critical"
312
313 @pytest.mark.asyncio
314 async def test_P1_19_zero_blast_excluded(
315 self, db_session: AsyncSession
316 ) -> None:
317 repo = await create_repo(db_session)
318 await _seed_symbol(
319 db_session, repo.repo_id,
320 address="pkg/b.py::no_blast",
321 blast=0, churn_30d=10,
322 )
323 await _run_provider(db_session, repo.repo_id)
324 row = await _get_risk_row(db_session, repo.repo_id, "pkg/b.py::no_blast")
325 assert row is None
326
327 @pytest.mark.asyncio
328 async def test_P1_20_untracked_kind_excluded(
329 self, db_session: AsyncSession
330 ) -> None:
331 repo = await create_repo(db_session)
332 await _seed_symbol(
333 db_session, repo.repo_id,
334 address="pkg/c.py::some_import",
335 kind="import",
336 blast=20, churn_30d=5,
337 )
338 await _run_provider(db_session, repo.repo_id)
339 row = await _get_risk_row(db_session, repo.repo_id, "pkg/c.py::some_import")
340 assert row is None
341
342 @pytest.mark.asyncio
343 async def test_P1_21_risk_score_capped_at_100(
344 self, db_session: AsyncSession
345 ) -> None:
346 repo = await create_repo(db_session)
347 # Extreme inputs — all scores at 1.0 → should be exactly 100, never over
348 await _seed_symbol(
349 db_session, repo.repo_id,
350 address="pkg/d.py::overflow_fn",
351 blast=9999, blast_cross=9999, churn_30d=9999,
352 )
353 await _run_provider(db_session, repo.repo_id)
354 row = await _get_risk_row(db_session, repo.repo_id, "pkg/d.py::overflow_fn")
355 assert row is not None
356 assert row.risk_score <= 100
357
358 @pytest.mark.asyncio
359 async def test_P1_22_idempotent_run_twice_one_row(
360 self, db_session: AsyncSession
361 ) -> None:
362 from sqlalchemy import func
363 repo = await create_repo(db_session)
364 await _seed_symbol(
365 db_session, repo.repo_id,
366 address="pkg/e.py::idem_fn",
367 blast=10, churn_30d=5,
368 )
369 await _run_provider(db_session, repo.repo_id)
370 await _run_provider(db_session, repo.repo_id)
371 count = (await db_session.execute(
372 select(func.count()).select_from(MusehubIntelBlastRisk).where(
373 MusehubIntelBlastRisk.repo_id == repo.repo_id,
374 MusehubIntelBlastRisk.address == "pkg/e.py::idem_fn",
375 )
376 )).scalar_one()
377 assert count == 1
378
379 @pytest.mark.asyncio
380 async def test_P1_23_return_type(
381 self, db_session: AsyncSession
382 ) -> None:
383 repo = await create_repo(db_session)
384 await _seed_symbol(
385 db_session, repo.repo_id,
386 address="pkg/f.py::ret_fn",
387 blast=10, churn_30d=3,
388 )
389 result = await _run_provider(db_session, repo.repo_id)
390 assert len(result) == 1
391 intel_type, data = result[0]
392 assert intel_type == "intel.code.blast_risk"
393 assert data["count"] == 1
394
395 @pytest.mark.asyncio
396 async def test_P1_24_empty_repo_returns_empty_list(
397 self, db_session: AsyncSession
398 ) -> None:
399 repo = await create_repo(db_session)
400 result = await _run_provider(db_session, repo.repo_id)
401 assert result == []
402
403
404 # ---------------------------------------------------------------------------
405 # Layer 6 — State integrity
406 # ---------------------------------------------------------------------------
407
408 class TestBlastRiskStateIntegrity:
409
410 @pytest.mark.asyncio
411 async def test_P1_25_rerun_updates_risk_score_in_place(
412 self, db_session: AsyncSession
413 ) -> None:
414 repo = await create_repo(db_session)
415 repo_id = repo.repo_id # capture before any expire_all invalidates the ORM object
416 # First run: low churn → lower score
417 await _seed_symbol(db_session, repo_id, address="pkg/g.py::update_fn",
418 blast=10, blast_cross=0, churn_30d=0)
419 await _run_provider(db_session, repo_id)
420 row_first = await _get_risk_row(db_session, repo_id, "pkg/g.py::update_fn")
421 score_first = row_first.risk_score
422
423 # Update symbol to high churn — raw SQL upsert, bypasses ORM identity map
424 await _seed_symbol(db_session, repo_id, address="pkg/g.py::update_fn",
425 blast=10, blast_cross=0, churn_30d=20)
426 await _run_provider(db_session, repo_id)
427 db_session.expire_all() # invalidate cached blast_risk row so _get_risk_row re-fetches
428 row_second = await _get_risk_row(db_session, repo_id, "pkg/g.py::update_fn")
429 assert row_second.risk_score > score_first
430
431 # Still one row
432 from sqlalchemy import func
433 count = (await db_session.execute(
434 select(func.count()).select_from(MusehubIntelBlastRisk).where(
435 MusehubIntelBlastRisk.repo_id == repo_id,
436 MusehubIntelBlastRisk.address == "pkg/g.py::update_fn",
437 )
438 )).scalar_one()
439 assert count == 1
440
441 @pytest.mark.asyncio
442 async def test_P1_26_upsert_does_not_touch_symbol_intel_blast_churn(
443 self, db_session: AsyncSession
444 ) -> None:
445 repo = await create_repo(db_session)
446 await _seed_symbol(
447 db_session, repo.repo_id,
448 address="pkg/h.py::no_touch_fn",
449 blast=7, churn=3, churn_30d=2,
450 )
451 await _run_provider(db_session, repo.repo_id)
452
453 intel_row = (await db_session.execute(
454 select(MusehubSymbolIntel).where(
455 MusehubSymbolIntel.repo_id == repo.repo_id,
456 MusehubSymbolIntel.address == "pkg/h.py::no_touch_fn",
457 )
458 )).scalar_one()
459 assert intel_row.blast == 7
460 assert intel_row.churn == 3
461 assert intel_row.churn_30d == 2
462
463 @pytest.mark.asyncio
464 async def test_P1_27_ref_column_updated_on_rerun(
465 self, db_session: AsyncSession
466 ) -> None:
467 repo = await create_repo(db_session)
468 repo_id = repo.repo_id # capture before any expire_all invalidates the ORM object
469 await _seed_symbol(db_session, repo_id, address="pkg/i.py::ref_fn",
470 blast=10, churn_30d=3)
471 await _run_provider(db_session, repo_id, ref=_REF_A)
472 row = await _get_risk_row(db_session, repo_id, "pkg/i.py::ref_fn")
473 assert row.ref == _REF_A
474
475 await _run_provider(db_session, repo_id, ref=_REF_B)
476 db_session.expire_all() # invalidate cached blast_risk row so _get_risk_row re-fetches
477 row = await _get_risk_row(db_session, repo_id, "pkg/i.py::ref_fn")
478 assert row.ref == _REF_B
479
480
481 # ---------------------------------------------------------------------------
482 # Layer 7 — Performance
483 # ---------------------------------------------------------------------------
484
485 class TestBlastRiskPerformance:
486
487 @pytest.mark.asyncio
488 async def test_P1_28_1000_symbols_processed_under_5_seconds(
489 self, db_session: AsyncSession
490 ) -> None:
491 repo = await create_repo(db_session)
492 # Bulk insert via executemany-style
493 from sqlalchemy import text
494 rows = []
495 for i in range(1000):
496 rows.append({
497 "repo_id": repo.repo_id,
498 "address": f"pkg/perf_{i}.py::fn_{i}",
499 "symbol_kind": _TRACKED_KINDS[i % len(_TRACKED_KINDS)],
500 "blast": (i % 50) + 1,
501 "blast_direct": i % 10,
502 "blast_cross": i % 10,
503 "churn": (i % 20) + 1,
504 "churn_30d": i % 20,
505 "churn_90d": i % 20,
506 "author_count": 1,
507 "gravity": 0.0,
508 "weekly": [0] * 12,
509 "blast_top": [],
510 })
511 await db_session.execute(
512 pg_insert(MusehubSymbolIntel)
513 .values(rows)
514 .on_conflict_do_nothing()
515 )
516 await db_session.flush()
517
518 start = time.monotonic()
519 await _run_provider(db_session, repo.repo_id)
520 elapsed = time.monotonic() - start
521 assert elapsed < 5.0, f"1000 symbols took {elapsed:.2f}s (limit: 5s)"
522
523
524 # ---------------------------------------------------------------------------
525 # Layer 8 — No subprocess
526 # ---------------------------------------------------------------------------
527
528 class TestBlastRiskNoSubprocess:
529
530 @pytest.mark.asyncio
531 async def test_P1_29_no_subprocess_spawned(
532 self, db_session: AsyncSession
533 ) -> None:
534 repo = await create_repo(db_session)
535 await _seed_symbol(
536 db_session, repo.repo_id,
537 address="pkg/j.py::no_proc_fn",
538 blast=10, churn_30d=3,
539 )
540 with patch("asyncio.create_subprocess_exec") as mock_exec:
541 await _run_provider(db_session, repo.repo_id)
542 mock_exec.assert_not_called()
File History 1 commit
sha256:0997d6250ae6476362f6fe2025af7789f46d03df3e9f34356d5e8ee79b201923 fix(issues): use issue number as pagination cursor, not cre… Sonnet 4.6 patch 8 days ago