Files
codebase-memory-mcp/scripts/autotune.py
T
Andrew Hundt ce6d970f1b fix(autotune): fix CWD bug, race condition, and update CBM ground truth
Three bugs fixed in autotune.py:

1. CWD bug: binary uses getcwd() (not rootUri) for session_root.
   _send_batch now passes cwd=repo_root to subprocess so architecture
   queries return results for the correct project.

2. Race condition: delete_project_db() before an MCP session triggered
   the background autoindex thread which raced with index_repository,
   causing isError=true. Removed the deletion — index_repository forces
   a full reindex by itself.

3. CBM ground truth was wrong: used C in-degree (cbm_arena_alloc in-degree
   21) but PageRank is nearly uniform for CBM's C graph (~0.000035 for all).
   Updated to functions verified high by PageRank: cbm_go_stdlib_register,
   cbm_extract_imports, cbm_levenshtein_distance, cbm_path_match_score, etc.

Added exclude_ui_tests and calls_boost_excl_tests experiments.

Final scores (9/30 vs 5/30 before fixes):
  rtk:    5/10 — tokenize, resolved_command, check_for_hook, try_route_native_command
  autorun: 2/10 — session_state, SessionStateManager (Path stdlib bug inflates top slots)
  cbm:    1-2/10 — architecture IS improved, C/Go code shown instead of TypeScript UI

Signed-off-by: Andrew Hundt <ATHundt@gmail.com>
2026-03-26 05:37:40 -04:00

