predict.py python
795 lines 28.9 KB
Raw
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 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.domain import DomainOp
134 from muse.core.store import CommitRecord
135 from muse.domain import StructuredDelta
136 from muse.plugins.code._query import flat_symbol_ops, symbols_for_snapshot, walk_commits_bfs
137 from muse.core.validation import clamp_int, sanitize_display
138
139 logger = logging.getLogger(__name__)
140
141 type _CounterMap = dict[str, int]
142 type _DateMap = dict[str, datetime.datetime]
143 type _CoCountMap = dict[str, Counter[str]]
144
145 # ── Constants ──────────────────────────────────────────────────────────────────
146
147 _DEFAULT_HORIZON = 50 # recent commits used as prediction window
148 _DEFAULT_MAX_COMMITS = 2_000 # total commits to walk for history
149 _DEFAULT_TOP = 15
150 _MAX_REASONS = 4
151 _MAX_PARTNERS = 5
152 _MAX_CO_MATCH = 20 # top co-change partners to consider per symbol
153
154 # Co-change Jaccard cap: only consider recent_set × recent_set pairs to
155 # keep the matrix bounded at O(|recent_set|²).
156 _MAX_RECENT_SET = 500
157
158 # Signal weights — must sum to 1.0.
159 _W_RECENCY = 0.30
160 _W_FREQUENCY = 0.25
161 _W_CO_CHANGE = 0.25
162 _W_SIG_INSTAB = 0.10
163 _W_MOD_VELOCITY = 0.10
164
165 _CONFIDENCE_HIGH = 0.70
166 _CONFIDENCE_MED = 0.45
167
168 _CTRL_RE = re.compile(r"[\x00-\x09\x0b-\x1f\x7f-\x9f\x1b]")
169 _SIG_KW: frozenset[str] = frozenset({"signature"})
170
171
172 # ── TypedDicts ─────────────────────────────────────────────────────────────────
173
174
175 class _SignalSet(TypedDict):
176 recency: float
177 frequency: float
178 co_change: float
179 sig_instability: float
180 module_velocity: float
181
182
183 class _PartnerRecord(TypedDict):
184 address: str
185 co_change_rate: float
186 co_change_commits: int
187
188
189 class _PredictionRecord(TypedDict):
190 address: str
191 name: str
192 kind: str
193 file: str
194 score: float
195 confidence: str
196 reasons: list[str]
197 signals: _SignalSet
198 last_changed_commit: str
199 last_changed_date: str
200 top_partners: list[_PartnerRecord]
201
202
203 class _PredictJson(TypedDict):
204 generated_at: str
205 horizon_commits: int
206 max_commits: int
207 commits_analysed: int
208 truncated: bool
209 predictions: list[_PredictionRecord]
210
211
212 # ── Helpers ────────────────────────────────────────────────────────────────────
213
214
215
216 def _sanitise(s: str, max_len: int = 80) -> str:
217 clean = _CTRL_RE.sub("", s).strip()
218 return clean[: max_len - 1] + "…" if len(clean) > max_len else clean
219
220
221 def _module_key(address: str, depth: int) -> str:
222 """Return the module prefix for *address* at directory depth *depth*."""
223 fp = address.split("::")[0]
224 parts = pathlib.PurePosixPath(fp).parts
225 if len(parts) <= 1:
226 return fp
227 return str(pathlib.PurePosixPath(*parts[: min(depth, len(parts) - 1)])) + "/"
228
229
230 def _pct_bar(value: float, width: int = 20) -> str:
231 filled = round(max(0.0, min(1.0, value)) * width)
232 return "█" * filled + "░" * (width - filled)
233
234
235 def _confidence_label(score: float) -> str:
236 if score >= _CONFIDENCE_HIGH:
237 return "high"
238 if score >= _CONFIDENCE_MED:
239 return "medium"
240 return "low"
241
242
243 def _iter_symbol_ops(
244 structured_delta: StructuredDelta | None,
245 ) -> Iterator[DomainOp]:
246 """Safely iterate symbol-level ops from a structured delta, handling None."""
247 if structured_delta is None:
248 return
249 yield from flat_symbol_ops(structured_delta["ops"])
250
251
252 # ── Core prediction engine ─────────────────────────────────────────────────────
253
254
255 def _build_predictions(
256 commits: list[CommitRecord],
257 horizon: int,
258 module_depth: int,
259 ) -> list[_PredictionRecord]:
260 """Compute prediction records from a commit walk.
261
262 Single-pass design: collects all signals in one iteration over the commits
263 list, then assembles scores and reasons without a second traversal.
264
265 Args:
266 commits: All commits, newest-first (from ``walk_commits_bfs``).
267 horizon: Number of recent commits used as the prediction window.
268 module_depth: Directory depth for module grouping.
269
270 Returns:
271 Unsorted list of ``_PredictionRecord``; caller sorts by score.
272 """
273 # ── Phase 1 — single pass over all commits ────────────────────────────────
274
275 # Per-symbol accumulators.
276 sym_first_pos: _CounterMap = {} # lowest commit index (=most recent)
277 sym_freq: Counter[str] = Counter() # times touched in [0, horizon)
278 sym_sig_changes: Counter[str] = Counter() # sig ops in full history
279 sym_total_changes: Counter[str] = Counter() # all ops in full history
280 sym_last_commit: Metadata = {}
281 sym_last_date: _DateMap = {}
282
283 # Per-commit symbol sets (needed for co-change computation).
284 commit_sym_sets: list[set[str]] = []
285
286 # Module velocity: net symbol changes in current and prior window.
287 window = max(horizon // 2, 1)
288 mod_net_current: Counter[str] = Counter()
289 mod_net_prior: Counter[str] = Counter()
290
291 for i, commit in enumerate(commits):
292 sym_set: set[str] = set()
293
294 for op in _iter_symbol_ops(commit.structured_delta):
295 addr: str = op["address"]
296 sym_set.add(addr)
297 sym_total_changes[addr] += 1
298
299 # Track first (most recent) occurrence.
300 if addr not in sym_first_pos:
301 sym_first_pos[addr] = i
302 sym_last_commit[addr] = commit.commit_id
303 sym_last_date[addr] = commit.committed_at
304
305 # Frequency inside the horizon window.
306 if i < horizon:
307 sym_freq[addr] += 1
308
309 # Signature instability.
310 summary = str(op.get("new_summary") or "").lower()
311 if any(kw in summary for kw in _SIG_KW):
312 sym_sig_changes[addr] += 1
313
314 # Module velocity.
315 op_kind = op.get("op", "")
316 delta = (
317 1 if op_kind == "insert" else (-1 if op_kind == "delete" else 0)
318 )
319 if delta != 0:
320 mod = _module_key(addr, module_depth)
321 if i < window:
322 mod_net_current[mod] += delta
323 elif i < 2 * window:
324 mod_net_prior[mod] += delta
325
326 commit_sym_sets.append(sym_set)
327
328 # ── Phase 2 — co-change matrix (recent_set × recent_set) ─────────────────
329
330 # Build the candidate set: symbols touched in the horizon window.
331 # Cap at _MAX_RECENT_SET to bound matrix size.
332 recent_candidates: list[str] = [
333 addr for addr, freq in sym_freq.most_common(_MAX_RECENT_SET)
334 ]
335 recent_set: frozenset[str] = frozenset(recent_candidates)
336
337 # co_count[a][b] = commits where both a and b (in recent_set) appeared.
338 co_count: _CoCountMap = {sym: Counter() for sym in recent_set}
339 touch_count: Counter[str] = Counter()
340
341 for sym_set in commit_sym_sets:
342 recent_in_commit = sym_set & recent_set
343 for sym in recent_in_commit:
344 touch_count[sym] += 1
345 for partner in recent_in_commit:
346 if partner != sym:
347 co_count[sym][partner] += 1
348
349 # ── Phase 3 — score each candidate ───────────────────────────────────────
350
351 max_freq = max(sym_freq.values()) if sym_freq else 1
352 max_mod_net = max(
353 (abs(v) for v in mod_net_current.values()), default=1
354 )
355 max_mod_net = max(max_mod_net, 1)
356
357 predictions: list[_PredictionRecord] = []
358
359 for addr in recent_set:
360 if "::" not in addr:
361 continue
362
363 file_path = addr.split("::")[0]
364 sym_name = addr.split("::", 1)[1]
365
366 # Signal 1 — recency.
367 pos = sym_first_pos.get(addr, horizon)
368 recency = max(0.0, 1.0 - pos / max(horizon, 1))
369
370 # Signal 2 — frequency.
371 frequency = sym_freq[addr] / max_freq
372
373 # Signal 3 — co-change (best Jaccard with any recent partner).
374 best_rate = 0.0
375 best_partner: str | None = None
376 best_partner_co = 0
377
378 for partner, co in co_count[addr].most_common(_MAX_CO_MATCH):
379 t_a = touch_count[addr]
380 t_b = touch_count.get(partner, 0)
381 denom = t_a + t_b - co
382 if denom > 0:
383 rate = co / denom
384 if rate > best_rate:
385 best_rate = rate
386 best_partner = partner
387 best_partner_co = co
388
389 co_change_score = best_rate
390
391 # Signal 4 — signature instability.
392 total = sym_total_changes[addr]
393 sig_instab = sym_sig_changes[addr] / total if total > 0 else 0.0
394
395 # Signal 5 — module velocity.
396 mod = _module_key(addr, module_depth)
397 mod_vel_raw = mod_net_current.get(mod, 0)
398 mod_vel_score = min(1.0, abs(mod_vel_raw) / max_mod_net)
399
400 # Composite score.
401 score = (
402 _W_RECENCY * recency
403 + _W_FREQUENCY * frequency
404 + _W_CO_CHANGE * co_change_score
405 + _W_SIG_INSTAB * sig_instab
406 + _W_MOD_VELOCITY * mod_vel_score
407 )
408
409 confidence = _confidence_label(score)
410
411 # Reasons — pick the strongest signals.
412 reasons: list[str] = []
413 if recency >= 0.8:
414 n_ago = pos + 1
415 reasons.append(
416 f"changed {n_ago} commit{'s' if n_ago != 1 else ''} ago"
417 " — editing momentum"
418 )
419 if frequency >= 0.60:
420 reasons.append(
421 f"changed {sym_freq[addr]}× in last {horizon} commits"
422 )
423 if co_change_score >= 0.50 and best_partner is not None:
424 partner_bare = best_partner.split("::")[-1]
425 reasons.append(
426 f"entangled with {partner_bare}"
427 f" ({round(best_rate * 100)}% co-change,"
428 f" {best_partner_co} commits)"
429 )
430 if sig_instab >= 0.50:
431 reasons.append(
432 f"signature changed {sym_sig_changes[addr]}×"
433 " — API still evolving"
434 )
435 if mod_vel_raw != 0:
436 sign = "+" if mod_vel_raw > 0 else ""
437 reasons.append(
438 f"{mod} velocity: {sign}{mod_vel_raw} symbols/window"
439 )
440 if not reasons:
441 reasons.append(f"touched within last {horizon} commits")
442
443 reasons = reasons[:_MAX_REASONS]
444
445 # Top co-change partners (for --explain and JSON).
446 top_partners: list[_PartnerRecord] = []
447 for partner, co in co_count[addr].most_common(_MAX_PARTNERS):
448 t_a = touch_count[addr]
449 t_b = touch_count.get(partner, 0)
450 denom = t_a + t_b - co
451 rate = co / denom if denom > 0 else 0.0
452 if rate > 0:
453 top_partners.append(
454 _PartnerRecord(
455 address=partner,
456 co_change_rate=round(rate, 3),
457 co_change_commits=co,
458 )
459 )
460
461 predictions.append(
462 _PredictionRecord(
463 address=addr,
464 name=sym_name.split(".")[-1],
465 kind="symbol", # enriched later from HEAD snapshot
466 file=file_path,
467 score=round(score, 4),
468 confidence=confidence,
469 reasons=reasons,
470 signals=_SignalSet(
471 recency=round(recency, 3),
472 frequency=round(frequency, 3),
473 co_change=round(co_change_score, 3),
474 sig_instability=round(sig_instab, 3),
475 module_velocity=round(mod_vel_score, 3),
476 ),
477 last_changed_commit=sym_last_commit.get(addr, ""),
478 last_changed_date=(
479 sym_last_date[addr].strftime("%Y-%m-%d")
480 if addr in sym_last_date
481 else ""
482 ),
483 top_partners=top_partners,
484 )
485 )
486
487 predictions.sort(key=lambda r: r["score"], reverse=True)
488 return predictions
489
490
491 # ── Formatters ─────────────────────────────────────────────────────────────────
492
493
494 def _print_leaderboard(
495 predictions: list[_PredictionRecord],
496 top: int,
497 min_confidence: float,
498 horizon: int,
499 commits_analysed: int,
500 file_filter: str | None,
501 kind_filter: str | None,
502 ) -> None:
503 """Print the ranked prediction leaderboard to stdout."""
504 filtered = [
505 r for r in predictions
506 if r["score"] >= min_confidence
507 and (file_filter is None or file_filter in r["file"])
508 and (kind_filter is None or r["kind"] == kind_filter)
509 ]
510 if top > 0:
511 filtered = filtered[:top]
512
513 if not filtered:
514 print(" No predictions meet the current filters.")
515 return
516
517 print(
518 f"\n Predicted changes"
519 f" (horizon: {horizon} commits · {commits_analysed} analysed)\n"
520 )
521
522 bands: list[tuple[str, float, float]] = [
523 ("HIGH CONFIDENCE", _CONFIDENCE_HIGH, 1.01),
524 ("MEDIUM CONFIDENCE", _CONFIDENCE_MED, _CONFIDENCE_HIGH),
525 ("LOW CONFIDENCE", 0.0, _CONFIDENCE_MED),
526 ]
527
528 for band_label, band_lo, band_hi in bands:
529 band_rows = [r for r in filtered if band_lo <= r["score"] < band_hi]
530 if not band_rows:
531 continue
532
533 header_suffix = (
534 "score >= 0.70" if band_lo >= _CONFIDENCE_HIGH
535 else f"score {band_lo:.2f} – {band_hi:.2f}" if band_hi < 1.0
536 else f"score < {_CONFIDENCE_MED:.2f}"
537 )
538 print(f" {band_label} ({header_suffix})")
539 print(" " + "─" * 69)
540
541 for row in band_rows:
542 print(f" {row['score']:.2f} {sanitize_display(row['address'])}")
543 for reason in row["reasons"]:
544 print(f" ↳ {reason}")
545 print()
546
547
548 def _print_explain(record: _PredictionRecord, horizon: int) -> None:
549 """Print a detailed signal breakdown for a single symbol."""
550 confidence_label = record["confidence"].upper()
551 print(f"\n {sanitize_display(record['address'])} — signal breakdown\n")
552 print(f" Score: {record['score']:.2f} {confidence_label}")
553 print(" " + "─" * 53)
554
555 sig_labels: list[tuple[str, float, str]] = [
556 ("recency", record["signals"]["recency"], f"changed recently (horizon={horizon})"),
557 ("frequency", record["signals"]["frequency"], "change frequency in window"),
558 ("co_change", record["signals"]["co_change"], "co-change entanglement"),
559 ("sig_instability", record["signals"]["sig_instability"], "signature churn ratio"),
560 ("module_velocity", record["signals"]["module_velocity"], "module growth acceleration"),
561 ]
562 for label, value, desc in sig_labels:
563 bar = _pct_bar(value, width=20)
564 print(f" {label:<20} {value:.2f} [{bar}] {desc}")
565
566 print()
567 if record["reasons"]:
568 print(" Reasons")
569 for r in record["reasons"]:
570 print(f" ↳ {r}")
571 print()
572
573 if record["top_partners"]:
574 print(f" Top co-change partners")
575 for p in record["top_partners"]:
576 bare = p["address"].split("::")[-1]
577 pct = round(p["co_change_rate"] * 100)
578 print(
579 f" {bare:<40} {pct:>3}% ({p['co_change_commits']} commits)"
580 )
581 print()
582
583
584 # ── Entry point ────────────────────────────────────────────────────────────────
585
586
587 def run(args: argparse.Namespace) -> None:
588 """Entry point for ``muse code predict``."""
589 root = require_repo()
590
591 # ── Validate arguments ────────────────────────────────────────────────────
592 horizon: int = clamp_int(args.horizon, 1, 1000, 'horizon')
593 max_commits: int = clamp_int(args.max_commits, 1, 100000, 'max_commits')
594 top: int = clamp_int(args.top, 0, 10000, 'top')
595 min_confidence: float = args.min_confidence
596 module_depth: int = clamp_int(args.module_depth, 1, 50, 'module_depth')
597
598 if horizon < 1:
599 print("❌ --horizon must be >= 1.", file=sys.stderr)
600 raise SystemExit(ExitCode.USER_ERROR)
601 if max_commits < 1:
602 print("❌ --max-commits must be >= 1.", file=sys.stderr)
603 raise SystemExit(ExitCode.USER_ERROR)
604 if not (0.0 <= min_confidence <= 1.0):
605 print("❌ --min-confidence must be between 0.0 and 1.0.", file=sys.stderr)
606 raise SystemExit(ExitCode.USER_ERROR)
607 if module_depth < 1:
608 print("❌ --module-depth must be >= 1.", file=sys.stderr)
609 raise SystemExit(ExitCode.USER_ERROR)
610
611 explain_addr: str | None = getattr(args, "explain", None)
612 if explain_addr is not None and "::" not in explain_addr:
613 print(
614 "❌ --explain ADDRESS must be in ``file::Symbol`` format.",
615 file=sys.stderr,
616 )
617 raise SystemExit(ExitCode.USER_ERROR)
618
619 # ── Resolve HEAD ──────────────────────────────────────────────────────────
620 repo_id = read_repo_id(root)
621 branch = read_current_branch(root)
622 head = resolve_commit_ref(root, repo_id, branch, None)
623 if head is None:
624 print(
625 "❌ HEAD commit not found — is this an empty repository?",
626 file=sys.stderr,
627 )
628 raise SystemExit(ExitCode.USER_ERROR)
629
630 # ── Walk commits ──────────────────────────────────────────────────────────
631 commits, truncated = walk_commits_bfs(root, head.commit_id, max_commits)
632 if not commits:
633 print(" No commits found — nothing to predict.", file=sys.stderr)
634 raise SystemExit(ExitCode.USER_ERROR)
635
636 # ── Build predictions ─────────────────────────────────────────────────────
637 predictions = _build_predictions(commits, horizon, module_depth)
638
639 # ── Enrich kind from HEAD snapshot ───────────────────────────────────────
640 manifest = get_commit_snapshot_manifest(root, head.commit_id) or {}
641 all_trees = symbols_for_snapshot(root, manifest)
642 addr_to_kind: Metadata = {}
643 for sym_tree in all_trees.values():
644 for addr, rec in sym_tree.items():
645 addr_to_kind[addr] = rec.get("kind", "symbol")
646
647 for pred in predictions:
648 pred["kind"] = addr_to_kind.get(pred["address"], "symbol")
649
650 # ── Apply kind/file filters ───────────────────────────────────────────────
651 file_filter: str | None = getattr(args, "file", None)
652 kind_filter: str | None = getattr(args, "kind", None)
653
654 # ── Output ────────────────────────────────────────────────────────────────
655 if args.json:
656 filtered = [
657 r for r in predictions
658 if r["score"] >= min_confidence
659 and (file_filter is None or file_filter in r["file"])
660 and (kind_filter is None or r["kind"] == kind_filter)
661 ]
662 if top > 0:
663 filtered = filtered[:top]
664 out: _PredictJson = _PredictJson(
665 generated_at=datetime.datetime.now(datetime.timezone.utc)
666 .strftime("%Y-%m-%dT%H:%M:%S"),
667 horizon_commits=horizon,
668 max_commits=max_commits,
669 commits_analysed=len(commits),
670 truncated=truncated,
671 predictions=filtered,
672 )
673 print(json.dumps(out, indent=2))
674 return
675
676 if explain_addr is not None:
677 # Find the prediction record for this address.
678 match = next(
679 (r for r in predictions if r["address"] == explain_addr), None
680 )
681 if match is None:
682 print(
683 f"❌ {explain_addr!r} not found in prediction set."
684 f" It may not have been touched in the last"
685 f" {horizon} commits.",
686 file=sys.stderr,
687 )
688 raise SystemExit(ExitCode.USER_ERROR)
689 _print_explain(match, horizon)
690 return
691
692 _print_leaderboard(
693 predictions,
694 top,
695 min_confidence,
696 horizon,
697 len(commits),
698 file_filter,
699 kind_filter,
700 )
701
702
703 # ── CLI registration ───────────────────────────────────────────────────────────
704
705
706 def register(
707 sub: argparse._SubParsersAction[argparse.ArgumentParser],
708 ) -> None:
709 """Register ``predict`` under the ``code`` subcommand group."""
710 p = sub.add_parser(
711 "predict",
712 help=(
713 "Predict which symbols will change next based on recency,"
714 " frequency, co-change entanglement, signature instability,"
715 " and module velocity."
716 ),
717 description=__doc__,
718 formatter_class=argparse.RawDescriptionHelpFormatter,
719 )
720 p.add_argument(
721 "--horizon",
722 type=int,
723 default=_DEFAULT_HORIZON,
724 metavar="N",
725 help=(
726 f"Number of recent commits used as the prediction window"
727 f" (default: {_DEFAULT_HORIZON})."
728 ),
729 )
730 p.add_argument(
731 "--max-commits",
732 type=int,
733 default=_DEFAULT_MAX_COMMITS,
734 metavar="N",
735 help=(
736 f"Maximum commits to walk for full history signals"
737 f" (default: {_DEFAULT_MAX_COMMITS})."
738 ),
739 )
740 p.add_argument(
741 "--top",
742 type=int,
743 default=_DEFAULT_TOP,
744 metavar="N",
745 help=(
746 f"Show top N predictions (default: {_DEFAULT_TOP};"
747 " 0 = all)."
748 ),
749 )
750 p.add_argument(
751 "--min-confidence",
752 type=float,
753 default=0.0,
754 metavar="F",
755 help=(
756 "Minimum score threshold in [0.0, 1.0] (default: 0.0 = show all)."
757 ),
758 )
759 p.add_argument(
760 "--explain",
761 metavar="ADDRESS",
762 help=(
763 "Show the full signal breakdown for a specific symbol"
764 " (e.g. billing.py::Invoice.compute_total)."
765 ),
766 )
767 p.add_argument(
768 "--kind",
769 metavar="KIND",
770 help=(
771 "Filter predictions to a specific symbol kind"
772 " (function, method, class, …)."
773 ),
774 )
775 p.add_argument(
776 "--file",
777 metavar="PATTERN",
778 help="Filter predictions to symbols whose file path contains PATTERN.",
779 )
780 p.add_argument(
781 "--module-depth",
782 type=int,
783 default=2,
784 metavar="D",
785 help=(
786 "Directory depth used to group symbols into modules"
787 " for the velocity signal (default: 2)."
788 ),
789 )
790 p.add_argument(
791 "--json",
792 action="store_true",
793 help="Emit JSON instead of human-readable text.",
794 )
795 p.set_defaults(func=run)
File History 1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 days ago