gabriel / musehub public
musehub_proposal_risk.py python
189 lines 7.9 KB
Raw
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