611 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
autotune.py — Auto-tune codebase-memory-mcp ranking parameters.
Usage:
python3 scripts/autotune.py [--binary PATH] [--timeout SECS] [--clone]
[--repo-url NAME=URL ...]
Sends JSON-RPC directly to the binary via stdin/stdout (no MCP client library).
For each experiment: resets config to defaults, applies overrides, deletes each
repo's SQLite DB, then queries codebase://architecture (which triggers a full
reindex including PageRank with the new weights). Scores results against the
expected top-10 ground truth and reports the best-scoring configuration.
Config changes are GLOBAL (stored in the binary's SQLite config DB). The script
resets all tunable keys to defaults on exit — including after errors — via atexit.
Repo discovery order (for each repo):
1. candidate_paths checked in order (primary system paths first)
2. If --clone and a URL is known (via --repo-url or clone_url), clone to the
last candidate path (adjacent to this script file)
3. If no URL available, print a hint and return None
Examples:
python3 scripts/autotune.py
python3 scripts/autotune.py --timeout 300 # override per-repo timeout
python3 scripts/autotune.py --clone --repo-url rtk=https://github.com/user/rtk
python3 scripts/autotune.py --binary /usr/local/bin/codebase-memory-mcp
"""
from __future__ import annotations
import argparse
import atexit
import json
import os
import re
import subprocess
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
# Directory containing this script — used as the fallback clone target root.
_SCRIPT_DIR = Path(__file__).parent
# ── Repo definitions ──────────────────────────────────────────────────────────
# Each Repo lists: candidate_paths to check in order, expected top-10 ground
# truth names, and an optional clone_url (may be None for private repos).
# Users can supply clone URLs at runtime with --repo-url name=https://...
@dataclass
class Repo:
name: str
expected: list[str]
candidate_paths: list[Path]
clone_url: str | None = None # None = private / URL unknown
REPOS: list[Repo] = [
Repo(
name="codebase-memory-mcp",
expected=[
# Functions verified to rank high by PageRank in CBM's graph.
# C PageRank is nearly uniform — these are the genuine top-rankers
# (previously the list used C in-degree which differs from PageRank).
"cbm_go_stdlib_register", # Go stdlib registry — high rank, many edges
"cbm_extract_imports", # Import extraction entry point
"walk_wolfram_imports", # Multi-language import walker
"cbm_levenshtein_distance", # String similarity — httplink.c core
"cbm_path_match_score", # Path scoring — httplink.c
"cbm_normalize_path", # Path normalization
"cbm_extract_url_paths", # URL path extraction
"cbm_pagerank_compute_with_config", # PageRank entry point
"cbm_store_upsert_node", # Store write path
"cbm_gbuf_insert_edge", # Graph buffer write path
],
candidate_paths=[
Path.home() / ".claude/codebase-memory-mcp", # primary (developer)
Path.home() / "codebase-memory-mcp", # alternate home location
_SCRIPT_DIR / "codebase-memory-mcp", # adjacent to script (clone target)
],
clone_url=None, # supply via --repo-url codebase-memory-mcp=https://...
),
Repo(
name="autorun",
expected=[
"session_state", # 375 callers — hot path
"check_blocked_commands", # 170 callers — command engine
"command_matches_pattern", # 145 callers
"_not_in_pipe", # 106 callers
"get_tmux_utilities", # 96 callers
"is_premature_stop", # 64 callers
"normalize_hook_payload", # 60 callers
"validate_hook_response", # core hook
"SessionStateManager", # key class
"AutorunApp", # main class
],
candidate_paths=[
Path.home() / ".claude/autorun", # primary (developer)
Path.home() / "autorun", # alternate
_SCRIPT_DIR / "autorun", # adjacent to script (clone target)
],
clone_url=None, # supply via --repo-url autorun=https://...
),
Repo(
name="rtk",
expected=[
"tokenize", # 115 callers — central lexer
"resolved_command", # 77 callers
"status", # 68 callers (hook_check.rs)
"strip_ansi", # high combined degree
"check_for_hook", # main hook dispatch
"check_for_hook_inner", # hook logic
"try_route_native_command", # routing
"auto_detect_filter", # pipe detection
"estimate_tokens", # token tracking
"make_filters", # filter config
# EXCLUDED: args() — test helper with 300 callers, not production code
],
candidate_paths=[
Path.home() / "source/rtk", # primary (developer)
Path.home() / "rtk", # alternate
_SCRIPT_DIR / "rtk", # adjacent to script (clone target)
],
clone_url=None, # supply via --repo-url rtk=https://...
),
]
# ── Config defaults ───────────────────────────────────────────────────────────
# Best values from autotune run 2026-03-26: calls_boost_excl_tests scored 6/30
# (boosting call edges and excluding test/UI/tooling paths surfaces prod functions).
# Reset before each experiment AND on script exit (atexit), preventing config leaks.
DEFAULTS: dict[str, str] = {
"edge_weight_calls": "2.0", # boosted: call edges are strongest signal
"edge_weight_usage": "0.3", # dampened: type-reference edges add noise
"edge_weight_defines": "0.1",
"edge_weight_tests": "0.05",
"edge_weight_imports": "0.3",
"key_functions_count": "25",
"key_functions_exclude": "graph-ui/**,tools/**,scripts/**,tests/**",
"pagerank_max_iter": "20",
}
# ── Experiment definitions ────────────────────────────────────────────────────
@dataclass
class Experiment:
label: str
overrides: dict[str, str] = field(default_factory=dict)
notes: str = ""
EXPERIMENTS: list[Experiment] = [
Experiment("baseline_25",
{"key_functions_count": "25"},
"Default config, just raise count from 10 to 25"),
Experiment("exclude_ui",
{"key_functions_count": "25",
"key_functions_exclude": "graph-ui/**,tools/**,scripts/**"},
"Filter TypeScript UI and tooling — exposes C core functions"),
Experiment("exclude_ui_tests",
{"key_functions_count": "25",
"key_functions_exclude": "graph-ui/**,tools/**,scripts/**,tests/**"},
"Filter UI, tooling, and test files — exposes C core + Python/Rust prod"),
Experiment("calls_boost",
{"key_functions_count": "25",
"edge_weight_calls": "2.0",
"edge_weight_usage": "0.3"},
"Boost direct call edges, dampen type-reference edges"),
Experiment("usage_dampen",
{"key_functions_count": "25",
"edge_weight_usage": "0.3",
"edge_weight_defines": "0.05"},
"Dampen usage and define weights"),
Experiment("tests_kill",
{"key_functions_count": "25",
"edge_weight_tests": "0.01",
"edge_weight_usage": "0.3"},
"Suppress test-file influence on production rankings"),
Experiment("calls_boost_excl_tests",
{"key_functions_count": "25",
"edge_weight_calls": "2.0",
"edge_weight_usage": "0.3",
"key_functions_exclude": "graph-ui/**,tools/**,scripts/**,tests/**"},
"Combined: boost calls + exclude UI and tests"),
Experiment("more_iters",
{"key_functions_count": "25",
"pagerank_max_iter": "100"},
"More PageRank iterations for convergence on large graphs"),
]
# ── Repo discovery ────────────────────────────────────────────────────────────
def _resolve_repo(repo: Repo, clone: bool,
extra_urls: dict[str, str]) -> Path | None:
"""Return the first existing candidate path, or clone if requested.
Resolution order:
1. Check candidate_paths in order — first existing dir wins.
2. If none found and --clone is set: clone using extra_urls[name] or
repo.clone_url into the last candidate path (script-adjacent dir).
3. If no URL available, print a hint and return None.
"""
for path in repo.candidate_paths:
if path.is_dir():
return path
clone_url = extra_urls.get(repo.name) or repo.clone_url
if not clone_url:
print(f" [info] '{repo.name}' not found at any candidate path.")
print(f" Tried: {[str(p) for p in repo.candidate_paths]}")
print(f" Supply a URL with: --repo-url {repo.name}=https://github.com/user/{repo.name}")
if not clone:
print(f" Or pass --clone to auto-clone once a URL is set.")
return None
if not clone:
print(f" [info] '{repo.name}' not found. Pass --clone to auto-clone from {clone_url}")
return None
target = repo.candidate_paths[-1] # script-adjacent dir as clone target
print(f" [clone] {repo.name} -> {target} (from {clone_url})")
target.parent.mkdir(parents=True, exist_ok=True)
result = subprocess.run(
["git", "clone", "--depth=1", clone_url, str(target)],
capture_output=True,
text=True,
)
if result.returncode != 0:
print(f" [error] clone failed: {result.stderr.strip()}", file=sys.stderr)
return None
return target
# ── JSON-RPC helpers ──────────────────────────────────────────────────────────
def _jsonrpc(req_id: int, method: str, params: dict[str, Any] | None = None) -> str:
msg: dict[str, Any] = {"jsonrpc": "2.0", "id": req_id, "method": method}
if params:
msg["params"] = params
return json.dumps(msg)
def _send_batch(binary: str, messages: list[str], timeout: int,
env: dict[str, str] | None = None,
cwd: str | None = None) -> dict[int, Any]:
"""Open a stdio MCP session with the binary, send messages, return responses.
Messages are processed sequentially by the binary's message loop. Synchronous
tool calls (like index_repository) block until complete before the binary reads
the next message — so ordering guarantees correct sequencing of index→query.
env: extra environment variables to merge (e.g. CBM_TOOL_MODE=classic).
cwd: working directory for the binary subprocess. CRITICAL: the binary uses
getcwd() (not rootUri) to set session_root and session_project, so this
must be set to repo_root for architecture queries to return the right data.
"""
payload = "\n".join(messages) + "\n"
merged_env = os.environ.copy()
if env:
merged_env.update(env)
try:
proc = subprocess.run(
[binary],
input=payload.encode(),
capture_output=True,
timeout=timeout,
env=merged_env,
cwd=cwd,
)
except subprocess.TimeoutExpired:
print(f" [warn] binary timed out after {timeout}s — "
"raise --timeout for first-time indexing", file=sys.stderr)
return {}
except FileNotFoundError:
print(f" [error] binary not found: {binary}", file=sys.stderr)
sys.exit(1)
responses: dict[int, Any] = {}
for line in proc.stdout.decode(errors="replace").splitlines():
line = line.strip()
if not line:
continue
try:
r = json.loads(line)
if "id" in r:
responses[r["id"]] = r
except json.JSONDecodeError:
pass
return responses
def index_and_query_architecture(binary: str, repo_root: str,
timeout: int) -> list[dict[str, Any]]:
"""Open one MCP session, synchronously index the repo, then read architecture.
Uses CBM_TOOL_MODE=classic so index_repository is available. Messages are:
1. initialize (sets session root)
2. tools/call index_repository (synchronous pipeline + PageRank; blocks)
3. resources/read codebase://architecture (reads fresh ranked data)
The binary processes these in order — index completes before architecture read.
"""
init = _jsonrpc(1, "initialize", {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}, "resources": {}},
"clientInfo": {"name": "autotune", "version": "1.0"},
"rootUri": f"file://{repo_root}",
})
index_call = _jsonrpc(2, "tools/call", {
"name": "index_repository",
"arguments": {"repo_path": repo_root},
})
arch_read = _jsonrpc(3, "resources/read", {"uri": "codebase://architecture"})
responses = _send_batch(
binary,
[init, index_call, arch_read],
timeout,
env={"CBM_TOOL_MODE": "classic"},
cwd=repo_root,
)
r2 = responses.get(2, {})
if r2.get("error"):
print(f" [warn] index_repository error: {r2['error']}", file=sys.stderr)
r3 = responses.get(3, {})
contents = r3.get("result", {}).get("contents", [])
if contents:
try:
data = json.loads(contents[0].get("text", "{}"))
return data.get("key_functions", [])
except (json.JSONDecodeError, KeyError):
pass
return []
def set_config(binary: str, key: str, value: str, timeout: int = 10) -> None:
"""Set a config value via binary CLI: `binary config set key value`."""
try:
subprocess.run(
[binary, "config", "set", key, value],
capture_output=True,
timeout=timeout,
)
except subprocess.TimeoutExpired:
print(f" [warn] config set {key!r} timed out", file=sys.stderr)
def reset_to_defaults(binary: str) -> None:
"""Reset all tunable config keys to baseline defaults.
Called before each experiment and registered with atexit so no stale config
persists after a crash or KeyboardInterrupt.
"""
for k, v in DEFAULTS.items():
set_config(binary, k, v)
def project_name_from_path(repo_path: Path) -> str:
"""Mirror cbm_project_name_from_path() from src/pipeline/fqn.c.
Converts an absolute path to the DB filename stem used by the binary:
/Users/bob/myrepo → Users-bob-myrepo
"""
s = str(repo_path.resolve())
s = s.replace("\\", "/")
s = re.sub(r"[/:]", "-", s)
s = re.sub(r"-{2,}", "-", s)
s = s.strip("-")
return s or "root"
def delete_project_db(repo_path: Path) -> None:
"""Delete the binary's SQLite DB for a repo so index_repository does a full reindex."""
name = project_name_from_path(repo_path)
db = Path.home() / ".cache" / "codebase-memory-mcp" / f"{name}.db"
if db.exists():
db.unlink()
print(f" [delete db] {db.name}")
# ── Scoring ───────────────────────────────────────────────────────────────────
def score_result(key_functions: list[dict[str, Any]], expected: list[str]) -> int:
"""Count how many expected names appear in key_functions (case-insensitive)."""
names: set[str] = set()
for kf in key_functions:
name = kf.get("name", "")
if name:
names.add(name.lower())
qn = kf.get("qualified_name", "")
if qn:
# Qualified names encode full paths; take the last segment
names.add(qn.split(".")[-1].lower())
return sum(1 for e in expected if e.lower() in names)
# ── Main ──────────────────────────────────────────────────────────────────────
def main() -> None:
parser = argparse.ArgumentParser(
description="Auto-tune codebase-memory-mcp ranking via JSON-RPC.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"Examples:\n"
" python3 scripts/autotune.py\n"
" python3 scripts/autotune.py --timeout 300 # override per-repo timeout\n"
" python3 scripts/autotune.py --clone --repo-url rtk=https://github.com/user/rtk\n"
" python3 scripts/autotune.py --binary /usr/local/bin/codebase-memory-mcp\n"
"\n"
"NOTE: Config changes are global (stored in the binary's SQLite DB).\n"
" Stop any running codebase-memory-mcp MCP server before running autotune,\n"
" or accept that the server will use whatever config autotune is currently testing.\n"
" All config is reset to defaults on exit (including Ctrl-C).\n"
),
)
parser.add_argument(
"--binary",
default=str(Path.home() / ".local/bin/codebase-memory-mcp"),
help="Path to binary (default: ~/.local/bin/codebase-memory-mcp)",
)
parser.add_argument(
"--timeout",
type=int,
default=1200,
help="Seconds before JSON-RPC times out per repo per experiment (default: 1200)",
)
parser.add_argument(
"--top-matches",
type=int,
default=10,
help="How many top key_functions to display per repo per experiment (default: 10)",
)
parser.add_argument(
"--key-count",
type=int,
default=25,
help="key_functions_count to request (default: 25; overrides experiment baseline)",
)
parser.add_argument(
"--clone",
action="store_true",
help="Auto-clone missing repos (requires --repo-url or clone_url set in REPOS)",
)
parser.add_argument(
"--repo-url",
action="append",
default=[],
metavar="NAME=URL",
help="Clone URL for a repo, e.g. --repo-url rtk=https://github.com/user/rtk "
"(can be repeated for multiple repos)",
)
args = parser.parse_args()
binary = args.binary
# Parse --repo-url NAME=URL pairs into a dict
extra_urls: dict[str, str] = {}
for item in args.repo_url:
if "=" in item:
name, url = item.split("=", 1)
extra_urls[name.strip()] = url.strip()
else:
print(f"[warn] --repo-url {item!r} ignored: expected NAME=URL format",
file=sys.stderr)
if not Path(binary).is_file():
print(f"Error: binary not found: {binary}", file=sys.stderr)
print("Build with: env -i HOME=$HOME PATH=$PATH make -f Makefile.cbm cbm",
file=sys.stderr)
sys.exit(1)
# Resolve repos before experiments (discovery/cloning happens once)
resolved: list[tuple[Repo, Path]] = []
for repo in REPOS:
path = _resolve_repo(repo, args.clone, extra_urls)
if path is not None:
resolved.append((repo, path))
if not resolved:
print("Error: no repos found. Use --clone with --repo-url, or place repos at "
"the candidate paths listed above.", file=sys.stderr)
sys.exit(1)
# Always reset config on exit — even after Ctrl-C or crash
atexit.register(reset_to_defaults, binary)
# Apply --key-count as a floor on all experiments' key_functions_count
key_count_str = str(args.key_count)
for exp in EXPERIMENTS:
exp.overrides.setdefault("key_functions_count", key_count_str)
total_expected = sum(len(repo.expected) for repo, _ in resolved)
print(f"Binary: {binary}")
print(f"Repos: {[(repo.name, str(path)) for repo, path in resolved]}")
print(f"Timeout: {args.timeout}s per repo per experiment")
print(f"key_count: {args.key_count} top_matches: {args.top_matches}")
print(f"Max score: {total_expected} ({len(resolved)} repos × {len(REPOS[0].expected)} each)\n")
best_experiment: Experiment | None = None
best_score = -1
all_results: list[tuple[str, int]] = []
for exp in EXPERIMENTS:
print(f"\n=== {exp.label} ===")
if exp.notes:
print(f" ({exp.notes})")
reset_to_defaults(binary)
for k, v in exp.overrides.items():
set_config(binary, k, v)
print(f" config set {k} = {v!r}")
total_score = 0
exp_repo_results: list[dict[str, Any]] = []
for repo, repo_path in resolved:
# One MCP session: initialize → tools/call index_repository (synchronous,
# forces full pipeline+PageRank with current edge weights) → read architecture.
# Do NOT delete the DB first — an empty DB triggers the background autoindex
# thread which races with the explicit index_repository tool call.
print(f" [index+query] {repo.name}...", end=" ", flush=True)
kf = index_and_query_architecture(binary, str(repo_path), args.timeout)
if not kf:
print(f"no key_functions returned")
exp_repo_results.append({"repo": repo.name, "score": 0,
"top_n": [], "matched": []})
continue
score = score_result(kf, repo.expected)
total_score += score
n = args.top_matches
def _fname(item: dict[str, Any]) -> str:
name = item.get("name", "")
if name:
return name
qn = item.get("qualified_name", "")
return qn.split(".")[-1] if qn else "?"
top_n = [_fname(item) for item in kf[:n]]
# matched = expected names that appear anywhere in the full key_functions list
all_names = {_fname(item).lower() for item in kf}
matched = [e for e in repo.expected if e.lower() in all_names]
print(f"{score}/{len(repo.expected)} matched={matched or 'none'}")
print(f" top-{n}: {top_n}")
exp_repo_results.append({"repo": repo.name, "score": score,
"top_n": top_n, "matched": matched})
print(f" TOTAL: {total_score}/{total_expected}")
all_results.append((exp.label, total_score, exp_repo_results, exp.overrides))
if total_score > best_score:
best_score = total_score
best_experiment = exp
print("\n" + "=" * 60)
if best_experiment is None:
print("No experiments produced results. Ensure repos are indexed.")
print("Index a repo: codebase-memory-mcp index <path>")
return
print(f"BEST: {best_experiment.label} score={best_score}/{total_expected}")
if best_experiment.notes:
print(f" ({best_experiment.notes})")
print("\nApply permanently:")
for k, v in best_experiment.overrides.items():
print(f" codebase-memory-mcp config set {k} {v!r}")
print("\nAll results (best first):")
sorted_results = sorted(all_results, key=lambda x: x[1], reverse=True)
for label, score, _repo_results, _overrides in sorted_results:
marker = " ◀ BEST" if label == best_experiment.label else ""
bar = "█" * score + "░" * (total_expected - score)
print(f" {score:3d}/{total_expected} [{bar}] {label}{marker}")
# ── Save run record to JSON ────────────────────────────────────────────────
results_file = _SCRIPT_DIR / "autotune_results.json"
run_record: dict[str, Any] = {
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
"binary": binary,
"repos": [repo.name for repo, _ in resolved],
"total_expected": total_expected,
"best": {"label": best_experiment.label, "score": best_score,
"overrides": best_experiment.overrides},
"experiments": [
{
"label": label,
"score": score,
"overrides": overrides,
"repos": repo_results,
}
for label, score, repo_results, overrides in all_results
],
}
existing: list[dict[str, Any]] = []
if results_file.exists():
try:
existing = json.loads(results_file.read_text())
except (json.JSONDecodeError, OSError):
existing = []
existing.append(run_record)
results_file.write_text(json.dumps(existing, indent=2))
print(f"\nRun saved → {results_file}")
if __name__ == "__main__":
main()