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