gabriel / muse public
predict.py python
909 lines 34.4 KB
Raw
sha256:502e812e12319e30302d07d165425ceeb21687ad30e8af3a439b1f6de36c64d6 fix: update stale tag tests to use label for free-form anno… Sonnet 4.6 19 days ago
1 """muse code predict — predict which symbols will change next.
2
3 Every commit leaves a fingerprint. Symbols that churn repeatedly, that always
4 change together, whose signatures keep shifting, whose modules are accelerating
5 — all of these are signals that a change is coming.
6
7 ``muse code predict`` synthesises five independent signals mined from the full
8 commit DAG and surfaces a ranked leaderboard of symbols statistically most
9 likely to change in the next sprint, with named confidence bands and per-symbol
10 reasoning so you know *why* each prediction was made.
11
12 The five signals
13 ----------------
14
15 1. **Recency** — a symbol touched 2 commits ago has more editing momentum
16 than one touched 200 commits ago. Decays linearly over the ``--horizon``
17 window.
18
19 2. **Frequency** — symbols changed many times within the analysis window are
20 in active development and will continue to evolve.
21
22 3. **Co-change entanglement** — pairs of symbols that co-change in commits at
23 a high Jaccard rate are implicitly coupled. If your partner just changed,
24 you are likely next. This is the hidden-dependency signal only Muse can
25 compute at symbol granularity.
26
27 4. **Signature instability** — a symbol whose signature keeps shifting is
28 still being negotiated. High ratio of signature ops to total ops → likely
29 to change again.
30
31 5. **Module velocity** — symbols living in an accelerating module are more
32 likely to change because the module is still being developed.
33
34 Output::
35
36 Predicted changes (horizon: 50 commits · 315 analysed)
37
38 HIGH CONFIDENCE (score >= 0.70)
39 ─────────────────────────────────────────────────────────────────────
40 0.91 muse/core/store.py::resolve_commit_ref
41 ↳ changed 3 commits ago — editing momentum
42 ↳ entangled with resolve_commit_sha (88% co-change)
43 ↳ muse/core/ velocity: +12 symbols/window
44
45 0.84 muse/cli/commands/dead.py::run
46 ↳ changed 12× in last 50 commits
47 ↳ signature changed 3× — API still evolving
48
49 MEDIUM CONFIDENCE (score 0.45 – 0.70)
50 ─────────────────────────────────────────────────────────────────────
51 0.62 muse/plugins/code/_query.py::flat_symbol_ops
52 ↳ entangled with walk_commits_bfs (75% co-change)
53
54 Explain mode (``--explain ADDRESS``) shows the full signal breakdown for a
55 specific symbol::
56
57 billing.py::Invoice.compute_total — signal breakdown
58
59 Score: 0.91 HIGH
60 ─────────────────────────────────────────────────────
61 recency: 0.95 [████████████████████] changed 3 commits ago
62 frequency: 0.88 [██████████████████ ] 9× in last 50 commits
63 co_change: 0.92 [████████████████████] entangled with process_order
64 sig_instability: 0.50 [██████████ ] 2/4 changes were sig ops
65 module_velocity: 0.75 [███████████████ ] billing/ accelerating
66
67 Top co-change partners
68 billing.py::process_order 100% (9 commits)
69 services.py::create_invoice 78% (7 commits)
70
71 JSON output (``--json``) provides full provenance for every prediction::
72
73 {
74 "generated_at": "2026-03-24T18:00:00",
75 "horizon_commits": 50,
76 "max_commits": 2000,
77 "commits_analysed": 315,
78 "truncated": false,
79 "predictions": [
80 {
81 "address": "muse/core/store.py::resolve_commit_ref",
82 "name": "resolve_commit_ref",
83 "kind": "function",
84 "file": "muse/core/store.py",
85 "score": 0.91,
86 "confidence": "high",
87 "reasons": [ "changed 3 commits ago — editing momentum", ... ],
88 "signals": {
89 "recency": 0.95,
90 "frequency": 0.88,
91 "co_change": 0.92,
92 "sig_instability": 0.5,
93 "module_velocity": 0.75
94 },
95 "last_changed_commit": "f66b7b1d...",
96 "last_changed_date": "2026-03-24",
97 "top_partners": [
98 { "address": "...", "co_change_rate": 0.88, "co_change_commits": 9 }
99 ]
100 }
101 ]
102 }
103
104 Security note
105 -------------
106 Symbol addresses in the output are derived from the content-addressed object
107 store, not from user input. Commit messages used in reason text are sanitised
108 (control characters stripped). ``--explain`` validates the supplied address
109 format before any store access. All file I/O goes through the object store;
110 no user-supplied paths are opened directly.
111 """
112
113 import argparse
114 import datetime
115 import json
116 import logging
117 import pathlib
118 import re
119 import sys
120 from collections import Counter
121 from typing import Iterator, TypedDict
122
123 from muse.core.types import Metadata
124 from muse.core.errors import ExitCode
125 from muse.core.repo import require_repo
126 from muse.core.refs import read_current_branch
127 from muse.core.commits import resolve_commit_ref
128 from muse.core.snapshots import get_commit_snapshot_manifest
129 from muse.core.envelope import EnvelopeJson, make_envelope
130 from muse.core.timing import start_timer
131 from muse.domain import DomainOp
132 from muse.core.commits import CommitRecord
133 from muse.domain import StructuredDelta
134 from muse.plugins.code._query import flat_symbol_ops, symbols_for_snapshot, walk_commits_bfs
135 from muse.core.validation import clamp_int, sanitize_display
136
137 logger = logging.getLogger(__name__)
138
139 type _CounterMap = dict[str, int]
140 type _DateMap = dict[str, datetime.datetime]
141 type _CoCountMap = dict[str, Counter[str]]
142
143 # ── Constants ──────────────────────────────────────────────────────────────────
144
145 _DEFAULT_HORIZON = 50 # recent commits used as prediction window
146 _DEFAULT_MAX_COMMITS = 2_000 # total commits to walk for history
147 _DEFAULT_TOP = 15
148 _MAX_REASONS = 4
149 _MAX_PARTNERS = 5
150 _MAX_CO_MATCH = 20 # top co-change partners to consider per symbol
151
152 # Co-change Jaccard cap: only consider recent_set × recent_set pairs to
153 # keep the matrix bounded at O(|recent_set|²).
154 _MAX_RECENT_SET = 500
155
156 # Signal weights — must sum to 1.0.
157 _W_RECENCY = 0.30
158 _W_FREQUENCY = 0.25
159 _W_CO_CHANGE = 0.25
160 _W_SIG_INSTAB = 0.10
161 _W_MOD_VELOCITY = 0.10
162
163 _CONFIDENCE_HIGH = 0.70
164 _CONFIDENCE_MED = 0.45
165
166 _CTRL_RE = re.compile(r"[\x00-\x09\x0b-\x1f\x7f-\x9f\x1b]")
167 _SIG_KW: frozenset[str] = frozenset({"signature"})
168
169 # ── TypedDicts ─────────────────────────────────────────────────────────────────
170
171 class _SignalSet(TypedDict):
172 recency: float
173 frequency: float
174 co_change: float
175 sig_instability: float
176 module_velocity: float
177
178 class _PartnerRecord(TypedDict):
179 address: str
180 co_change_rate: float
181 co_change_commits: int
182
183 class _PredictionRecord(TypedDict):
184 address: str
185 name: str
186 kind: str
187 file: str
188 score: float
189 confidence: str
190 reasons: list[str]
191 signals: _SignalSet
192 last_changed_commit: str
193 last_changed_date: str
194 top_partners: list[_PartnerRecord]
195
196 class _PredictJson(EnvelopeJson):
197 """Full JSON envelope for ``muse code predict --json``.
198
199 Fields
200 ------
201 generated_at: ISO-8601 UTC timestamp of when the command ran.
202 horizon_commits: ``--horizon`` value used for the prediction window.
203 max_commits: ``--max-commits`` cap on the commit walk.
204 commits_analysed: Actual number of commits visited (≤ ``max_commits``).
205 truncated: ``true`` when the DAG was cut at ``max_commits``.
206 predictions: Ranked list of ``_PredictionRecord`` objects.
207 """
208
209 generated_at: str
210 horizon_commits: int
211 max_commits: int
212 commits_analysed: int
213 truncated: bool
214 predictions: list[_PredictionRecord]
215
216 class _ExplainJson(EnvelopeJson):
217 """Structured JSON envelope for ``muse code predict --explain ADDR --json``.
218
219 Emitted instead of the human-readable signal breakdown when both
220 ``--explain`` and ``--json`` (or ``-j``) are supplied. Provides the
221 full signal vector and co-change partners for a single symbol so agents
222 can parse and act on the breakdown programmatically.
223
224 Fields
225 ------
226 address: Full Muse symbol address that was explained.
227 score: Composite prediction score in [0.0, 1.0].
228 confidence: Human label — ``"high"``, ``"medium"``, or ``"low"``.
229 signals: Per-signal breakdown (``_SignalSet``).
230 reasons: Natural-language reason strings (same as leaderboard).
231 top_partners: Co-change partners ranked by Jaccard rate.
232 horizon_commits: ``--horizon`` value that was active.
233 commits_analysed: Total commits visited during the walk.
234 """
235
236 address: str
237 score: float
238 confidence: str
239 signals: _SignalSet
240 reasons: list[str]
241 top_partners: list[_PartnerRecord]
242 horizon_commits: int
243 commits_analysed: int
244
245 # ── Helpers ────────────────────────────────────────────────────────────────────
246
247 def _sanitise(s: str, max_len: int = 80) -> str:
248 """Strip control characters and truncate *s* to *max_len* display characters.
249
250 Removes ASCII control characters (C0 + C1 ranges), trims leading/trailing
251 whitespace, then appends ``"…"`` if the result exceeds *max_len*. Safe to
252 call on untrusted commit messages before embedding them in output.
253
254 Args:
255 s: Input string, potentially containing control characters.
256 max_len: Maximum output length including the ellipsis (default: 80).
257
258 Returns:
259 Cleaned, length-bounded string.
260 """
261 clean = _CTRL_RE.sub("", s).strip()
262 return f"{clean[:max_len - 1]}…" if len(clean) > max_len else clean
263
264 def _module_key(address: str, depth: int) -> str:
265 """Return the module prefix for *address* at directory depth *depth*."""
266 fp = address.split("::")[0]
267 parts = pathlib.PurePosixPath(fp).parts
268 if len(parts) <= 1:
269 return fp
270 return f"{pathlib.PurePosixPath(*parts[: min(depth, len(parts) - 1)])}/"
271
272 def _pct_bar(value: float, width: int = 20) -> str:
273 """Render *value* ∈ [0, 1] as a filled/empty Unicode block bar of *width* chars.
274
275 Values outside [0, 1] are clamped. Used in ``--explain`` human output to
276 give a visual sense of signal magnitude at a glance.
277
278 Args:
279 value: Signal strength in [0.0, 1.0].
280 width: Total bar width in characters (default: 20).
281
282 Returns:
283 String of length *width* made of ``"█"`` (filled) and ``"░"`` (empty).
284 """
285 filled = round(max(0.0, min(1.0, value)) * width)
286 return f"{'█' * filled}{'░' * (width - filled)}"
287
288 def _confidence_label(score: float) -> str:
289 """Map a composite prediction *score* to a named confidence band.
290
291 Bands mirror the leaderboard section headers:
292
293 - ``"high"`` — score ≥ 0.70 (``_CONFIDENCE_HIGH``)
294 - ``"medium"`` — score ≥ 0.45 (``_CONFIDENCE_MED``)
295 - ``"low"`` — score < 0.45
296
297 Args:
298 score: Composite score in [0.0, 1.0].
299
300 Returns:
301 One of ``"high"``, ``"medium"``, or ``"low"``.
302 """
303 if score >= _CONFIDENCE_HIGH:
304 return "high"
305 if score >= _CONFIDENCE_MED:
306 return "medium"
307 return "low"
308
309 def _iter_symbol_ops(
310 structured_delta: StructuredDelta | None,
311 ) -> Iterator[DomainOp]:
312 """Yield all symbol-level ops from *structured_delta*, handling ``None`` safely.
313
314 Wraps :func:`~muse.plugins.code._query.flat_symbol_ops` with a ``None``
315 guard so callers in the commit-walk loop never need to branch on whether a
316 commit has a structured delta. Only ops whose ``address`` contains ``"::"``
317 are yielded (file-level patch ops are skipped).
318
319 Args:
320 structured_delta: A commit's ``StructuredDelta``, or ``None`` for
321 commits predating the structured-delta format or
322 non-code-domain commits.
323
324 Yields:
325 Symbol-level ``DomainOp`` dicts (contain ``"::"`` in their address).
326 """
327 if structured_delta is None:
328 return
329 yield from flat_symbol_ops(structured_delta["ops"])
330
331 # ── Core prediction engine ─────────────────────────────────────────────────────
332
333 def _build_predictions(
334 commits: list[CommitRecord],
335 horizon: int,
336 module_depth: int,
337 ) -> list[_PredictionRecord]:
338 """Compute prediction records from a commit walk.
339
340 Single-pass design: collects all signals in one iteration over the commits
341 list, then assembles scores and reasons without a second traversal.
342
343 Args:
344 commits: All commits, newest-first (from ``walk_commits_bfs``).
345 horizon: Number of recent commits used as the prediction window.
346 module_depth: Directory depth for module grouping.
347
348 Returns:
349 Unsorted list of ``_PredictionRecord``; caller sorts by score.
350 """
351 # ── Phase 1 — single pass over all commits ────────────────────────────────
352
353 # Per-symbol accumulators.
354 sym_first_pos: _CounterMap = {} # lowest commit index (=most recent)
355 sym_freq: Counter[str] = Counter() # times touched in [0, horizon)
356 sym_sig_changes: Counter[str] = Counter() # sig ops in full history
357 sym_total_changes: Counter[str] = Counter() # all ops in full history
358 sym_last_commit: Metadata = {}
359 sym_last_date: _DateMap = {}
360
361 # Per-commit symbol sets (needed for co-change computation).
362 commit_sym_sets: list[set[str]] = []
363
364 # Module velocity: net symbol changes in current and prior window.
365 window = max(horizon // 2, 1)
366 mod_net_current: Counter[str] = Counter()
367 mod_net_prior: Counter[str] = Counter()
368
369 for i, commit in enumerate(commits):
370 sym_set: set[str] = set()
371
372 for op in _iter_symbol_ops(commit.structured_delta):
373 addr: str = op["address"]
374 sym_set.add(addr)
375 sym_total_changes[addr] += 1
376
377 # Track first (most recent) occurrence.
378 if addr not in sym_first_pos:
379 sym_first_pos[addr] = i
380 sym_last_commit[addr] = commit.commit_id
381 sym_last_date[addr] = commit.committed_at
382
383 # Frequency inside the horizon window.
384 if i < horizon:
385 sym_freq[addr] += 1
386
387 # Signature instability.
388 summary = str(op.get("new_summary") or "").lower()
389 if any(kw in summary for kw in _SIG_KW):
390 sym_sig_changes[addr] += 1
391
392 # Module velocity.
393 op_kind = op.get("op", "")
394 delta = (
395 1 if op_kind == "insert" else (-1 if op_kind == "delete" else 0)
396 )
397 if delta != 0:
398 mod = _module_key(addr, module_depth)
399 if i < window:
400 mod_net_current[mod] += delta
401 elif i < 2 * window:
402 mod_net_prior[mod] += delta
403
404 commit_sym_sets.append(sym_set)
405
406 # ── Phase 2 — co-change matrix (recent_set × recent_set) ─────────────────
407
408 # Build the candidate set: symbols touched in the horizon window.
409 # Cap at _MAX_RECENT_SET to bound matrix size.
410 recent_candidates: list[str] = [
411 addr for addr, freq in sym_freq.most_common(_MAX_RECENT_SET)
412 ]
413 recent_set: frozenset[str] = frozenset(recent_candidates)
414
415 # co_count[a][b] = commits where both a and b (in recent_set) appeared.
416 co_count: _CoCountMap = {sym: Counter() for sym in recent_set}
417 touch_count: Counter[str] = Counter()
418
419 for sym_set in commit_sym_sets:
420 recent_in_commit = sym_set & recent_set
421 for sym in recent_in_commit:
422 touch_count[sym] += 1
423 for partner in recent_in_commit:
424 if partner != sym:
425 co_count[sym][partner] += 1
426
427 # ── Phase 3 — score each candidate ───────────────────────────────────────
428
429 max_freq = max(sym_freq.values()) if sym_freq else 1
430 max_mod_net = max(
431 (abs(v) for v in mod_net_current.values()), default=1
432 )
433 max_mod_net = max(max_mod_net, 1)
434
435 predictions: list[_PredictionRecord] = []
436
437 for addr in recent_set:
438 if "::" not in addr:
439 continue
440
441 file_path = addr.split("::")[0]
442 sym_name = addr.split("::", 1)[1]
443
444 # Signal 1 — recency.
445 pos = sym_first_pos.get(addr, horizon)
446 recency = max(0.0, 1.0 - pos / max(horizon, 1))
447
448 # Signal 2 — frequency.
449 frequency = sym_freq[addr] / max_freq
450
451 # Signal 3 — co-change (best Jaccard with any recent partner).
452 best_rate = 0.0
453 best_partner: str | None = None
454 best_partner_co = 0
455
456 for partner, co in co_count[addr].most_common(_MAX_CO_MATCH):
457 t_a = touch_count[addr]
458 t_b = touch_count.get(partner, 0)
459 denom = t_a + t_b - co
460 if denom > 0:
461 rate = co / denom
462 if rate > best_rate:
463 best_rate = rate
464 best_partner = partner
465 best_partner_co = co
466
467 co_change_score = best_rate
468
469 # Signal 4 — signature instability.
470 total = sym_total_changes[addr]
471 sig_instab = sym_sig_changes[addr] / total if total > 0 else 0.0
472
473 # Signal 5 — module velocity.
474 mod = _module_key(addr, module_depth)
475 mod_vel_raw = mod_net_current.get(mod, 0)
476 mod_vel_score = min(1.0, abs(mod_vel_raw) / max_mod_net)
477
478 # Composite score.
479 score = (
480 _W_RECENCY * recency
481 + _W_FREQUENCY * frequency
482 + _W_CO_CHANGE * co_change_score
483 + _W_SIG_INSTAB * sig_instab
484 + _W_MOD_VELOCITY * mod_vel_score
485 )
486
487 confidence = _confidence_label(score)
488
489 # Reasons — pick the strongest signals.
490 reasons: list[str] = []
491 if recency >= 0.8:
492 n_ago = pos + 1
493 reasons.append(
494 f"changed {n_ago} commit{'s' if n_ago != 1 else ''} ago"
495 " — editing momentum"
496 )
497 if frequency >= 0.60:
498 reasons.append(
499 f"changed {sym_freq[addr]}× in last {horizon} commits"
500 )
501 if co_change_score >= 0.50 and best_partner is not None:
502 partner_bare = best_partner.split("::")[-1]
503 reasons.append(
504 f"entangled with {partner_bare}"
505 f" ({round(best_rate * 100)}% co-change,"
506 f" {best_partner_co} commits)"
507 )
508 if sig_instab >= 0.50:
509 reasons.append(
510 f"signature changed {sym_sig_changes[addr]}×"
511 " — API still evolving"
512 )
513 if mod_vel_raw != 0:
514 sign = "+" if mod_vel_raw > 0 else ""
515 reasons.append(
516 f"{mod} velocity: {sign}{mod_vel_raw} symbols/window"
517 )
518 if not reasons:
519 reasons.append(f"touched within last {horizon} commits")
520
521 reasons = reasons[:_MAX_REASONS]
522
523 # Top co-change partners (for --explain and JSON).
524 top_partners: list[_PartnerRecord] = []
525 for partner, co in co_count[addr].most_common(_MAX_PARTNERS):
526 t_a = touch_count[addr]
527 t_b = touch_count.get(partner, 0)
528 denom = t_a + t_b - co
529 rate = co / denom if denom > 0 else 0.0
530 if rate > 0:
531 top_partners.append(
532 _PartnerRecord(
533 address=partner,
534 co_change_rate=round(rate, 3),
535 co_change_commits=co,
536 )
537 )
538
539 predictions.append(
540 _PredictionRecord(
541 address=addr,
542 name=sym_name.split(".")[-1],
543 kind="symbol", # enriched later from HEAD snapshot
544 file=file_path,
545 score=round(score, 4),
546 confidence=confidence,
547 reasons=reasons,
548 signals=_SignalSet(
549 recency=round(recency, 3),
550 frequency=round(frequency, 3),
551 co_change=round(co_change_score, 3),
552 sig_instability=round(sig_instab, 3),
553 module_velocity=round(mod_vel_score, 3),
554 ),
555 last_changed_commit=sym_last_commit.get(addr, ""),
556 last_changed_date=(
557 sym_last_date[addr].strftime("%Y-%m-%d")
558 if addr in sym_last_date
559 else ""
560 ),
561 top_partners=top_partners,
562 )
563 )
564
565 predictions.sort(key=lambda r: r["score"], reverse=True)
566 return predictions
567
568 # ── Formatters ─────────────────────────────────────────────────────────────────
569
570 def _print_leaderboard(
571 predictions: list[_PredictionRecord],
572 top: int,
573 min_confidence: float,
574 horizon: int,
575 commits_analysed: int,
576 file_filter: str | None,
577 kind_filter: str | None,
578 ) -> None:
579 """Print the ranked prediction leaderboard to stdout."""
580 filtered = [
581 r for r in predictions
582 if r["score"] >= min_confidence
583 and (file_filter is None or file_filter in r["file"])
584 and (kind_filter is None or r["kind"] == kind_filter)
585 ]
586 if top > 0:
587 filtered = filtered[:top]
588
589 if not filtered:
590 print(" No predictions meet the current filters.")
591 return
592
593 print(
594 f"\n Predicted changes"
595 f" (horizon: {horizon} commits · {commits_analysed} analysed)\n"
596 )
597
598 bands: list[tuple[str, float, float]] = [
599 ("HIGH CONFIDENCE", _CONFIDENCE_HIGH, 1.01),
600 ("MEDIUM CONFIDENCE", _CONFIDENCE_MED, _CONFIDENCE_HIGH),
601 ("LOW CONFIDENCE", 0.0, _CONFIDENCE_MED),
602 ]
603
604 for band_label, band_lo, band_hi in bands:
605 band_rows = [r for r in filtered if band_lo <= r["score"] < band_hi]
606 if not band_rows:
607 continue
608
609 header_suffix = (
610 "score >= 0.70" if band_lo >= _CONFIDENCE_HIGH
611 else f"score {band_lo:.2f} – {band_hi:.2f}" if band_hi < 1.0
612 else f"score < {_CONFIDENCE_MED:.2f}"
613 )
614 print(f" {band_label} ({header_suffix})")
615 print(f" {'─' * 69}")
616
617 for row in band_rows:
618 print(f" {row['score']:.2f} {sanitize_display(row['address'])}")
619 for reason in row["reasons"]:
620 print(f" ↳ {reason}")
621 print()
622
623 def _print_explain(record: _PredictionRecord, horizon: int) -> None:
624 """Print a detailed signal breakdown for a single symbol."""
625 confidence_label = record["confidence"].upper()
626 print(f"\n {sanitize_display(record['address'])} — signal breakdown\n")
627 print(f" Score: {record['score']:.2f} {confidence_label}")
628 print(f" {'─' * 53}")
629
630 sig_labels: list[tuple[str, float, str]] = [
631 ("recency", record["signals"]["recency"], f"changed recently (horizon={horizon})"),
632 ("frequency", record["signals"]["frequency"], "change frequency in window"),
633 ("co_change", record["signals"]["co_change"], "co-change entanglement"),
634 ("sig_instability", record["signals"]["sig_instability"], "signature churn ratio"),
635 ("module_velocity", record["signals"]["module_velocity"], "module growth acceleration"),
636 ]
637 for label, value, desc in sig_labels:
638 bar = _pct_bar(value, width=20)
639 print(f" {label:<20} {value:.2f} [{bar}] {desc}")
640
641 print()
642 if record["reasons"]:
643 print(" Reasons")
644 for r in record["reasons"]:
645 print(f" ↳ {r}")
646 print()
647
648 if record["top_partners"]:
649 print(f" Top co-change partners")
650 for p in record["top_partners"]:
651 bare = p["address"].split("::")[-1]
652 pct = round(p["co_change_rate"] * 100)
653 print(
654 f" {bare:<40} {pct:>3}% ({p['co_change_commits']} commits)"
655 )
656 print()
657
658 # ── Entry point ────────────────────────────────────────────────────────────────
659
660 def run(args: argparse.Namespace) -> None:
661 """Entry point for ``muse code predict``.
662
663 Walks the commit DAG, computes five independent signals per symbol, and
664 either prints a ranked leaderboard (human mode) or emits a JSON envelope
665 (``--json`` / ``-j``). When ``--explain ADDRESS`` is combined with
666 ``--json``, emits a single ``_ExplainJson`` object instead of the full
667 predictions list so agents can parse the signal breakdown for one symbol.
668
669 JSON envelope fields
670 --------------------
671 Full predict: ``_PredictJson`` — see that TypedDict for field docs.
672 Explain mode: ``_ExplainJson`` — see that TypedDict for field docs.
673
674 Both envelopes always include ``exit_code`` (``0``) and ``duration_ms``.
675 """
676 elapsed = start_timer()
677 root = require_repo()
678
679 # ── Validate arguments ────────────────────────────────────────────────────
680 horizon: int = clamp_int(args.horizon, 1, 1000, 'horizon')
681 max_commits: int = clamp_int(args.max_commits, 1, 100000, 'max_commits')
682 top: int = clamp_int(args.top, 0, 10000, 'top')
683 min_confidence: float = args.min_confidence
684 module_depth: int = clamp_int(args.module_depth, 1, 50, 'module_depth')
685
686 if horizon < 1:
687 print("❌ --horizon must be >= 1.", file=sys.stderr)
688 raise SystemExit(ExitCode.USER_ERROR)
689 if max_commits < 1:
690 print("❌ --max-commits must be >= 1.", file=sys.stderr)
691 raise SystemExit(ExitCode.USER_ERROR)
692 if not (0.0 <= min_confidence <= 1.0):
693 print("❌ --min-confidence must be between 0.0 and 1.0.", file=sys.stderr)
694 raise SystemExit(ExitCode.USER_ERROR)
695 if module_depth < 1:
696 print("❌ --module-depth must be >= 1.", file=sys.stderr)
697 raise SystemExit(ExitCode.USER_ERROR)
698
699 explain_addr: str | None = getattr(args, "explain", None)
700 if explain_addr is not None and "::" not in explain_addr:
701 print(
702 "❌ --explain ADDRESS must be in ``file::Symbol`` format.",
703 file=sys.stderr,
704 )
705 raise SystemExit(ExitCode.USER_ERROR)
706
707 # ── Resolve HEAD ──────────────────────────────────────────────────────────
708 branch = read_current_branch(root)
709 head = resolve_commit_ref(root, branch, None)
710 if head is None:
711 print(
712 "❌ HEAD commit not found — is this an empty repository?",
713 file=sys.stderr,
714 )
715 raise SystemExit(ExitCode.USER_ERROR)
716
717 # ── Walk commits ──────────────────────────────────────────────────────────
718 commits, truncated = walk_commits_bfs(root, head.commit_id, max_commits)
719 if not commits:
720 print(" No commits found — nothing to predict.", file=sys.stderr)
721 raise SystemExit(ExitCode.USER_ERROR)
722
723 # ── Build predictions ─────────────────────────────────────────────────────
724 predictions = _build_predictions(commits, horizon, module_depth)
725
726 # ── Enrich kind from HEAD snapshot ───────────────────────────────────────
727 manifest = get_commit_snapshot_manifest(root, head.commit_id) or {}
728 all_trees = symbols_for_snapshot(root, manifest)
729 addr_to_kind: Metadata = {}
730 for sym_tree in all_trees.values():
731 for addr, rec in sym_tree.items():
732 addr_to_kind[addr] = rec.get("kind", "symbol")
733
734 for pred in predictions:
735 pred["kind"] = addr_to_kind.get(pred["address"], "symbol")
736
737 # ── Apply kind/file filters ───────────────────────────────────────────────
738 file_filter: str | None = getattr(args, "file", None)
739 kind_filter: str | None = getattr(args, "kind", None)
740
741 # ── Output ────────────────────────────────────────────────────────────────
742 if args.json_out:
743 if explain_addr is not None:
744 # --explain --json → structured single-symbol breakdown.
745 match = next(
746 (r for r in predictions if r["address"] == explain_addr), None
747 )
748 if match is None:
749 print(
750 f"❌ {explain_addr!r} not found in prediction set."
751 f" It may not have been touched in the last"
752 f" {horizon} commits.",
753 file=sys.stderr,
754 )
755 raise SystemExit(ExitCode.USER_ERROR)
756 print(json.dumps(_ExplainJson(
757 **make_envelope(elapsed),
758 address=match["address"],
759 score=match["score"],
760 confidence=match["confidence"],
761 signals=match["signals"],
762 reasons=match["reasons"],
763 top_partners=match["top_partners"],
764 horizon_commits=horizon,
765 commits_analysed=len(commits),
766 )))
767 return
768
769 filtered = [
770 r for r in predictions
771 if r["score"] >= min_confidence
772 and (file_filter is None or file_filter in r["file"])
773 and (kind_filter is None or r["kind"] == kind_filter)
774 ]
775 if top > 0:
776 filtered = filtered[:top]
777 print(json.dumps(_PredictJson(
778 **make_envelope(elapsed),
779 generated_at=(
780 datetime.datetime.now(datetime.timezone.utc)
781 .strftime("%Y-%m-%dT%H:%M:%S")
782 ),
783 horizon_commits=horizon,
784 max_commits=max_commits,
785 commits_analysed=len(commits),
786 truncated=truncated,
787 predictions=filtered,
788 )))
789 return
790
791 if explain_addr is not None:
792 # Human-readable explain mode.
793 match = next(
794 (r for r in predictions if r["address"] == explain_addr), None
795 )
796 if match is None:
797 print(
798 f"❌ {explain_addr!r} not found in prediction set."
799 f" It may not have been touched in the last"
800 f" {horizon} commits.",
801 file=sys.stderr,
802 )
803 raise SystemExit(ExitCode.USER_ERROR)
804 _print_explain(match, horizon)
805 return
806
807 _print_leaderboard(
808 predictions,
809 top,
810 min_confidence,
811 horizon,
812 len(commits),
813 file_filter,
814 kind_filter,
815 )
816
817 # ── CLI registration ───────────────────────────────────────────────────────────
818
819 def register(
820 sub: argparse._SubParsersAction[argparse.ArgumentParser],
821 ) -> None:
822 """Register ``predict`` under the ``code`` subcommand group."""
823 p = sub.add_parser(
824 "predict",
825 help=(
826 "Predict which symbols will change next based on recency,"
827 " frequency, co-change entanglement, signature instability,"
828 " and module velocity."
829 ),
830 description=__doc__,
831 formatter_class=argparse.RawDescriptionHelpFormatter,
832 )
833 p.add_argument(
834 "--horizon",
835 type=int,
836 default=_DEFAULT_HORIZON,
837 metavar="N",
838 help=(
839 f"Number of recent commits used as the prediction window"
840 f" (default: {_DEFAULT_HORIZON})."
841 ),
842 )
843 p.add_argument(
844 "--max-commits",
845 type=int,
846 default=_DEFAULT_MAX_COMMITS,
847 metavar="N",
848 help=(
849 f"Maximum commits to walk for full history signals"
850 f" (default: {_DEFAULT_MAX_COMMITS})."
851 ),
852 )
853 p.add_argument(
854 "--top",
855 type=int,
856 default=_DEFAULT_TOP,
857 metavar="N",
858 help=(
859 f"Show top N predictions (default: {_DEFAULT_TOP};"
860 " 0 = all)."
861 ),
862 )
863 p.add_argument(
864 "--min-confidence",
865 type=float,
866 default=0.0,
867 metavar="F",
868 help=(
869 "Minimum score threshold in [0.0, 1.0] (default: 0.0 = show all)."
870 ),
871 )
872 p.add_argument(
873 "--explain",
874 metavar="ADDRESS",
875 help=(
876 "Show the full signal breakdown for a specific symbol"
877 " (e.g. billing.py::Invoice.compute_total)."
878 ),
879 )
880 p.add_argument(
881 "--kind",
882 metavar="KIND",
883 help=(
884 "Filter predictions to a specific symbol kind"
885 " (function, method, class, …)."
886 ),
887 )
888 p.add_argument(
889 "--file",
890 metavar="PATTERN",
891 help="Filter predictions to symbols whose file path contains PATTERN.",
892 )
893 p.add_argument(
894 "--module-depth",
895 type=int,
896 default=2,
897 metavar="D",
898 help=(
899 "Directory depth used to group symbols into modules"
900 " for the velocity signal (default: 2)."
901 ),
902 )
903 p.add_argument(
904 "--json", "-j",
905 action="store_true",
906 dest="json_out",
907 help="Emit JSON instead of human-readable text (see _PredictJson / _ExplainJson).",
908 )
909 p.set_defaults(func=run)
File History 1 commit
sha256:502e812e12319e30302d07d165425ceeb21687ad30e8af3a439b1f6de36c64d6 fix: update stale tag tests to use label for free-form anno… Sonnet 4.6 19 days ago