gabriel / musehub public
musehub_intel.py python
444 lines 17.4 KB
Raw
sha256:e519738f2102d625d47828a17d113283cd744577b43551013cb668fd04f1c3fe chore(mwp8/phase5): tick all RG checkboxes; close-out doc (… Sonnet 4.6 18 days ago
1 """MuseHub Intelligence Service — Phase 3.
2
3 Computes ``IntelSnapshot`` from the symbol history index.
4 All computations run entirely from the already-loaded ``symbol_history``
5 dict (address → list[{commit_id, op, ...}]) — zero additional DB queries
6 beyond the initial index fetch.
7
8 Health formula (0–100):
9 100 - dead_penalty(0–25) - hotspot_penalty(0–25)
10 - coupling_penalty(0–25) - breakage_penalty(0–25)
11 """
12
13 import math
14 from dataclasses import dataclass, field
15 from datetime import datetime, timezone
16 from musehub.types.json_types import IntDict, JSONObject, JSONValue, SymbolHistoryEntry, jint
17
18 type SymbolHistoryDict = dict[str, list[SymbolHistoryEntry]]
19 type CommitSymsDict = dict[str, set[str]]
20 type SymDateMap = dict[str, datetime]
21
22 # ── Data classes ──────────────────────────────────────────────────────────────
23
24 @dataclass
25 class HotspotEntry:
26 address: str
27 change_count: int
28 last_changed: str | None # ISO-8601 or None
29
30 @dataclass
31 class DeadEntry:
32 address: str
33 days_cold: int
34 blast_radius: int
35 added_at: str | None # ISO-8601 or None
36
37 @dataclass
38 class BlastRiskEntry:
39 address: str
40 co_change_count: int
41 top_co_symbols: list[str]
42
43 @dataclass
44 class CouplingPair:
45 address_a: str
46 address_b: str
47 shared_commits: int
48
49 @dataclass
50 class VelocityWindow:
51 """Commits per week, newest-first (12 windows = 12 weeks)."""
52 weeks: list[int] # len == 12, index 0 = most recent
53
54 @dataclass
55 class IntelSnapshot:
56 # Health
57 health_score: int # 0–100
58 health_label: str # "Excellent" | "Good" | "Fair" | "Poor" | "Critical"
59
60 # Alerts (rendered in the alert strip)
61 alert_hotspot_count: int # symbols with > hotspot_threshold changes
62 alert_dead_count: int # cold candidates (>= 90 days no change)
63 alert_blast_risk_count: int # symbols with blast radius > blast_threshold
64 alert_breaking_count: int # breaking changes in last 30 days
65
66 # Panel data (top-N lists for the dashboard cards)
67 hotspots: list[HotspotEntry]
68 dead_candidates: list[DeadEntry]
69 blast_risk: list[BlastRiskEntry]
70 coupling_pairs: list[CouplingPair]
71 velocity: VelocityWindow
72
73 # Totals
74 total_symbols: int
75 total_commits_indexed: int
76
77 def as_dict(self) -> JSONObject:
78 return {
79 "health_score": self.health_score,
80 "health_label": self.health_label,
81 "alert_hotspot_count": self.alert_hotspot_count,
82 "alert_dead_count": self.alert_dead_count,
83 "alert_blast_risk_count": self.alert_blast_risk_count,
84 "alert_breaking_count": self.alert_breaking_count,
85 "hotspots": [
86 {
87 "address": h.address,
88 "change_count": h.change_count,
89 "last_changed": h.last_changed,
90 }
91 for h in self.hotspots
92 ],
93 "dead_candidates": [
94 {
95 "address": d.address,
96 "days_cold": d.days_cold,
97 "blast_radius": d.blast_radius,
98 "added_at": d.added_at,
99 }
100 for d in self.dead_candidates
101 ],
102 "blast_risk": [
103 {
104 "address": b.address,
105 "co_change_count": b.co_change_count,
106 "top_co_symbols": _str_list_to_json(b.top_co_symbols),
107 }
108 for b in self.blast_risk
109 ],
110 "coupling_pairs": [
111 {
112 "address_a": c.address_a,
113 "address_b": c.address_b,
114 "shared_commits": c.shared_commits,
115 }
116 for c in self.coupling_pairs
117 ],
118 "velocity": {"weeks": _int_list_to_json(self.velocity.weeks)},
119 "total_symbols": self.total_symbols,
120 "total_commits_indexed": self.total_commits_indexed,
121 }
122
123 @classmethod
124 def from_dict(cls, d: JSONObject) -> "IntelSnapshot":
125 """Reconstruct an IntelSnapshot from the dict produced by as_dict()."""
126 raw_hotspots = d.get("hotspots", [])
127 hotspots: list[HotspotEntry] = []
128 if isinstance(raw_hotspots, list):
129 for h in raw_hotspots:
130 if isinstance(h, dict):
131 addr = h.get("address")
132 lc = h.get("last_changed")
133 hotspots.append(HotspotEntry(
134 address=addr if isinstance(addr, str) else "",
135 change_count=jint(h.get("change_count")),
136 last_changed=lc if isinstance(lc, str) else None,
137 ))
138
139 raw_dead = d.get("dead_candidates", [])
140 dead_candidates: list[DeadEntry] = []
141 if isinstance(raw_dead, list):
142 for dc in raw_dead:
143 if isinstance(dc, dict):
144 addr = dc.get("address")
145 added = dc.get("added_at")
146 dead_candidates.append(DeadEntry(
147 address=addr if isinstance(addr, str) else "",
148 days_cold=jint(dc.get("days_cold")),
149 blast_radius=jint(dc.get("blast_radius")),
150 added_at=added if isinstance(added, str) else None,
151 ))
152
153 raw_blast = d.get("blast_risk", [])
154 blast_risk: list[BlastRiskEntry] = []
155 if isinstance(raw_blast, list):
156 for b in raw_blast:
157 if isinstance(b, dict):
158 addr = b.get("address")
159 raw_top = b.get("top_co_symbols", [])
160 top_co: list[str] = (
161 [s for s in raw_top if isinstance(s, str)]
162 if isinstance(raw_top, list) else []
163 )
164 blast_risk.append(BlastRiskEntry(
165 address=addr if isinstance(addr, str) else "",
166 co_change_count=jint(b.get("co_change_count")),
167 top_co_symbols=top_co,
168 ))
169
170 raw_coupling = d.get("coupling_pairs", [])
171 coupling_pairs: list[CouplingPair] = []
172 if isinstance(raw_coupling, list):
173 for c in raw_coupling:
174 if isinstance(c, dict):
175 addr_a = c.get("address_a")
176 addr_b = c.get("address_b")
177 coupling_pairs.append(CouplingPair(
178 address_a=addr_a if isinstance(addr_a, str) else "",
179 address_b=addr_b if isinstance(addr_b, str) else "",
180 shared_commits=jint(c.get("shared_commits")),
181 ))
182
183 raw_velocity = d.get("velocity", {})
184 weeks: list[int] = []
185 if isinstance(raw_velocity, dict):
186 raw_weeks = raw_velocity.get("weeks", [])
187 if isinstance(raw_weeks, list):
188 weeks = [jint(w) for w in raw_weeks]
189
190 return cls(
191 health_score=jint(d.get("health_score")),
192 health_label=(
193 hl if isinstance(hl := d.get("health_label"), str) else ""
194 ),
195 alert_hotspot_count=jint(d.get("alert_hotspot_count")),
196 alert_dead_count=jint(d.get("alert_dead_count")),
197 alert_blast_risk_count=jint(d.get("alert_blast_risk_count")),
198 alert_breaking_count=jint(d.get("alert_breaking_count")),
199 hotspots=hotspots,
200 dead_candidates=dead_candidates,
201 blast_risk=blast_risk,
202 coupling_pairs=coupling_pairs,
203 velocity=VelocityWindow(weeks=weeks),
204 total_symbols=jint(d.get("total_symbols")),
205 total_commits_indexed=jint(d.get("total_commits_indexed")),
206 )
207
208 # ── Thresholds ────────────────────────────────────────────────────────────────
209
210 _HOTSPOT_THRESHOLD = 10 # changes to be considered a hotspot
211 _DEAD_COLD_DAYS = 90 # days since last change to be a dead candidate
212 _BLAST_THRESHOLD = 20 # co-change radius to trigger blast alert
213 _TOP_N = 10 # items returned in each panel list
214 _TOP_COUPLING = 5 # coupling pairs returned
215 _VELOCITY_WEEKS = 12 # weeks in the velocity sparkline
216
217 # ── Main entry point ──────────────────────────────────────────────────────────
218
219 def compute_intel(
220 symbol_history: SymbolHistoryDict,
221 recent_breaking_changes: list[str],
222 now_utc: datetime | None = None,
223 ) -> IntelSnapshot:
224 """Compute a full IntelSnapshot from the symbol history index.
225
226 Parameters
227 ----------
228 symbol_history:
229 address → list of history entries, each a dict with at minimum:
230 ``{"commit_id": str, "op": str}``; optionally ``"timestamp": str`` (ISO-8601).
231 recent_breaking_changes:
232 List of breaking-change strings from recent commits (last 30 days).
233 Caller is responsible for filtering to the relevant time window.
234 now_utc:
235 Override for "now" — used in tests.
236 """
237 if now_utc is None:
238 now_utc = datetime.now(tz=timezone.utc)
239
240 # ── 1. Pre-compute per-symbol change counts and last-changed timestamps ──
241 sym_change_count: IntDict = {}
242 sym_last_ts: SymDateMap = {}
243 sym_first_ts: SymDateMap = {}
244 commit_syms: CommitSymsDict = {} # commit_id → {addresses}
245 all_commit_ids: set[str] = set()
246 # Weekly commit buckets for velocity (newest = bucket 0)
247 week_buckets: list[int] = [0] * _VELOCITY_WEEKS
248 _secs_per_week = 7 * 86_400
249
250 for addr, entries in symbol_history.items():
251 count = len(entries)
252 sym_change_count[addr] = count
253
254 for entry in entries:
255 cid = entry.get("commit_id")
256 if cid:
257 all_commit_ids.add(cid)
258 commit_syms.setdefault(cid, set()).add(addr)
259
260 ts_raw = entry.get("committed_at") or entry.get("timestamp") or entry.get("ts")
261 if ts_raw:
262 try:
263 ts = _parse_ts(ts_raw)
264 if addr not in sym_last_ts or ts > sym_last_ts[addr]:
265 sym_last_ts[addr] = ts
266 if addr not in sym_first_ts or ts < sym_first_ts[addr]:
267 sym_first_ts[addr] = ts
268 # Velocity bucketing
269 age_secs = (now_utc - ts).total_seconds()
270 bucket = int(age_secs / _secs_per_week)
271 if 0 <= bucket < _VELOCITY_WEEKS:
272 week_buckets[bucket] += 1
273 except Exception:
274 pass
275
276 total_symbols = len(symbol_history)
277 total_commits_indexed = len(all_commit_ids)
278
279 # ── 2. Hotspots — symbols ranked by change frequency ──────────────────
280 sorted_by_chg = sorted(
281 sym_change_count.items(), key=lambda kv: kv[1], reverse=True
282 )
283 hotspot_threshold_actual = max(_HOTSPOT_THRESHOLD, 1)
284 hotspot_entries: list[HotspotEntry] = []
285 for addr, count in sorted_by_chg[:_TOP_N]:
286 last_ts = sym_last_ts.get(addr)
287 hotspot_entries.append(HotspotEntry(
288 address=addr,
289 change_count=count,
290 last_changed=last_ts.isoformat() if last_ts else None,
291 ))
292 alert_hotspot_count = sum(
293 1 for _, c in sorted_by_chg if c >= hotspot_threshold_actual
294 )
295
296 # ── 3. Dead candidates — added but rarely/never modified ──────────────
297 dead_entries: list[DeadEntry] = []
298 for addr, entries in symbol_history.items():
299 if len(entries) > 3:
300 # Modified more than twice — not dead
301 continue
302 last_ts = sym_last_ts.get(addr)
303 if last_ts is None:
304 continue
305 days_cold = (now_utc - last_ts).days
306 if days_cold < _DEAD_COLD_DAYS:
307 continue
308 # Compute blast radius: how many unique other symbols share commits with this one
309 shared_addrs: set[str] = set()
310 for entry in entries:
311 cid = entry.get("commit_id")
312 if cid:
313 for co in commit_syms.get(cid, ()):
314 if co != addr:
315 shared_addrs.add(co)
316 first_ts = sym_first_ts.get(addr)
317 dead_entries.append(DeadEntry(
318 address=addr,
319 days_cold=days_cold,
320 blast_radius=len(shared_addrs),
321 added_at=first_ts.isoformat() if first_ts else None,
322 ))
323 dead_entries.sort(key=lambda d: d.days_cold, reverse=True)
324 dead_top = dead_entries[:_TOP_N]
325 alert_dead_count = len(dead_entries)
326
327 # ── 4. Blast risk — symbols with highest co-change radius ─────────────
328 blast_risk_entries: list[BlastRiskEntry] = []
329 for addr, entries in symbol_history.items():
330 co_addrs: IntDict = {}
331 for entry in entries:
332 cid = entry.get("commit_id")
333 if cid:
334 for co in commit_syms.get(cid, ()):
335 if co != addr:
336 co_addrs[co] = co_addrs.get(co, 0) + 1
337 if not co_addrs:
338 continue
339 total_co = sum(co_addrs.values())
340 top_co = sorted(co_addrs, key=lambda k: co_addrs[k], reverse=True)[:5]
341 blast_risk_entries.append(BlastRiskEntry(
342 address=addr,
343 co_change_count=len(co_addrs),
344 top_co_symbols=top_co,
345 ))
346 blast_risk_entries.sort(key=lambda b: b.co_change_count, reverse=True)
347 blast_top = blast_risk_entries[:_TOP_N]
348 alert_blast_risk_count = sum(
349 1 for b in blast_risk_entries if b.co_change_count > _BLAST_THRESHOLD
350 )
351
352 # ── 5. Coupling pairs — top frequently co-changed symbol pairs ─────────
353 # Count all pairs (a, b) where a < b that appear in the same commit.
354 pair_counts: dict[tuple[str, str], int] = {}
355 for cid, syms in commit_syms.items():
356 syms_list = sorted(syms)
357 for i, a in enumerate(syms_list):
358 for b in syms_list[i + 1:]:
359 key = (a, b)
360 pair_counts[key] = pair_counts.get(key, 0) + 1
361
362 coupling_pairs: list[CouplingPair] = [
363 CouplingPair(address_a=a, address_b=b, shared_commits=cnt)
364 for (a, b), cnt in sorted(
365 pair_counts.items(), key=lambda kv: kv[1], reverse=True
366 )[:_TOP_COUPLING]
367 ]
368
369 # ── 6. Health score ────────────────────────────────────────────────────
370 # Each penalty is 0–25. Sum is subtracted from 100.
371 dead_ratio = min(alert_dead_count / max(total_symbols, 1), 1.0)
372 dead_penalty = round(dead_ratio * 25)
373
374 hotspot_ratio = min(alert_hotspot_count / max(total_symbols, 1), 1.0)
375 hotspot_penalty = round(math.sqrt(hotspot_ratio) * 25)
376
377 coupling_penalty = min(len(coupling_pairs), 25)
378
379 recent_break_count = len(recent_breaking_changes)
380 breakage_penalty = min(recent_break_count * 5, 25)
381
382 health_score = max(0, 100 - dead_penalty - hotspot_penalty - coupling_penalty - breakage_penalty)
383 health_label = _health_label(health_score)
384
385 return IntelSnapshot(
386 health_score=health_score,
387 health_label=health_label,
388 alert_hotspot_count=alert_hotspot_count,
389 alert_dead_count=alert_dead_count,
390 alert_blast_risk_count=alert_blast_risk_count,
391 alert_breaking_count=len(recent_breaking_changes),
392 hotspots=hotspot_entries,
393 dead_candidates=dead_top,
394 blast_risk=blast_top,
395 coupling_pairs=coupling_pairs,
396 velocity=VelocityWindow(weeks=week_buckets),
397 total_symbols=total_symbols,
398 total_commits_indexed=total_commits_indexed,
399 )
400
401 # ── Helpers ───────────────────────────────────────────────────────────────────
402
403 def _str_list_to_json(items: list[str]) -> list[JSONValue]:
404 """Convert list[str] to list[JSONValue] — needed because list is invariant."""
405 out: list[JSONValue] = []
406 for s in items:
407 out.append(s)
408 return out
409
410 def _int_list_to_json(items: list[int]) -> list[JSONValue]:
411 """Convert list[int] to list[JSONValue] — needed because list is invariant."""
412 out: list[JSONValue] = []
413 for i in items:
414 out.append(i)
415 return out
416
417 def _parse_ts(raw: str | int | float) -> datetime:
418 if isinstance(raw, (int, float)):
419 return datetime.fromtimestamp(raw, tz=timezone.utc)
420 # ISO-8601 string — strip trailing Z
421 s = str(raw).replace("Z", "+00:00")
422 return datetime.fromisoformat(s)
423
424 def _health_label(score: int) -> str:
425 if score >= 90:
426 return "Excellent"
427 if score >= 75:
428 return "Good"
429 if score >= 55:
430 return "Fair"
431 if score >= 35:
432 return "Poor"
433 return "Critical"
434
435 def _health_color_class(score: int) -> str:
436 if score >= 90:
437 return "intel-health--excellent"
438 if score >= 75:
439 return "intel-health--good"
440 if score >= 55:
441 return "intel-health--fair"
442 if score >= 35:
443 return "intel-health--poor"
444 return "intel-health--critical"
File History 2 commits
sha256:e519738f2102d625d47828a17d113283cd744577b43551013cb668fd04f1c3fe chore(mwp8/phase5): tick all RG checkboxes; close-out doc (… Sonnet 4.6 18 days ago
sha256:0e5174da777684df43b2db71f282079030ef28bacffb93e19c29ee712b2a8bc4 test(mwp8/phase0): audit — repair+force-push leave stale ca… Sonnet 4.6 20 days ago