musehub_proposal_risk.py
python
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226
Merge branch 'fix/two-column-scroll-layout' into dev
Human
9 days ago
| 1 | """musehub_proposal_risk — compute risk score for a merge proposal. |
| 2 | |
| 3 | Risk dimensions |
| 4 | --------------- |
| 5 | breakage number of symbol-breaking changes (deleted/renamed exported symbols) |
| 6 | blast proxy blast radius: unique symbols that co-changed with proposal symbols |
| 7 | historically — inferred from the MusehubSymbolIndex without extra |
| 8 | DB queries (symbol_history is already loaded in the detail handler) |
| 9 | symbols total symbols touched (log-scaled contribution) |
| 10 | test_gap modified symbols that have no test-file address in the history |
| 11 | signing whether every agent commit carries an HMAC signature |
| 12 | |
| 13 | Score: 0–100 integer. |
| 14 | Bands: low (0–25) · medium (26–50) · high (51–75) · critical (76–100) |
| 15 | |
| 16 | Design note: all inputs are data already computed inside proposal_detail_page, |
| 17 | so compute_risk() performs zero additional DB queries. |
| 18 | """ |
| 19 | |
| 20 | import math |
| 21 | from dataclasses import dataclass |
| 22 | |
| 23 | from musehub.types.json_types import JSONObject, SymbolHistoryEntry |
| 24 | |
| 25 | type SymbolHistoryDict = dict[str, list[SymbolHistoryEntry]] |
| 26 | type CommitToSymsDict = dict[str, set[str]] |
| 27 | |
| 28 | # ── Risk record ─────────────────────────────────────────────────────────────── |
| 29 | |
| 30 | @dataclass |
| 31 | class ProposalRisk: |
| 32 | score: int # 0–100 |
| 33 | band: str # "low" | "medium" | "high" | "critical" |
| 34 | blast_delta: int # co-changed symbols beyond this proposal's symbol set |
| 35 | breakage_count: int |
| 36 | sym_total: int # sym_added + sym_modified + sym_deleted |
| 37 | agent_commit_ratio: float # 0.0–1.0 |
| 38 | test_gap_count: int # modified/deleted symbols with no test coverage proxy |
| 39 | all_signed: bool |
| 40 | agent_count: int |
| 41 | human_count: int |
| 42 | |
| 43 | # ── convenience helpers ──────────────────────────────────────────────── |
| 44 | @property |
| 45 | def band_color(self) -> str: |
| 46 | return { |
| 47 | "low": "var(--color-success)", |
| 48 | "medium": "var(--color-warning)", |
| 49 | "high": "var(--color-danger)", |
| 50 | "critical": "#ff2244", |
| 51 | }.get(self.band, "var(--text-muted)") |
| 52 | |
| 53 | @property |
| 54 | def score_label(self) -> str: |
| 55 | """Human label for the risk score.""" |
| 56 | if self.score <= 10: |
| 57 | return "Minimal" |
| 58 | if self.score <= 25: |
| 59 | return "Low" |
| 60 | if self.score <= 50: |
| 61 | return "Medium" |
| 62 | if self.score <= 75: |
| 63 | return "High" |
| 64 | return "Critical" |
| 65 | |
| 66 | def as_dict(self) -> JSONObject: |
| 67 | return { |
| 68 | "score": self.score, |
| 69 | "band": self.band, |
| 70 | "band_color": self.band_color, |
| 71 | "score_label": self.score_label, |
| 72 | "blast_delta": self.blast_delta, |
| 73 | "breakage_count": self.breakage_count, |
| 74 | "sym_total": self.sym_total, |
| 75 | "agent_commit_ratio": self.agent_commit_ratio, |
| 76 | "test_gap_count": self.test_gap_count, |
| 77 | "all_signed": self.all_signed, |
| 78 | "agent_count": self.agent_count, |
| 79 | "human_count": self.human_count, |
| 80 | } |
| 81 | |
| 82 | # ── Band helper ─────────────────────────────────────────────────────────────── |
| 83 | |
| 84 | def _band(score: int) -> str: |
| 85 | if score <= 25: |
| 86 | return "low" |
| 87 | if score <= 50: |
| 88 | return "medium" |
| 89 | if score <= 75: |
| 90 | return "high" |
| 91 | return "critical" |
| 92 | |
| 93 | # ── Full risk computation (detail page) ─────────────────────────────────────── |
| 94 | |
| 95 | def compute_risk( |
| 96 | *, |
| 97 | breaking_changes: list[str], |
| 98 | sym_added: int, |
| 99 | sym_modified: int, |
| 100 | sym_deleted: int, |
| 101 | sym_modified_names: list[str], |
| 102 | sym_deleted_names: list[str], |
| 103 | proposal_commits: list[JSONObject], |
| 104 | symbol_history: SymbolHistoryDict, |
| 105 | ) -> ProposalRisk: |
| 106 | """Compute a full risk score from data already in the detail page handler. |
| 107 | |
| 108 | Parameters mirror what ``proposal_detail_page`` has computed before |
| 109 | rendering — no additional queries needed. |
| 110 | |
| 111 | ``symbol_history`` shape: address → [{"commit_id", "op", ...}, ...] |
| 112 | """ |
| 113 | sym_total = sym_added + sym_modified + sym_deleted |
| 114 | breakage_count = len(breaking_changes) |
| 115 | |
| 116 | # ── Agent vs human ratio ────────────────────────────────────────────── |
| 117 | agent_count = sum(1 for c in proposal_commits if c.get("is_agent")) |
| 118 | human_count = len(proposal_commits) - agent_count |
| 119 | agent_commit_ratio = agent_count / max(1, len(proposal_commits)) |
| 120 | all_signed = bool(proposal_commits) and all(c.get("is_signed") for c in proposal_commits) |
| 121 | |
| 122 | # ── Blast delta via commit co-occurrence ────────────────────────────── |
| 123 | # Build reverse index: commit_id → set of addresses that changed in it. |
| 124 | commit_to_syms: CommitToSymsDict = {} |
| 125 | for addr, entries in symbol_history.items(): |
| 126 | for entry in entries: |
| 127 | cid: str | None = entry.get("commit_id") |
| 128 | if cid: |
| 129 | commit_to_syms.setdefault(cid, set()).add(addr) |
| 130 | |
| 131 | # For each symbol in the proposal, collect commit IDs from history, then count |
| 132 | # unique symbols that share those commits but are NOT in the proposal itself. |
| 133 | proposal_symbol_set = set(sym_modified_names) | set(sym_deleted_names) |
| 134 | blast_set: set[str] = set() |
| 135 | |
| 136 | for addr in proposal_symbol_set: |
| 137 | for entry in symbol_history.get(addr, []): |
| 138 | cid = entry.get("commit_id") |
| 139 | if cid: |
| 140 | for co_addr in commit_to_syms.get(cid, ()): |
| 141 | if co_addr not in proposal_symbol_set: |
| 142 | blast_set.add(co_addr) |
| 143 | |
| 144 | blast_delta = len(blast_set) |
| 145 | |
| 146 | # ── Test gap (proxy) ────────────────────────────────────────────────── |
| 147 | # Symbols whose address contains "test" or "spec" are treated as test |
| 148 | # coverage markers. A modified/deleted symbol has a "test gap" if no |
| 149 | # address in the history looks like a test for it. |
| 150 | test_addrs = { |
| 151 | addr for addr in symbol_history |
| 152 | if "test" in addr.lower() or "spec" in addr.lower() |
| 153 | } |
| 154 | test_gap_count = sum( |
| 155 | 1 |
| 156 | for name in sym_modified_names + sym_deleted_names |
| 157 | if not any(name in t or t.startswith(f"{name.split('::')[0]}::test") for t in test_addrs) |
| 158 | ) |
| 159 | test_gap_count = min(test_gap_count, sym_total) |
| 160 | |
| 161 | # ── Score ───────────────────────────────────────────────────────────── |
| 162 | # breakage is the dominant signal (0–40 pts) |
| 163 | breakage_score = min(40, breakage_count * 15) |
| 164 | # symbol breadth (log-scaled, 0–20 pts) |
| 165 | sym_score = min(20, int(math.log1p(sym_total) * 4)) |
| 166 | # blast radius (log-scaled, 0–20 pts) |
| 167 | blast_score = min(20, int(math.log1p(blast_delta) * 4)) |
| 168 | # test gap (0–15 pts) |
| 169 | test_score = min(15, test_gap_count * 2) |
| 170 | # signing lowers risk |
| 171 | sign_bonus = -5 if all_signed else 0 |
| 172 | # high agent ratio slightly lowers risk (agents are precise and sign commits) |
| 173 | agent_bonus = int(-4 * agent_commit_ratio) if agent_count > 0 else 0 |
| 174 | |
| 175 | score = max(0, min(100, breakage_score + sym_score + blast_score + test_score + sign_bonus + agent_bonus)) |
| 176 | |
| 177 | return ProposalRisk( |
| 178 | score=score, |
| 179 | band=_band(score), |
| 180 | blast_delta=blast_delta, |
| 181 | breakage_count=breakage_count, |
| 182 | sym_total=sym_total, |
| 183 | agent_commit_ratio=round(agent_commit_ratio, 2), |
| 184 | test_gap_count=test_gap_count, |
| 185 | all_signed=all_signed, |
| 186 | agent_count=agent_count, |
| 187 | human_count=human_count, |
| 188 | ) |
| 189 |
File History
13 commits
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226
Merge branch 'fix/two-column-scroll-layout' into dev
Human
9 days ago
sha256:408916fc5973ba59c6e4eebaa80ebdcc801c0a63205651e25009d11548f79454
chore: bump version to 0.2.0.dev2 — nightly.2, matching muse
Sonnet 4.6
patch
12 days ago
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2
chore: bump version to 0.2.0rc15 for musehub#113 fix release
Sonnet 4.6
patch
15 days ago
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352
Merge branch 'task/version-tags-phase3-server' into dev
Human
17 days ago
sha256:4669620efda9ff41c55bdefd1f7bfe1c239d468428744c84ead9957e5a003a53
merge: rescue snapshot-recovery hardening (c00aa21d) into d…
Opus 4.8
minor
⚠
30 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a
fix: remove false-positive proposal_comments index drop fro…
Sonnet 4.6
patch
34 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3
feat: render markdown mists as HTML with heading anchor links
Sonnet 4.6
patch
35 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6
Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump …
Human
36 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee
Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump …
Human
36 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c
Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo…
Human
36 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd
Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop…
Human
36 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f
fix: use wire_bytes not mpack_bytes_raw in compute_object_b…
Sonnet 4.6
patch
48 days ago
sha256:b99f2455dc346966d040133f5203297e6e3ef5803a93728a2c30568d0a0f7583
rename: delta_add → delta_upsert across wire format, models…
Sonnet 4.6
patch
50 days ago