use anyhow::{bail, Context, Result};
use chrono::Utc;
use clap::Args;
use colored::Colorize;
use indicatif::{ProgressBar, ProgressStyle};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use super::OutputConfig;
use crate::config::Config;
use crate::index::git::GitAnalyzer;
use crate::index::Embedder;
use crate::search::context::{BridgeMode, ContentMode, ContextAssembler, ContextConfig};
use crate::storage::{MetadataStore, VectorStore};
#[derive(Args)]
pub struct CalibrateArgs {
#[arg(long, short = 'n', default_value = "20")]
samples: usize,
#[arg(long, default_value = "6 months ago")]
since: String,
#[arg(long)]
search_limit: Option<usize>,
#[arg(long)]
budget: Option<usize>,
#[arg(long)]
apply: bool,
#[arg(long)]
verbose: bool,
#[arg(long)]
full: bool,
#[arg(long)]
resume: bool,
#[arg(long)]
bridge_sweep: bool,
#[arg(long)]
repo: Option<String>,
#[arg(long)]
source: Option<PathBuf>,
#[arg(default_value = ".")]
path: PathBuf,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CalibrationResult {
pub calibrated_at: String,
pub snapshot: ProjectSnapshot,
pub best_config: CalibratedConfig,
pub top_results: Vec<GridResult>,
pub sample_count: usize,
pub probe_count: usize,
pub terse_warning: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectSnapshot {
pub chunk_count: usize,
pub file_count: usize,
pub primary_language: String,
pub language_distribution: Vec<(String, f32)>,
pub repo_age_days: u32,
pub recent_commit_rate: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CalibratedConfig {
pub semantic_weight: f32,
pub doc_demotion: f32,
pub rrf_k: f32,
#[serde(skip_serializing_if = "Option::is_none")]
pub recency_half_life_days: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recency_weight: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub coupling_depth: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub budget_lines: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub search_limit: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bridge_mode: Option<BridgeMode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bridge_boost_factor: Option<f32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GridResult {
pub semantic_weight: f32,
pub doc_demotion: f32,
pub rrf_k: f32,
#[serde(skip_serializing_if = "Option::is_none")]
pub recency_half_life_days: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recency_weight: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub coupling_depth: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub budget_lines: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub search_limit: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bridge_mode: Option<BridgeMode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bridge_boost_factor: Option<f32>,
pub precision: f32,
pub recall: f32,
pub f1: f32,
}
struct SampledCommit {
hash: String,
message: String,
files: Vec<String>,
}
fn sample_commits(
git: &GitAnalyzer,
since: &str,
target_count: usize,
) -> Result<Vec<SampledCommit>> {
let scan_depth = target_count * 20; let all_commits = git.get_commit_log(scan_depth, None)?;
let since_ts = parse_since_timestamp(git, since)?;
let mut candidates: Vec<SampledCommit> = Vec::new();
for commit in &all_commits {
if commit.timestamp < since_ts {
continue;
}
if commit.files.is_empty() {
continue;
}
if commit.message.starts_with("Merge ") {
continue;
}
if commit.message.starts_with("Revert ") {
continue;
}
if is_noise_commit(&commit.message) {
continue;
}
let file_count = commit.files.len();
if file_count < 2 || file_count > 30 {
continue;
}
candidates.push(SampledCommit {
hash: commit.hash.clone(),
message: commit.message.clone(),
files: commit.files.clone(),
});
}
if candidates.is_empty() {
bail!(
"No suitable commits found in the last {}. \
Need commits with 2-30 files, non-merge, non-noise.",
since
);
}
let selected = if candidates.len() <= target_count {
candidates
} else {
let step = candidates.len() as f64 / target_count as f64;
(0..target_count)
.map(|i| {
let idx = (i as f64 * step) as usize;
let idx = idx.min(candidates.len() - 1);
SampledCommit {
hash: candidates[idx].hash.clone(),
message: candidates[idx].message.clone(),
files: candidates[idx].files.clone(),
}
})
.collect()
};
Ok(selected)
}
fn is_noise_commit(message: &str) -> bool {
let lower = message.to_lowercase();
let noise_prefixes = [
"chore:",
"chore(",
"ci:",
"ci(",
"docs:",
"docs(",
"style:",
"style(",
"build:",
"build(",
"release:",
"bump ",
"auto-merge",
"update dependency",
];
noise_prefixes.iter().any(|p| lower.starts_with(p))
}
fn parse_since_timestamp(git: &GitAnalyzer, since: &str) -> Result<i64> {
let now = chrono::Utc::now().timestamp();
let lower = since.to_lowercase();
if let Some(ts) = parse_relative_date(&lower, now) {
return Ok(ts);
}
let output = std::process::Command::new("git")
.args([
"log",
"--no-merges",
"--format=%ct",
&format!("--since={}", since),
"--reverse",
])
.current_dir(git.repo_root())
.output()
.context("Failed to parse --since timestamp")?;
let first_line = String::from_utf8_lossy(&output.stdout);
let ts = first_line
.lines()
.next()
.and_then(|l| l.trim().parse::<i64>().ok())
.unwrap_or(0);
Ok(if ts > 0 { ts - 1 } else { 0 })
}
fn parse_relative_date(s: &str, now: i64) -> Option<i64> {
let parts: Vec<&str> = s.split_whitespace().collect();
if parts.len() < 3 || parts.last() != Some(&"ago") {
return None;
}
let n: i64 = parts[0].parse().ok()?;
let unit = parts[1].trim_end_matches('s'); let seconds_per_unit = match unit {
"second" => 1,
"minute" => 60,
"hour" => 3600,
"day" => 86400,
"week" => 7 * 86400,
"month" => 30 * 86400,
"year" => 365 * 86400,
_ => return None,
};
Some(now - n * seconds_per_unit)
}
fn detect_terse_messages(commits: &[SampledCommit]) -> bool {
if commits.is_empty() {
return false;
}
let terse_count = commits
.iter()
.filter(|c| is_terse_message(&c.message))
.count();
(terse_count as f32 / commits.len() as f32) > 0.5
}
fn is_terse_message(message: &str) -> bool {
let msg = message.trim();
if msg.len() < 20 {
return true;
}
let generic = ["fix", "update", "wip", "temp", "fixup", "squash"];
let lower = msg.to_lowercase();
generic.iter().any(|g| lower == *g)
}
fn score_probe(injected_files: &[String], ground_truth_files: &[String]) -> (f32, f32, f32) {
let injected: HashSet<&str> = injected_files.iter().map(|s| s.as_str()).collect();
let truth: HashSet<&str> = ground_truth_files.iter().map(|s| s.as_str()).collect();
let overlap: HashSet<&&str> = injected.intersection(&truth).collect();
let precision = if injected.is_empty() {
0.0
} else {
overlap.len() as f32 / injected.len() as f32
};
let recall = if truth.is_empty() {
0.0
} else {
overlap.len() as f32 / truth.len() as f32
};
let f1 = if precision + recall > 0.0 {
2.0 * precision * recall / (precision + recall)
} else {
0.0
};
(precision, recall, f1)
}
struct GridPoint {
semantic_weight: f32,
doc_demotion: f32,
rrf_k: f32,
recency_half_life_days: Option<f32>,
recency_weight: Option<f32>,
budget_lines: usize,
search_limit: usize,
bridge_mode: BridgeMode,
bridge_boost_factor: f32,
}
fn build_grid(budgets: &[usize], search_limits: &[usize]) -> Vec<GridPoint> {
build_grid_with_recency(
&[],
&[],
budgets,
search_limits,
&[BridgeMode::Inject],
&[0.3],
)
}
fn build_grid_with_recency(
half_lives: &[f32],
recency_weights: &[f32],
budgets: &[usize],
search_limits: &[usize],
bridge_modes: &[BridgeMode],
bridge_boost_factors: &[f32],
) -> Vec<GridPoint> {
let sws = [0.0, 0.3, 0.5, 0.7, 0.9];
let dds = [0.1, 0.3, 0.5];
let ks = [60.0];
let hl_iter: Vec<Option<f32>> = if half_lives.is_empty() {
vec![None]
} else {
half_lives.iter().map(|&v| Some(v)).collect()
};
let rw_iter: Vec<Option<f32>> = if recency_weights.is_empty() {
vec![None]
} else {
recency_weights.iter().map(|&v| Some(v)).collect()
};
let mut grid = Vec::new();
for &sw in &sws {
for &dd in &dds {
for &k in &ks {
for &hl in &hl_iter {
for &rw in &rw_iter {
for &b in budgets {
for &sl in search_limits {
for &bm in bridge_modes {
let factors: &[f32] = match bm {
BridgeMode::Off | BridgeMode::Inject => &[0.0],
BridgeMode::Boost | BridgeMode::BoostInject => {
bridge_boost_factors
}
};
for &bbf in factors {
grid.push(GridPoint {
semantic_weight: sw,
doc_demotion: dd,
rrf_k: k,
recency_half_life_days: hl,
recency_weight: rw,
budget_lines: b,
search_limit: sl,
bridge_mode: bm,
bridge_boost_factor: bbf,
});
}
}
}
}
}
}
}
}
}
grid
}
async fn capture_snapshot(
vector_store: &VectorStore,
git: &GitAnalyzer,
) -> Result<ProjectSnapshot> {
let chunk_count = vector_store.count().await? as usize;
let repo_age_days = git_repo_age_days(git).unwrap_or(0);
let recent_commits = git
.get_commit_log(500, None)
.ok()
.map(|commits| {
let thirty_days_ago = Utc::now().timestamp() - (30 * 86400);
commits
.iter()
.filter(|c| c.timestamp > thirty_days_ago)
.count()
})
.unwrap_or(0);
let recent_commit_rate = recent_commits as f32 / 4.3;
Ok(ProjectSnapshot {
chunk_count,
file_count: 0, primary_language: "unknown".to_string(), language_distribution: vec![],
repo_age_days,
recent_commit_rate,
})
}
fn git_repo_age_days(git: &GitAnalyzer) -> Result<u32> {
let output = std::process::Command::new("git")
.args(["log", "--reverse", "--format=%ct", "-1"])
.current_dir(git.repo_root())
.output()
.context("Failed to get repo age")?;
let first_ts: i64 = String::from_utf8_lossy(&output.stdout)
.trim()
.parse()
.unwrap_or(0);
if first_ts == 0 {
return Ok(0);
}
let now = Utc::now().timestamp();
Ok(((now - first_ts) / 86400) as u32)
}
pub fn capture_snapshot_from_index(chunk_count: usize) -> ProjectSnapshot {
ProjectSnapshot {
chunk_count,
file_count: 0,
primary_language: "unknown".to_string(),
language_distribution: vec![],
repo_age_days: 0,
recent_commit_rate: 0.0,
}
}
fn calibration_path(repo_root: &std::path::Path) -> PathBuf {
Config::data_dir(repo_root).join("calibration.json")
}
fn cache_path(repo_root: &std::path::Path) -> PathBuf {
Config::data_dir(repo_root).join("calibration_cache.json")
}
fn progress_path(repo_root: &std::path::Path) -> PathBuf {
Config::data_dir(repo_root).join("calibration_progress.jsonl")
}
fn save_calibration(repo_root: &std::path::Path, result: &CalibrationResult) -> Result<()> {
let path = calibration_path(repo_root);
let json = serde_json::to_string_pretty(result)?;
std::fs::write(&path, json).with_context(|| format!("Failed to write {}", path.display()))?;
Ok(())
}
pub fn load_calibration(repo_root: &std::path::Path) -> Option<CalibrationResult> {
let path = calibration_path(repo_root);
let content = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&content).ok()
}
#[derive(Debug, Serialize, Deserialize)]
struct SweepCache {
completed_depths: HashMap<String, Vec<GridResult>>,
sample_count: usize,
sample_hashes: Vec<String>,
}
fn save_cache(repo_root: &std::path::Path, cache: &SweepCache) -> Result<()> {
let path = cache_path(repo_root);
let json = serde_json::to_string_pretty(cache)?;
std::fs::write(&path, json)
.with_context(|| format!("Failed to write cache {}", path.display()))?;
Ok(())
}
fn load_cache(repo_root: &std::path::Path) -> Option<SweepCache> {
let path = cache_path(repo_root);
let content = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&content).ok()
}
fn clear_cache(repo_root: &std::path::Path) {
let path = cache_path(repo_root);
let _ = std::fs::remove_file(path);
}
async fn run_probes(
grid: &[GridPoint],
commits: &[SampledCommit],
lance_path: &std::path::Path,
db_path: &std::path::Path,
config: &Config,
model_dir: &std::path::Path,
coupling_depth: Option<usize>,
repo_root: &std::path::Path,
pb: Option<&ProgressBar>,
progress_file: Option<&std::path::Path>,
) -> Result<Vec<GridResult>> {
let vs = VectorStore::open(lance_path).await?;
let ms = MetadataStore::open(db_path)?;
let embedder = Embedder::from_config(&config.embedding, model_dir)?;
let initial_config = ContextConfig {
budget_lines: 300,
depth: 1,
max_coupled: 3,
coupling_threshold: 0.1,
semantic_weight: 0.5,
content_mode: ContentMode::None,
search_limit: 20,
doc_demotion: 0.3,
recency_half_life_days: config.search.recency_half_life_days,
recency_weight: config.search.recency_weight,
rrf_k: 60.0,
bridge_mode: BridgeMode::Inject,
bridge_boost_factor: 0.3,
extra_filter: None,
tags_config: None,
role: None,
file_type_rules: vec![],
repo_affinity: None,
repo_affinity_boost: 2.0,
max_bridged_files: 3,
max_bridged_chunks_per_file: 2,
repo_path_prefix: config.server.repo_path_prefix.clone(),
..ContextConfig::default()
};
let mut assembler = ContextAssembler::new(embedder, vs, ms, initial_config);
let mut grid_results: Vec<GridResult> = Vec::new();
let prefix = repo_root.to_string_lossy();
for point in grid {
assembler.set_config(ContextConfig {
budget_lines: point.budget_lines,
depth: 1,
max_coupled: 3,
coupling_threshold: 0.1,
semantic_weight: point.semantic_weight,
content_mode: ContentMode::None,
search_limit: point.search_limit,
doc_demotion: point.doc_demotion,
recency_half_life_days: point
.recency_half_life_days
.unwrap_or(config.search.recency_half_life_days),
recency_weight: point.recency_weight.unwrap_or(config.search.recency_weight),
rrf_k: point.rrf_k,
bridge_mode: point.bridge_mode,
bridge_boost_factor: point.bridge_boost_factor,
extra_filter: None,
tags_config: None,
role: None,
file_type_rules: vec![],
repo_affinity: None,
repo_affinity_boost: 2.0,
max_bridged_files: 3,
max_bridged_chunks_per_file: 2,
repo_path_prefix: config.server.repo_path_prefix.clone(),
..ContextConfig::default()
});
let mut total_precision = 0.0_f32;
let mut total_recall = 0.0_f32;
let mut total_f1 = 0.0_f32;
let mut valid_probes = 0usize;
for commit in commits {
let bundle = assembler.assemble(&commit.message, None).await;
if let Ok(bundle) = bundle {
let injected: Vec<String> = bundle
.files
.iter()
.map(|f| {
f.path
.strip_prefix(prefix.as_ref())
.unwrap_or(&f.path)
.trim_start_matches('/')
.to_string()
})
.collect();
let (p, r, f1) = score_probe(&injected, &commit.files);
total_precision += p;
total_recall += r;
total_f1 += f1;
valid_probes += 1;
}
if let Some(pb) = pb {
pb.inc(1);
}
}
let n = valid_probes.max(1) as f32;
let result = GridResult {
semantic_weight: point.semantic_weight,
doc_demotion: point.doc_demotion,
rrf_k: point.rrf_k,
recency_half_life_days: point.recency_half_life_days,
recency_weight: point.recency_weight,
coupling_depth,
budget_lines: Some(point.budget_lines),
search_limit: Some(point.search_limit),
bridge_mode: Some(point.bridge_mode),
bridge_boost_factor: if point.bridge_boost_factor > 0.0 {
Some(point.bridge_boost_factor)
} else {
None
},
precision: total_precision / n,
recall: total_recall / n,
f1: total_f1 / n,
};
if let Some(path) = progress_file {
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
{
let _ = writeln!(f, "{}", serde_json::to_string(&result).unwrap_or_default());
}
}
grid_results.push(result);
}
Ok(grid_results)
}
fn reindex_coupling(
git: &GitAnalyzer,
db_path: &std::path::Path,
depth: usize,
threshold: u32,
freq_weight: f32,
recency_days: f32,
) -> Result<()> {
let couplings = git.analyze_coupling(depth, threshold, freq_weight, recency_days)?;
let ms = MetadataStore::open(db_path)?;
ms.clear_coupling()?;
ms.begin_transaction()?;
for c in &couplings {
ms.upsert_coupling(c)?;
}
ms.commit()?;
Ok(())
}
fn format_result(r: &GridResult, full: bool, bridge_sweep: bool) -> String {
let mut s = format!(
" sw={:.2} dd={:.2} k={:.0}",
r.semantic_weight, r.doc_demotion, r.rrf_k
);
if let Some(b) = r.budget_lines {
if b != 300 {
s.push_str(&format!(" b={}", b));
}
}
if let Some(sl) = r.search_limit {
if sl != 20 {
s.push_str(&format!(" sl={}", sl));
}
}
if let Some(bm) = r.bridge_mode {
if bridge_sweep || bm != BridgeMode::Inject {
s.push_str(&format!(" bm={}", bm));
}
}
if let Some(bbf) = r.bridge_boost_factor {
if bbf > 0.0 {
s.push_str(&format!(" bbf={:.2}", bbf));
}
}
if full {
if let Some(hl) = r.recency_half_life_days {
s.push_str(&format!(" hl={:.0}", hl));
}
if let Some(rw) = r.recency_weight {
s.push_str(&format!(" rw={:.2}", rw));
}
if let Some(cd) = r.coupling_depth {
s.push_str(&format!(" cd={}", cd));
}
}
s.push_str(&format!(
" F1={:.3} P={:.3} R={:.3}",
r.f1, r.precision, r.recall
));
s
}
fn resolve_git_source(
args: &CalibrateArgs,
repo_root: &std::path::Path,
db_path: &std::path::Path,
) -> Result<PathBuf> {
if let Some(ref source) = args.source {
return source
.canonicalize()
.with_context(|| format!("Invalid source path: {}", source.display()));
}
let ms = MetadataStore::open(db_path).ok();
if let Some(ref repo_name) = args.repo {
if let Some(ref ms) = ms {
let key = format!("repo_source:{}", repo_name);
if let Ok(Some(path_str)) = ms.get_meta(&key) {
let path = PathBuf::from(&path_str);
if path.exists() {
return Ok(path);
}
eprintln!(
" Warning: stored source path {} no longer exists, falling back to repo root",
path_str
);
} else {
bail!(
"No source path stored for repo '{}'. Re-run `bobbin index --repo {} --source <path>` to register it.",
repo_name, repo_name
);
}
}
}
if let Some(ref ms) = ms {
let sources = collect_repo_sources(ms);
if sources.len() == 1 {
let (name, path) = &sources[0];
if path.exists() {
if !args.repo.is_some() {
return Ok(path.clone());
}
} else {
eprintln!(
" Warning: stored source for '{}' ({}) no longer exists",
name,
path.display()
);
}
} else if sources.len() > 1 {
let git_check = std::process::Command::new("git")
.args(["rev-parse", "--git-dir"])
.current_dir(repo_root)
.output();
if git_check.map(|o| o.status.success()).unwrap_or(false) {
return Ok(repo_root.to_path_buf());
}
let repo_names: Vec<&str> = sources.iter().map(|(n, _)| n.as_str()).collect();
bail!(
"Multiple repos indexed ({}) but workspace root is not a git repo.\n\
Use --repo <name> to specify which repo to calibrate against.\n\
Available repos: {}",
sources.len(),
repo_names.join(", ")
);
}
}
Ok(repo_root.to_path_buf())
}
fn collect_repo_sources(ms: &MetadataStore) -> Vec<(String, PathBuf)> {
ms.get_meta_by_prefix("repo_source:")
.unwrap_or_default()
.into_iter()
.map(|(key, value)| {
let name = key.strip_prefix("repo_source:").unwrap_or(&key).to_string();
(name, PathBuf::from(value))
})
.collect()
}
pub async fn run(args: CalibrateArgs, output: OutputConfig) -> Result<()> {
let repo_root = args
.path
.canonicalize()
.with_context(|| format!("Invalid path: {}", args.path.display()))?;
let config_path = Config::config_path(&repo_root);
if !config_path.exists() {
bail!("{}", super::not_initialized_error(&repo_root));
}
let config = Config::load_merged(&repo_root)?;
let lance_path = Config::lance_path(&repo_root);
let db_path = Config::db_path(&repo_root);
let model_dir = Config::model_cache_dir()?;
let vector_store = VectorStore::open(&lance_path)
.await
.context("Failed to open vector store")?;
let count = vector_store.count().await?;
if count == 0 {
bail!("Index is empty. Run `bobbin index` first.");
}
let git_source = resolve_git_source(&args, &repo_root, &db_path)?;
let git = GitAnalyzer::new(&git_source).with_context(|| {
format!(
"Not a git repository: {}. Use --source to specify the repo path.",
git_source.display()
)
})?;
if !output.quiet {
if args.full {
eprintln!(
"{}",
"Extended calibration (includes recency and coupling parameters)...".bold()
);
} else {
eprintln!(
"{}",
"Calibrating search parameters against git history...".bold()
);
}
if git_source != repo_root {
eprintln!(" Sampling commits from: {}", git_source.display());
}
}
let commits = sample_commits(&git, &args.since, args.samples)?;
let is_terse = detect_terse_messages(&commits);
if is_terse && !output.quiet {
eprintln!(
"{}",
"⚠ Many commit messages are too short for reliable calibration.\n \
Calibration accuracy may be reduced."
.yellow()
);
}
if !output.quiet {
eprintln!(
" Sampled {} commits across last {}",
commits.len(),
args.since
);
}
let _embedder_check = Embedder::from_config(&config.embedding, &model_dir)
.context("Failed to load embedding model")?;
let grid_results = if args.bridge_sweep {
run_bridge_sweep(
&output,
&config,
&commits,
&lance_path,
&db_path,
&model_dir,
&repo_root,
)
.await?
} else if args.full {
run_full_sweep(
&args,
&output,
&config,
&commits,
&git,
&lance_path,
&db_path,
&model_dir,
&repo_root,
)
.await?
} else {
run_core_sweep(
&args,
&output,
&config,
&commits,
&lance_path,
&db_path,
&model_dir,
&repo_root,
)
.await?
};
let mut sorted = grid_results;
sorted.sort_by(|a, b| b.f1.partial_cmp(&a.f1).unwrap());
let best = sorted
.first()
.expect("Grid should have at least one result");
let current_f1 = sorted
.iter()
.find(|r| {
(r.semantic_weight - config.search.semantic_weight).abs() < 0.01
&& (r.doc_demotion - config.search.doc_demotion).abs() < 0.01
})
.map(|r| r.f1)
.unwrap_or(0.0);
let snapshot = capture_snapshot(&vector_store, &git).await?;
let total_probes = sorted.len() * commits.len();
let calibration = CalibrationResult {
calibrated_at: Utc::now().to_rfc3339(),
snapshot,
best_config: CalibratedConfig {
semantic_weight: best.semantic_weight,
doc_demotion: best.doc_demotion,
rrf_k: best.rrf_k,
recency_half_life_days: best.recency_half_life_days,
recency_weight: best.recency_weight,
coupling_depth: best.coupling_depth,
budget_lines: best.budget_lines,
search_limit: best.search_limit,
bridge_mode: best.bridge_mode,
bridge_boost_factor: best.bridge_boost_factor,
},
top_results: sorted.iter().take(10).cloned().collect(),
sample_count: commits.len(),
probe_count: total_probes,
terse_warning: is_terse,
};
if output.json {
println!("{}", serde_json::to_string_pretty(&calibration)?);
} else if !output.quiet {
eprintln!();
eprintln!("{}", "Calibration results (top 5 by F1):".bold());
for result in sorted.iter().take(5) {
eprintln!("{}", format_result(result, args.full, args.bridge_sweep));
}
eprintln!();
eprintln!(
" Current config F1: {:.3} (sw={:.2})",
current_f1, config.search.semantic_weight
);
eprintln!(
" Best config F1: {:.3} (sw={:.2}) {}",
best.f1,
best.semantic_weight,
if best.f1 > current_f1 && current_f1 > 0.0 {
let pct = ((best.f1 - current_f1) / current_f1 * 100.0) as i32;
format!("[+{}% improvement]", pct).green().to_string()
} else {
String::new()
}
);
}
if args.apply {
save_calibration(&repo_root, &calibration)?;
if !output.quiet {
eprintln!(
"\n {} Applied best config to .bobbin/calibration.json",
"✓".green()
);
}
} else if !output.quiet && !output.json {
eprintln!("\n Run with {} to apply best config.", "--apply".bold());
}
if args.full {
clear_cache(&repo_root);
}
Ok(())
}
async fn run_core_sweep(
args: &CalibrateArgs,
output: &OutputConfig,
config: &Config,
commits: &[SampledCommit],
lance_path: &std::path::Path,
db_path: &std::path::Path,
model_dir: &std::path::Path,
repo_root: &std::path::Path,
) -> Result<Vec<GridResult>> {
let budgets = match args.budget {
Some(b) => vec![b],
None => vec![300],
};
let search_limits = match args.search_limit {
Some(sl) => vec![sl],
None => vec![20],
};
let grid = build_grid(&budgets, &search_limits);
let total_probes = grid.len() * commits.len();
if !output.quiet {
eprintln!(
" Grid: {} configs × {} commits = {} probes",
grid.len(),
commits.len(),
total_probes
);
}
let pb = if !output.quiet {
let pb = ProgressBar::new(total_probes as u64);
pb.set_style(
ProgressStyle::default_bar()
.template(" Running {pos}/{len} probes {bar:30} {eta}")
.unwrap()
.progress_chars("█▓░"),
);
Some(pb)
} else {
None
};
let prog = progress_path(repo_root);
let results = run_probes(
&grid,
commits,
lance_path,
db_path,
config,
model_dir,
None,
repo_root,
pb.as_ref(),
Some(&prog),
)
.await?;
if let Some(pb) = &pb {
pb.finish_and_clear();
}
Ok(results)
}
async fn run_bridge_sweep(
output: &OutputConfig,
config: &Config,
commits: &[SampledCommit],
lance_path: &std::path::Path,
db_path: &std::path::Path,
model_dir: &std::path::Path,
repo_root: &std::path::Path,
) -> Result<Vec<GridResult>> {
let cal = load_calibration(repo_root)
.context("--bridge-sweep requires existing calibration.json (run core sweep first)")?;
let best = &cal.best_config;
let budgets = vec![best.budget_lines.unwrap_or(300)];
let search_limits = vec![best.search_limit.unwrap_or(20)];
let bridge_modes = [
BridgeMode::Off,
BridgeMode::Inject,
BridgeMode::Boost,
BridgeMode::BoostInject,
];
let bridge_boost_factors = [0.15, 0.3, 0.5];
let grid = build_grid_with_recency(
&[],
&[],
&budgets,
&search_limits,
&bridge_modes,
&bridge_boost_factors,
);
let sw = best.semantic_weight;
let dd = best.doc_demotion;
let k = best.rrf_k;
let grid: Vec<GridPoint> = grid
.into_iter()
.filter(|p| {
(p.semantic_weight - sw).abs() < 0.01
&& (p.doc_demotion - dd).abs() < 0.01
&& (p.rrf_k - k).abs() < 0.01
})
.collect();
let total_probes = grid.len() * commits.len();
if !output.quiet {
eprintln!(
" Bridge sweep: {} configs × {} commits = {} probes (sw={:.2} dd={:.1} b={} sl={})",
grid.len(),
commits.len(),
total_probes,
sw,
dd,
budgets[0],
search_limits[0],
);
}
let pb = if !output.quiet {
let pb = ProgressBar::new(total_probes as u64);
pb.set_style(
ProgressStyle::default_bar()
.template(" Running {pos}/{len} probes {bar:30} {eta}")
.unwrap()
.progress_chars("█▓░"),
);
Some(pb)
} else {
None
};
let prog = progress_path(repo_root);
let results = run_probes(
&grid,
commits,
lance_path,
db_path,
config,
model_dir,
None,
repo_root,
pb.as_ref(),
Some(&prog),
)
.await?;
if let Some(pb) = &pb {
pb.finish_and_clear();
}
Ok(results)
}
async fn run_full_sweep(
args: &CalibrateArgs,
output: &OutputConfig,
config: &Config,
commits: &[SampledCommit],
git: &GitAnalyzer,
lance_path: &std::path::Path,
db_path: &std::path::Path,
model_dir: &std::path::Path,
repo_root: &std::path::Path,
) -> Result<Vec<GridResult>> {
let half_lives: Vec<f32> = vec![7.0, 14.0, 30.0, 90.0];
let recency_weights: Vec<f32> = vec![0.0, 0.15, 0.30, 0.50];
let coupling_depths: Vec<usize> = vec![500, 2000, 5000, 20000];
let budgets: Vec<usize> = match args.budget {
Some(b) => vec![b],
None => vec![150, 300, 500],
};
let search_limits: Vec<usize> = match args.search_limit {
Some(sl) => vec![sl],
None => vec![10, 20, 30, 40],
};
let bridge_modes = [
BridgeMode::Off,
BridgeMode::Inject,
BridgeMode::Boost,
BridgeMode::BoostInject,
];
let bridge_boost_factors = [0.15, 0.3, 0.5];
let grid = build_grid_with_recency(
&half_lives,
&recency_weights,
&budgets,
&search_limits,
&bridge_modes,
&bridge_boost_factors,
);
let total_configs = grid.len() * coupling_depths.len();
let total_probes = total_configs * commits.len();
if !output.quiet {
eprintln!(
" Grid: {} configs × {} coupling depths = {} total configs",
grid.len(),
coupling_depths.len(),
total_configs,
);
eprintln!(
" Total: {} configs × {} commits = {} probes",
total_configs,
commits.len(),
total_probes,
);
}
let mut cache = if args.resume {
load_cache(repo_root).unwrap_or_else(|| {
if !output.quiet {
eprintln!(" No cache found, starting fresh.");
}
SweepCache {
completed_depths: HashMap::new(),
sample_count: commits.len(),
sample_hashes: commits.iter().map(|c| c.hash.clone()).collect(),
}
})
} else {
SweepCache {
completed_depths: HashMap::new(),
sample_count: commits.len(),
sample_hashes: commits.iter().map(|c| c.hash.clone()).collect(),
}
};
if args.resume && cache.sample_count != commits.len() {
if !output.quiet {
eprintln!(
" {} Cache sample count mismatch ({} vs {}), starting fresh.",
"⚠".yellow(),
cache.sample_count,
commits.len()
);
}
cache.completed_depths.clear();
}
let mut all_results: Vec<GridResult> = Vec::new();
for (depth_key, results) in &cache.completed_depths {
if !output.quiet {
eprintln!(
" Restored {} results from cache (depth={})",
results.len(),
depth_key
);
}
all_results.extend(results.iter().cloned());
}
let remaining_depths: Vec<&usize> = coupling_depths
.iter()
.filter(|d| !cache.completed_depths.contains_key(&d.to_string()))
.collect();
let remaining_probes = remaining_depths.len() * grid.len() * commits.len();
let pb = if !output.quiet && remaining_probes > 0 {
let pb = ProgressBar::new(remaining_probes as u64);
pb.set_style(
ProgressStyle::default_bar()
.template(" [{msg}] {pos}/{len} probes {bar:30} {eta}")
.unwrap()
.progress_chars("█▓░"),
);
Some(pb)
} else {
None
};
let original_coupling_depth = config.git.coupling_depth;
for &depth in &coupling_depths {
let depth_key = depth.to_string();
if cache.completed_depths.contains_key(&depth_key) {
continue;
}
if let Some(pb) = &pb {
pb.set_message(format!("cd={}", depth));
}
if !output.quiet {
if let Some(pb) = &pb {
pb.suspend(|| {
eprintln!(" Re-indexing coupling data (depth={})...", depth);
});
}
}
reindex_coupling(
git,
db_path,
depth,
config.git.coupling_threshold,
config.git.coupling_freq_weight,
config.git.coupling_recency_days,
)?;
let prog = progress_path(repo_root);
let depth_results = run_probes(
&grid,
commits,
lance_path,
db_path,
config,
model_dir,
Some(depth),
repo_root,
pb.as_ref(),
Some(&prog),
)
.await?;
all_results.extend(depth_results.iter().cloned());
cache.completed_depths.insert(depth_key, depth_results);
save_cache(repo_root, &cache)?;
}
if let Some(pb) = &pb {
pb.finish_and_clear();
}
if !output.quiet {
eprintln!(
" Restoring coupling data (depth={})...",
original_coupling_depth
);
}
reindex_coupling(
git,
db_path,
original_coupling_depth,
config.git.coupling_threshold,
config.git.coupling_freq_weight,
config.git.coupling_recency_days,
)?;
Ok(all_results)
}
pub trait CalibrationGuard {
fn should_recalibrate(
&self,
current: &ProjectSnapshot,
previous: Option<&CalibrationResult>,
) -> bool;
}
pub struct DefaultCalibrationGuard;
impl CalibrationGuard for DefaultCalibrationGuard {
fn should_recalibrate(
&self,
current: &ProjectSnapshot,
previous: Option<&CalibrationResult>,
) -> bool {
let Some(prev) = previous else {
return true;
};
let prev_chunks = prev.snapshot.chunk_count;
if prev_chunks > 0 {
let delta =
(current.chunk_count as f64 - prev_chunks as f64).abs() / prev_chunks as f64;
if delta > 0.2 {
return true;
}
}
if current.primary_language != prev.snapshot.primary_language
&& current.primary_language != "unknown"
&& prev.snapshot.primary_language != "unknown"
{
return true;
}
if let Ok(cal_time) = chrono::DateTime::parse_from_rfc3339(&prev.calibrated_at) {
let age = Utc::now() - cal_time.with_timezone(&Utc);
if age.num_days() > 30 {
return true;
}
}
false
}
}
impl CalibrateArgs {
pub fn default_for_auto(path: PathBuf, repo: Option<String>, source: Option<PathBuf>) -> Self {
Self {
samples: 20,
since: "6 months ago".to_string(),
search_limit: Some(20),
budget: Some(300),
apply: true,
verbose: false,
full: false,
resume: false,
bridge_sweep: false,
repo,
source,
path,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_score_probe_perfect() {
let injected = vec!["a.rs".into(), "b.rs".into()];
let truth = vec!["a.rs".into(), "b.rs".into()];
let (p, r, f1) = score_probe(&injected, &truth);
assert!((p - 1.0).abs() < 0.001);
assert!((r - 1.0).abs() < 0.001);
assert!((f1 - 1.0).abs() < 0.001);
}
#[test]
fn test_score_probe_no_overlap() {
let injected = vec!["a.rs".into(), "b.rs".into()];
let truth = vec!["c.rs".into(), "d.rs".into()];
let (p, r, f1) = score_probe(&injected, &truth);
assert!((p - 0.0).abs() < 0.001);
assert!((r - 0.0).abs() < 0.001);
assert!((f1 - 0.0).abs() < 0.001);
}
#[test]
fn test_score_probe_partial() {
let injected = vec!["a.rs".into(), "b.rs".into(), "c.rs".into()];
let truth = vec!["a.rs".into(), "d.rs".into()];
let (p, r, _f1) = score_probe(&injected, &truth);
assert!((p - 0.333).abs() < 0.01);
assert!((r - 0.5).abs() < 0.001);
}
#[test]
fn test_score_probe_empty_injected() {
let injected: Vec<String> = vec![];
let truth = vec!["a.rs".into()];
let (p, r, f1) = score_probe(&injected, &truth);
assert!((p - 0.0).abs() < 0.001);
assert!((r - 0.0).abs() < 0.001);
assert!((f1 - 0.0).abs() < 0.001);
}
#[test]
fn test_is_noise_commit() {
assert!(is_noise_commit("chore: update deps"));
assert!(is_noise_commit("ci: fix pipeline"));
assert!(is_noise_commit("docs: update readme"));
assert!(is_noise_commit("Bump version to 1.2.3"));
assert!(!is_noise_commit("Fix parser to handle nested types"));
assert!(!is_noise_commit(
"Add webhook support for real-time indexing"
));
}
#[test]
fn test_is_terse_message() {
assert!(is_terse_message("fix"));
assert!(is_terse_message("update"));
assert!(is_terse_message("wip"));
assert!(is_terse_message("short msg"));
assert!(!is_terse_message(
"Fix parser to handle nested generic types correctly"
));
}
#[test]
fn test_detect_terse_majority() {
let commits = vec![
SampledCommit {
hash: "a".into(),
message: "fix".into(),
files: vec![],
},
SampledCommit {
hash: "b".into(),
message: "wip".into(),
files: vec![],
},
SampledCommit {
hash: "c".into(),
message: "This is a proper commit message about fixing auth".into(),
files: vec![],
},
];
assert!(detect_terse_messages(&commits));
}
#[test]
fn test_detect_terse_minority() {
let commits = vec![
SampledCommit {
hash: "a".into(),
message: "fix".into(),
files: vec![],
},
SampledCommit {
hash: "b".into(),
message: "Fix parser to handle nested types".into(),
files: vec![],
},
SampledCommit {
hash: "c".into(),
message: "Add webhook support for real-time reindexing".into(),
files: vec![],
},
];
assert!(!detect_terse_messages(&commits));
}
#[test]
fn test_build_grid_size() {
let grid = build_grid(&[300], &[20]);
assert_eq!(grid.len() * 20, 300);
assert!(grid[0].recency_half_life_days.is_none());
assert!(grid[0].recency_weight.is_none());
assert_eq!(grid[0].budget_lines, 300);
assert_eq!(grid[0].search_limit, 20);
}
#[test]
fn test_build_grid_sweep_budget_search_limit() {
let grid = build_grid(&[150, 300, 500], &[10, 20, 30, 40]);
assert_eq!(grid.len(), 180);
}
#[test]
fn test_build_grid_with_recency() {
let half_lives = [7.0, 14.0, 30.0, 90.0];
let recency_weights = [0.0, 0.15, 0.30, 0.50];
let grid = build_grid_with_recency(
&half_lives,
&recency_weights,
&[300],
&[20],
&[BridgeMode::Inject],
&[0.3],
);
assert_eq!(grid.len(), 240);
assert!(grid[0].recency_half_life_days.is_some());
assert!(grid[0].recency_weight.is_some());
}
#[test]
fn test_build_grid_recency_empty_fallback() {
let grid = build_grid_with_recency(&[], &[], &[300], &[20], &[BridgeMode::Inject], &[0.3]);
assert_eq!(grid.len(), 15);
assert!(grid[0].recency_half_life_days.is_none());
}
#[test]
fn test_build_grid_bridge_mode_sweep() {
let bridge_modes = [
BridgeMode::Off,
BridgeMode::Inject,
BridgeMode::Boost,
BridgeMode::BoostInject,
];
let grid = build_grid_with_recency(&[], &[], &[300], &[20], &bridge_modes, &[0.15, 0.3]);
assert_eq!(grid.len(), 15 * 6);
}
#[test]
fn test_format_result_core() {
let r = GridResult {
semantic_weight: 0.3,
doc_demotion: 0.3,
rrf_k: 60.0,
recency_half_life_days: None,
recency_weight: None,
coupling_depth: None,
budget_lines: Some(300),
search_limit: Some(20),
bridge_mode: None,
bridge_boost_factor: None,
precision: 0.4,
recall: 0.5,
f1: 0.444,
};
let s = format_result(&r, false, false);
assert!(s.contains("sw=0.30"));
assert!(!s.contains("hl="));
assert!(!s.contains("b="));
assert!(!s.contains("sl="));
}
#[test]
fn test_format_result_non_default_budget_search_limit() {
let r = GridResult {
semantic_weight: 0.3,
doc_demotion: 0.3,
rrf_k: 60.0,
recency_half_life_days: None,
recency_weight: None,
coupling_depth: None,
budget_lines: Some(500),
search_limit: Some(40),
bridge_mode: None,
bridge_boost_factor: None,
precision: 0.4,
recall: 0.5,
f1: 0.444,
};
let s = format_result(&r, false, false);
assert!(s.contains("b=500"));
assert!(s.contains("sl=40"));
}
#[test]
fn test_format_result_full() {
let r = GridResult {
semantic_weight: 0.3,
doc_demotion: 0.3,
rrf_k: 60.0,
recency_half_life_days: Some(14.0),
recency_weight: Some(0.15),
coupling_depth: Some(5000),
budget_lines: Some(300),
search_limit: Some(20),
bridge_mode: None,
bridge_boost_factor: None,
precision: 0.4,
recall: 0.5,
f1: 0.444,
};
let s = format_result(&r, true, false);
assert!(s.contains("hl=14"));
assert!(s.contains("rw=0.15"));
assert!(s.contains("cd=5000"));
}
#[test]
fn test_sweep_cache_roundtrip() {
let mut cache = SweepCache {
completed_depths: HashMap::new(),
sample_count: 20,
sample_hashes: vec!["abc123".into()],
};
cache.completed_depths.insert(
"5000".into(),
vec![GridResult {
semantic_weight: 0.3,
doc_demotion: 0.3,
rrf_k: 60.0,
recency_half_life_days: Some(30.0),
recency_weight: Some(0.3),
coupling_depth: Some(5000),
budget_lines: Some(300),
search_limit: Some(20),
bridge_mode: None,
bridge_boost_factor: None,
precision: 0.4,
recall: 0.5,
f1: 0.444,
}],
);
let json = serde_json::to_string(&cache).unwrap();
let loaded: SweepCache = serde_json::from_str(&json).unwrap();
assert_eq!(loaded.sample_count, 20);
assert_eq!(loaded.completed_depths.len(), 1);
assert_eq!(loaded.completed_depths["5000"][0].f1, 0.444);
}
fn make_snapshot(chunks: usize, lang: &str) -> ProjectSnapshot {
ProjectSnapshot {
chunk_count: chunks,
file_count: 0,
primary_language: lang.to_string(),
language_distribution: vec![],
repo_age_days: 100,
recent_commit_rate: 5.0,
}
}
fn make_calibration(chunks: usize, lang: &str, days_ago: i64) -> CalibrationResult {
let cal_time = Utc::now() - chrono::Duration::days(days_ago);
CalibrationResult {
calibrated_at: cal_time.to_rfc3339(),
snapshot: make_snapshot(chunks, lang),
best_config: CalibratedConfig {
semantic_weight: 0.7,
doc_demotion: 0.3,
rrf_k: 60.0,
recency_half_life_days: None,
recency_weight: None,
coupling_depth: None,
budget_lines: None,
search_limit: None,
bridge_mode: None,
bridge_boost_factor: None,
},
top_results: vec![],
sample_count: 20,
probe_count: 300,
terse_warning: false,
}
}
#[test]
fn test_guard_first_run_always_calibrates() {
let guard = DefaultCalibrationGuard;
let current = make_snapshot(1000, "rust");
assert!(guard.should_recalibrate(¤t, None));
}
#[test]
fn test_guard_chunk_delta_over_20_pct() {
let guard = DefaultCalibrationGuard;
let current = make_snapshot(1300, "rust"); let prev = make_calibration(1000, "rust", 5);
assert!(guard.should_recalibrate(¤t, Some(&prev)));
}
#[test]
fn test_guard_chunk_delta_under_20_pct() {
let guard = DefaultCalibrationGuard;
let current = make_snapshot(1100, "rust"); let prev = make_calibration(1000, "rust", 5);
assert!(!guard.should_recalibrate(¤t, Some(&prev)));
}
#[test]
fn test_guard_language_change() {
let guard = DefaultCalibrationGuard;
let current = make_snapshot(1000, "python");
let prev = make_calibration(1000, "rust", 5);
assert!(guard.should_recalibrate(¤t, Some(&prev)));
}
#[test]
fn test_guard_language_unknown_ignored() {
let guard = DefaultCalibrationGuard;
let current = make_snapshot(1000, "unknown");
let prev = make_calibration(1000, "rust", 5);
assert!(!guard.should_recalibrate(¤t, Some(&prev)));
}
#[test]
fn test_guard_age_over_30_days() {
let guard = DefaultCalibrationGuard;
let current = make_snapshot(1000, "rust");
let prev = make_calibration(1000, "rust", 35);
assert!(guard.should_recalibrate(¤t, Some(&prev)));
}
#[test]
fn test_guard_age_under_30_days() {
let guard = DefaultCalibrationGuard;
let current = make_snapshot(1000, "rust");
let prev = make_calibration(1000, "rust", 10);
assert!(!guard.should_recalibrate(¤t, Some(&prev)));
}
#[test]
fn test_guard_no_change() {
let guard = DefaultCalibrationGuard;
let current = make_snapshot(1000, "rust");
let prev = make_calibration(1000, "rust", 5);
assert!(!guard.should_recalibrate(¤t, Some(&prev)));
}
#[test]
fn test_default_for_auto() {
let home = PathBuf::from("/var/lib/bobbin");
let source = PathBuf::from("/var/lib/bobbin/repos/demo");
let args = CalibrateArgs::default_for_auto(
home.clone(),
Some("demo".to_string()),
Some(source.clone()),
);
assert!(args.apply);
assert!(!args.verbose);
assert_eq!(args.samples, 20);
assert_eq!(args.search_limit, Some(20));
assert_eq!(args.budget, Some(300));
assert_eq!(args.path, home);
assert_eq!(args.repo, Some("demo".to_string()));
assert_eq!(args.source, Some(source));
}
#[test]
fn test_capture_snapshot_from_index() {
let snap = capture_snapshot_from_index(500);
assert_eq!(snap.chunk_count, 500);
assert_eq!(snap.primary_language, "unknown");
}
#[test]
fn test_full_sweep_grid_geometry() {
let half_lives = [7.0f32, 14.0, 30.0, 90.0];
let recency_weights = [0.0f32, 0.15, 0.30, 0.50];
let budgets = [150usize, 300, 500];
let search_limits = [10usize, 20, 30, 40];
let bridge_modes = [
BridgeMode::Off,
BridgeMode::Inject,
BridgeMode::Boost,
BridgeMode::BoostInject,
];
let bridge_boost_factors = [0.15f32, 0.3, 0.5];
let grid = build_grid_with_recency(
&half_lives,
&recency_weights,
&budgets,
&search_limits,
&bridge_modes,
&bridge_boost_factors,
);
assert_eq!(grid.len(), 23_040, "default full-sweep grid size");
let off_or_inject = grid
.iter()
.filter(|p| matches!(p.bridge_mode, BridgeMode::Off | BridgeMode::Inject))
.count();
assert_eq!(
off_or_inject,
23_040 / 8 * 2,
"Off and Inject contribute one point each"
);
let coupling_depths = 4;
let default_commits = 20;
assert_eq!(grid.len() * coupling_depths, 92_160);
assert_eq!(grid.len() * coupling_depths * default_commits, 1_843_200);
}
#[test]
fn test_pinned_dimensions_shrink_the_grid() {
let half_lives = [7.0f32, 14.0, 30.0, 90.0];
let recency_weights = [0.0f32, 0.15, 0.30, 0.50];
let bridge_modes = [
BridgeMode::Off,
BridgeMode::Inject,
BridgeMode::Boost,
BridgeMode::BoostInject,
];
let bridge_boost_factors = [0.15f32, 0.3, 0.5];
let pinned = build_grid_with_recency(
&half_lives,
&recency_weights,
&[300],
&[20],
&bridge_modes,
&bridge_boost_factors,
);
assert_eq!(pinned.len(), 23_040 / 3 / 4);
}
}