musehub_intel.py
python
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