#![allow(dead_code, unused_imports, unused_variables)]
use super::ast_tools::{self, AstMutationResult};
use super::fitness::{self, SabConfig, SabResult};
use super::sandbox::SandboxConfig;
use super::telemetry;
use super::tournament::{self, Hypothesis, HypothesisResult, TournamentConfig};
use super::{
is_protected, EvolutionConfig, FitnessMetrics, FitnessWeights, GenerationRating, LlmConfig,
};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Instant;
#[derive(Debug, Clone)]
pub struct GenerationWinner {
pub generation: usize,
pub description: String,
pub composite_score: f64,
pub sab_delta: f64,
pub token_delta: f64,
pub patch: String,
pub git_tag: Option<String>,
}
#[derive(Debug)]
pub struct EvolutionResult {
pub generations_run: usize,
pub improvements: Vec<GenerationWinner>,
pub final_sab_score: f64,
pub initial_sab_score: f64,
pub total_duration: std::time::Duration,
}
const DEFAULT_TOKEN_BUDGET: u64 = 500_000;
const DEFAULT_TIMEOUT_SECS: f64 = 3600.0;
const EVOLVE_FEATURES: &[&str] = &["self-improvement"];
fn rating_from_score(score: f64) -> GenerationRating {
match score as u32 {
85..=100 => GenerationRating::Bloom,
60..=84 => GenerationRating::Grow,
30..=59 => GenerationRating::Wilt,
_ => GenerationRating::Frost,
}
}
fn first_usize(s: &str) -> usize {
s.split_whitespace()
.filter_map(|w| w.parse().ok())
.next()
.unwrap_or(0)
}
pub fn parse_test_summary(output: &str) -> (usize, usize) {
let mut passed = 0usize;
let mut failed = 0usize;
for line in output.lines() {
if line.contains("test result:") {
for part in line.split(';') {
let part = part.trim();
if part.contains("passed") {
passed += first_usize(part);
} else if part.contains("failed") {
failed += first_usize(part);
}
}
}
}
(passed, passed + failed)
}
fn features_arg(features: &[&str]) -> String {
features.join(",")
}
fn measure_compile_test_baseline(
dir: &Path,
features: &[&str],
timeout_secs: f64,
) -> Result<FitnessMetrics, String> {
let start = Instant::now();
let feat = features_arg(features);
let mut check_cmd = Command::new("cargo");
check_cmd.arg("check").current_dir(dir);
if !features.is_empty() {
check_cmd.arg("--features").arg(&feat);
}
let check = check_cmd
.output()
.map_err(|e| format!("cargo check failed to run: {e}"))?;
if !check.status.success() {
return Err(format!(
"cargo check failed:\n{}",
String::from_utf8_lossy(&check.stderr)
));
}
let mut test_cmd = Command::new("cargo");
test_cmd.arg("test").current_dir(dir);
if !features.is_empty() {
test_cmd.arg("--features").arg(&feat);
}
let test = test_cmd
.output()
.map_err(|e| format!("cargo test failed to run: {e}"))?;
let test_stdout = String::from_utf8_lossy(&test.stdout);
let test_stderr = String::from_utf8_lossy(&test.stderr);
let full_output = format!("{}\n{}", test_stdout, test_stderr);
let (tests_passed, tests_total) = parse_test_summary(&full_output);
let mut fmt_cmd = Command::new("cargo");
fmt_cmd.args(["fmt", "--", "--check"]).current_dir(dir);
let fmt_ok = fmt_cmd
.output()
.map(|o| o.status.success())
.unwrap_or(false);
let mut clippy_cmd = Command::new("cargo");
clippy_cmd.arg("clippy").current_dir(dir);
if !features.is_empty() {
clippy_cmd.arg("--features").arg(&feat);
}
clippy_cmd.args(["--", "-D", "warnings"]);
let clippy_ok = clippy_cmd
.output()
.map(|o| o.status.success())
.unwrap_or(false);
let mut build_cmd = Command::new("cargo");
build_cmd.args(["build", "--release"]).current_dir(dir);
if !features.is_empty() {
build_cmd.arg("--features").arg(&feat);
}
let build = build_cmd
.output()
.map_err(|e| format!("cargo build --release failed to run: {e}"))?;
if !build.status.success() {
return Err(format!(
"cargo build --release failed:\n{}",
String::from_utf8_lossy(&build.stderr)
));
}
let binary_path = dir.join("target/release/selfware");
let binary_size_mb = std::fs::metadata(&binary_path)
.map(|m| m.len() as f64 / (1024.0 * 1024.0))
.unwrap_or(0.0);
let pass_ratio = if tests_total > 0 {
tests_passed as f64 / tests_total as f64
} else {
0.0
};
let fmt_factor = if fmt_ok { 1.0 } else { 0.95 };
let clippy_factor = if clippy_ok { 1.0 } else { 0.90 };
let sab_score = 100.0 * pass_ratio * fmt_factor * clippy_factor;
Ok(FitnessMetrics {
sab_score,
tokens_used: 0,
token_budget: DEFAULT_TOKEN_BUDGET,
wall_clock_secs: start.elapsed().as_secs_f64(),
timeout_secs,
test_coverage_pct: pass_ratio * 100.0,
binary_size_mb,
max_binary_size_mb: 50.0,
tests_passed,
tests_total,
visual_score: 0.0,
})
}
fn build_candidate_metrics(
worktree: &Path,
test_output: &std::process::Output,
test_duration: std::time::Duration,
features: &[&str],
config: &EvolutionConfig,
) -> Option<FitnessMetrics> {
let stdout = String::from_utf8_lossy(&test_output.stdout);
let stderr = String::from_utf8_lossy(&test_output.stderr);
let combined = format!("{}\n{}", stdout, stderr);
let (tests_passed, tests_total) = parse_test_summary(&combined);
let feat = features_arg(features);
let mut build_cmd = Command::new("cargo");
build_cmd.args(["build", "--release"]).current_dir(worktree);
if !features.is_empty() {
build_cmd.arg("--features").arg(&feat);
}
let build = build_cmd.output().ok()?;
if !build.status.success() {
return None;
}
let binary_path = worktree.join("target/release/selfware");
let binary_size_mb = std::fs::metadata(&binary_path)
.map(|m| m.len() as f64 / (1024.0 * 1024.0))
.unwrap_or(0.0);
let pass_ratio = if tests_total > 0 {
tests_passed as f64 / tests_total as f64
} else {
0.0
};
Some(FitnessMetrics {
sab_score: pass_ratio * 100.0,
tokens_used: 0,
token_budget: DEFAULT_TOKEN_BUDGET,
wall_clock_secs: test_duration.as_secs_f64(),
timeout_secs: DEFAULT_TIMEOUT_SECS,
test_coverage_pct: pass_ratio * 100.0,
binary_size_mb,
max_binary_size_mb: config.safety.max_binary_size_mb,
tests_passed,
tests_total,
visual_score: 0.0,
})
}
fn synthetic_baseline_metrics() -> FitnessMetrics {
FitnessMetrics {
sab_score: 50.0,
tokens_used: 0,
token_budget: DEFAULT_TOKEN_BUDGET,
wall_clock_secs: 0.0,
timeout_secs: DEFAULT_TIMEOUT_SECS,
test_coverage_pct: 50.0,
binary_size_mb: 15.0,
max_binary_size_mb: 50.0,
tests_passed: 0,
tests_total: 0,
visual_score: 0.0,
}
}
fn metrics_from_sab_result(
sab: &SabResult,
binary_path: &Path,
max_binary_size_mb: f64,
) -> FitnessMetrics {
let tests_passed = sab
.scenario_scores
.iter()
.filter(|s| s.tests_passed)
.count();
let tests_total = sab.scenario_scores.len();
let mut metrics = fitness::build_fitness_metrics(
sab,
DEFAULT_TOKEN_BUDGET,
DEFAULT_TIMEOUT_SECS,
binary_path,
tests_passed,
tests_total,
max_binary_size_mb,
);
metrics.sab_score = sab.aggregate_score;
metrics
}
pub async fn evolve(config: EvolutionConfig, repo_root: &Path) -> EvolutionResult {
let start = Instant::now();
let mut hall_of_fame: Vec<GenerationWinner> = Vec::new();
let mut generation: usize = 0;
let _ = std::fs::write(repo_root.join(".evolution-log.jsonl"), "");
log_event(
repo_root,
&serde_json::json!({
"event": "start",
"timestamp": chrono_now(),
"generations": config.generations,
"population_size": config.population_size,
"endpoint": config.llm.endpoint,
"model": config.llm.model,
}),
);
log_phase("Measuring baseline fitness...");
let sab_config = SabConfig::default();
let sab_mode = std::env::var("SELFWARE_EVOLVE_SAB").is_ok();
let baseline_metrics = if sab_mode {
let selfware_binary = repo_root.join("target/release/selfware");
match fitness::run_sab(&selfware_binary, &sab_config) {
Ok(r) => {
metrics_from_sab_result(&r, &selfware_binary, config.safety.max_binary_size_mb)
}
Err(e) => {
log_warning(&format!(
"SAB baseline failed ({}), using synthetic baseline",
e
));
synthetic_baseline_metrics()
}
}
} else {
log_phase("Using compile+test fitness (set SELFWARE_EVOLVE_SAB=1 for full SAB)");
match measure_compile_test_baseline(repo_root, EVOLVE_FEATURES, DEFAULT_TIMEOUT_SECS) {
Ok(mut m) => {
m.max_binary_size_mb = config.safety.max_binary_size_mb;
m
}
Err(e) => {
log_warning(&format!(
"Compile/test baseline failed ({}), using synthetic baseline",
e
));
synthetic_baseline_metrics()
}
}
};
let initial_sab = baseline_metrics.sab_score;
let mut current_baseline_metrics = baseline_metrics;
log_baseline(¤t_baseline_metrics, sab_mode);
loop {
generation += 1;
if config.generations > 0 && generation > config.generations {
break;
}
log_generation_start(generation);
let gen_start = Instant::now();
log_event(
repo_root,
&serde_json::json!({
"event": "generation_start",
"timestamp": chrono_now(),
"generation": generation,
}),
);
let telemetry_snapshot = telemetry::capture(repo_root, "sab_full").ok();
let telemetry_prompt = telemetry_snapshot
.as_ref()
.map(telemetry::to_agent_prompt)
.unwrap_or_default();
let history_prompt = format_evolution_history(&hall_of_fame);
let llm_start = Instant::now();
let hypotheses =
generate_hypotheses(&config, &telemetry_prompt, &history_prompt, repo_root).await;
log_event(
repo_root,
&serde_json::json!({
"event": "hypotheses_generated",
"timestamp": chrono_now(),
"generation": generation,
"count": hypotheses.len(),
"descriptions": hypotheses.iter().map(|h| &h.description).collect::<Vec<_>>(),
"llm_duration_secs": llm_start.elapsed().as_secs_f64(),
}),
);
if hypotheses.is_empty() {
log_warning("No valid hypotheses generated, retrying...");
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
continue;
}
let valid: Vec<_> = hypotheses
.into_iter()
.filter(|h| {
if hypothesis_touches_protected(h) {
log_warning(&format!(
"Hypothesis '{}' touches protected files, rejected",
h.id
));
return false;
}
true
})
.collect();
if valid.is_empty() {
log_warning("All hypotheses rejected by safety filter");
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
continue;
}
log_phase(&format!("Evaluating {} hypotheses...", valid.len()));
let sab_available =
sab_config.runner_script.exists() && std::env::var("SELFWARE_EVOLVE_SAB").is_ok();
let mut generation_winner: Option<(Hypothesis, FitnessMetrics, String)> = None;
for hypothesis in &valid {
log_phase(&format!(
" Testing '{}' [{}]...",
hypothesis.description, hypothesis.id
));
let worktree = match ast_tools::create_shadow_worktree(repo_root) {
Ok(w) => w,
Err(e) => {
log_warning(&format!(" Worktree failed: {}", e));
continue;
}
};
let _worktree_guard = WorktreeGuard::new(repo_root, worktree.clone());
if !apply_patch_to_worktree(&worktree, &hypothesis.patch) {
log_frost(generation, &format!("Patch failed: {}", hypothesis.id));
let preview = truncate_char_boundary(&hypothesis.patch, 500);
log_warning(&format!(" Edit preview:\n{}", preview));
continue;
}
let mut check_cmd = Command::new("cargo");
check_cmd
.arg("check")
.arg("--features")
.arg(features_arg(EVOLVE_FEATURES))
.current_dir(&worktree);
let check = check_cmd.output();
if check.map(|o| !o.status.success()).unwrap_or(true) {
log_frost(generation, &format!("Compile failed: {}", hypothesis.id));
continue;
}
let test_start = Instant::now();
let mut test_cmd = Command::new("cargo");
test_cmd
.arg("test")
.arg("--features")
.arg(features_arg(EVOLVE_FEATURES))
.current_dir(&worktree);
let test = test_cmd.output();
let test_output = match test {
Ok(o) => o,
Err(e) => {
log_warning(&format!(" Test execution failed: {}", e));
continue;
}
};
let test_passed = test_output.status.success();
let test_duration = test_start.elapsed();
if !test_passed {
let stderr = String::from_utf8_lossy(&test_output.stderr);
let fail_count = stderr
.lines()
.find(|l| l.contains("test result:"))
.unwrap_or("unknown");
log_frost(
generation,
&format!("Tests failed: {} — {}", hypothesis.id, fail_count),
);
continue;
}
let fmt_check = Command::new("cargo")
.args(["fmt", "--", "--check"])
.current_dir(&worktree)
.output();
if fmt_check.map(|o| !o.status.success()).unwrap_or(true) {
log_warning(&format!(
" {} failed fmt check — auto-formatting",
hypothesis.id
));
let _ = Command::new("cargo")
.args(["fmt"])
.current_dir(&worktree)
.output();
}
let mut clippy_cmd = Command::new("cargo");
clippy_cmd
.arg("clippy")
.arg("--features")
.arg(features_arg(EVOLVE_FEATURES))
.current_dir(&worktree);
clippy_cmd.args(["--", "-D", "warnings"]);
let clippy = clippy_cmd.output();
if clippy.map(|o| !o.status.success()).unwrap_or(true) {
log_frost(generation, &format!("Clippy failed: {}", hypothesis.id));
continue;
}
let winner_metrics = if sab_available {
let build = Command::new("cargo")
.args(["build", "--release", "--features", "self-improvement"])
.current_dir(&worktree)
.output();
if build.map(|o| !o.status.success()).unwrap_or(true) {
log_frost(
generation,
&format!("Release build failed: {}", hypothesis.id),
);
continue;
}
let mutated_binary = worktree.join("target/release/selfware");
match fitness::run_sab(&mutated_binary, &sab_config) {
Ok(r) => metrics_from_sab_result(
&r,
&mutated_binary,
config.safety.max_binary_size_mb,
),
Err(e) => {
log_warning(&format!(" SAB failed: {}", e));
continue;
}
}
} else {
match build_candidate_metrics(
&worktree,
&test_output,
test_duration,
EVOLVE_FEATURES,
&config,
) {
Some(m) => m,
None => {
log_frost(
generation,
&format!("Release build failed: {}", hypothesis.id),
);
continue;
}
}
};
let tested_diff = match capture_tested_diff(&worktree) {
Some(d) if !d.trim().is_empty() => d,
_ => {
log_frost(
generation,
&format!("No effective diff after evaluation: {}", hypothesis.id),
);
continue;
}
};
log_phase(&format!(
" ✓ '{}' passed (score: {:.0}, {:.1}s)",
hypothesis.description, winner_metrics.sab_score, winner_metrics.wall_clock_secs
));
if generation_winner.is_none() {
generation_winner = Some((hypothesis.clone(), winner_metrics, tested_diff));
}
}
let (winner, winner_metrics, tested_diff) = match generation_winner {
Some(w) => w,
None => {
log_frost(generation, "No hypotheses survived evaluation");
log_event(
repo_root,
&serde_json::json!({
"event": "generation_end",
"timestamp": chrono_now(),
"generation": generation,
"outcome": "frost",
"reason": "no hypotheses survived",
"duration_secs": gen_start.elapsed().as_secs_f64(),
}),
);
continue;
}
};
let baseline_composite = config.fitness_weights.composite(¤t_baseline_metrics);
let winner_composite = config.fitness_weights.composite(&winner_metrics);
if winner_composite > baseline_composite {
log_bloom(
generation,
&winner.description,
current_baseline_metrics.sab_score,
winner_metrics.sab_score,
);
let commit_msg = format!(
"🧬 Gen {} BLOOM: {:.0} → {:.0} | {}",
generation,
current_baseline_metrics.sab_score,
winner_metrics.sab_score,
winner.description
);
if commit_winner_to_repo(repo_root, &tested_diff, &commit_msg) {
let git_tag = if generation.is_multiple_of(config.checkpoint_interval) {
let tag = format!("evolve-gen-{}", generation);
let _ = Command::new("git")
.args(["tag", &tag])
.current_dir(repo_root)
.output();
Some(tag)
} else {
None
};
hall_of_fame.push(GenerationWinner {
generation,
description: winner.description.clone(),
composite_score: winner_composite,
sab_delta: winner_metrics.sab_score - current_baseline_metrics.sab_score,
token_delta: winner_metrics.tokens_used as f64
- current_baseline_metrics.tokens_used as f64,
patch: tested_diff.clone(),
git_tag,
});
log_event(
repo_root,
&serde_json::json!({
"event": "generation_end",
"timestamp": chrono_now(),
"generation": generation,
"outcome": "bloom",
"description": winner.description,
"score_before": current_baseline_metrics.sab_score,
"score_after": winner_metrics.sab_score,
"composite": winner_composite,
"duration_secs": gen_start.elapsed().as_secs_f64(),
"improvements_total": hall_of_fame.len(),
}),
);
current_baseline_metrics = winner_metrics;
}
} else {
let rating = if winner_composite < baseline_composite * 0.9 {
GenerationRating::Frost
} else {
GenerationRating::Wilt
};
log_reject(
generation,
&rating,
winner_metrics.sab_score,
current_baseline_metrics.sab_score,
);
log_event(
repo_root,
&serde_json::json!({
"event": "generation_end",
"timestamp": chrono_now(),
"generation": generation,
"outcome": format!("{}", rating),
"description": winner.description,
"winner_score": winner_metrics.sab_score,
"baseline_score": current_baseline_metrics.sab_score,
"duration_secs": gen_start.elapsed().as_secs_f64(),
}),
);
}
}
EvolutionResult {
generations_run: generation,
improvements: hall_of_fame,
final_sab_score: current_baseline_metrics.sab_score,
initial_sab_score: initial_sab,
total_duration: start.elapsed(),
}
}
async fn generate_hypotheses(
config: &EvolutionConfig,
telemetry_prompt: &str,
history_prompt: &str,
repo_root: &Path,
) -> Vec<Hypothesis> {
let use_micro_mode = super::micro_mode::is_micro_model(&config.llm.model);
if use_micro_mode {
log_phase("Micro mode: Using simplified prompts for small model");
generate_micro_hypotheses(config, telemetry_prompt, history_prompt, repo_root).await
} else {
generate_standard_hypotheses(config, telemetry_prompt, history_prompt, repo_root).await
}
}
async fn generate_standard_hypotheses(
config: &EvolutionConfig,
telemetry_prompt: &str,
history_prompt: &str,
repo_root: &Path,
) -> Vec<Hypothesis> {
let source_context = read_mutation_targets(&config.mutation_targets, repo_root);
if source_context.is_empty() {
log_warning("No mutation target files found or readable");
return vec![];
}
let system_prompt = build_system_prompt(config.population_size);
let user_prompt = build_user_prompt(telemetry_prompt, history_prompt, &source_context);
match call_llm(&config.llm, &system_prompt, &user_prompt).await {
Ok(response) => {
log_phase(&format!(
"LLM response ({} chars): {}",
response.len(),
truncate_char_boundary(&response, 200)
));
parse_hypotheses_response(&response)
}
Err(e) => {
log_warning(&format!("LLM call failed: {}", e));
vec![]
}
}
}
async fn generate_micro_hypotheses(
config: &EvolutionConfig,
telemetry_prompt: &str,
history_prompt: &str,
repo_root: &Path,
) -> Vec<Hypothesis> {
use super::micro_mode;
let all_paths: Vec<PathBuf> = config
.mutation_targets
.prompt_logic
.iter()
.chain(config.mutation_targets.tool_code.iter())
.chain(config.mutation_targets.cognitive.iter())
.cloned()
.collect();
let selected = micro_mode::select_micro_targets(&all_paths, repo_root);
if selected.is_empty() {
log_warning("Micro mode: No suitable target files found");
return vec![];
}
log_phase(&format!(
"Micro mode: Using {} files ({} chars)",
selected.len(),
selected.iter().map(|(_, c)| c.len()).sum::<usize>()
));
let source_context = micro_mode::build_micro_context(&selected);
let micro_population = config.population_size.min(3);
let system_prompt = micro_mode::build_micro_system_prompt(micro_population);
let user_prompt = build_user_prompt(telemetry_prompt, history_prompt, &source_context);
match call_llm(&config.llm, &system_prompt, &user_prompt).await {
Ok(response) => {
log_phase(&format!(
"Micro mode: LLM response ({} chars)",
response.len()
));
let hypotheses = parse_hypotheses_response(&response);
hypotheses
.into_iter()
.filter(|h| match micro_mode::validate_micro_hypothesis(h) {
Ok(()) => true,
Err(e) => {
log_warning(&format!("Micro mode: Rejected hypothesis: {}", e));
false
}
})
.take(micro_population)
.collect()
}
Err(e) => {
log_warning(&format!("Micro mode: LLM call failed: {}", e));
vec![]
}
}
}
const MAX_CONTEXT_CHARS: usize = 45_000;
fn extract_function_signatures(source: &str) -> Vec<String> {
let mut signatures = Vec::new();
let mut in_impl_block = false;
let mut impl_context = String::new();
for line in source.lines() {
let trimmed = line.trim();
if trimmed.starts_with("impl ") || trimmed.starts_with("pub impl ") {
in_impl_block = true;
impl_context = trimmed.to_string();
continue;
}
if trimmed == "}" && in_impl_block {
in_impl_block = false;
impl_context.clear();
continue;
}
if (trimmed.starts_with("fn ") || trimmed.starts_with("pub fn "))
&& !trimmed.starts_with("fn main()")
{
let mut sig = trimmed.to_string();
if !impl_context.is_empty() {
sig = format!("// In: {}\n{}", impl_context, sig);
}
if let Some(brace_pos) = sig.find('{') {
sig = sig[..brace_pos].to_string();
}
if !sig.is_empty() {
signatures.push(sig);
}
}
}
signatures
}
fn get_recent_git_changes(repo_root: &Path, max_commits: usize) -> Option<String> {
let output = std::process::Command::new("git")
.args(["log", "--oneline", "--no-merges"])
.arg(format!("-{}", max_commits))
.current_dir(repo_root)
.output();
match output {
Ok(o) if o.status.success() => {
let log = String::from_utf8_lossy(&o.stdout);
if log.trim().is_empty() {
None
} else {
Some(format!("Recent commits:\n{}", log))
}
}
_ => None,
}
}
fn contained_path(base: &Path, candidate: &Path) -> Option<PathBuf> {
use std::path::Component;
if candidate.is_absolute() {
return None;
}
let mut result = base.to_path_buf();
for comp in candidate.components() {
match comp {
Component::Normal(c) => result.push(c),
Component::CurDir => {}
Component::ParentDir => {
if !result.pop() || !result.starts_with(base) {
return None;
}
}
Component::RootDir | Component::Prefix(_) => return None,
}
}
result.starts_with(base).then_some(result)
}
pub fn read_mutation_targets(targets: &super::MutationTargets, repo_root: &Path) -> String {
let all_paths: Vec<&PathBuf> = targets
.prompt_logic
.iter()
.chain(targets.tool_code.iter())
.chain(targets.cognitive.iter())
.collect();
let mut file_entries: Vec<(&PathBuf, String, usize, Vec<String>)> = Vec::new();
for file in &all_paths {
let Some(full_path) = contained_path(repo_root, file) else {
log_warning(&format!(
"Refusing mutation target outside the repository: {}",
file.display()
));
continue;
};
match std::fs::read_to_string(&full_path) {
Ok(contents) => {
let len = contents.len();
let signatures = extract_function_signatures(&contents);
file_entries.push((file, contents, len, signatures));
}
Err(e) => {
log_warning(&format!("Could not read {}: {}", file.display(), e));
}
}
}
file_entries.sort_by_key(|(_, _, len, _)| *len);
let mut context = String::new();
let mut files_full = 0usize;
let mut files_truncated = 0usize;
let mut total_signatures = 0usize;
if let Some(recent_changes) = get_recent_git_changes(repo_root, 10) {
context.push_str(&format!("## Recent Git Changes\n{}\n\n", recent_changes));
}
for (file, contents, _len, signatures) in &file_entries {
let remaining = MAX_CONTEXT_CHARS.saturating_sub(context.len());
if remaining < 500 {
log_warning(&format!(
"Context limit reached ({} chars), skipping remaining files",
context.len()
));
break;
}
let numbered = add_line_numbers(contents);
let sig_overhead = signatures.len() * 50;
let overhead = 200 + file.display().to_string().len() + sig_overhead;
let budget = remaining.saturating_sub(overhead);
let (display_content, was_truncated) = if numbered.len() <= budget {
(numbered, false)
} else {
let truncated = truncate_to_line_boundary(&numbered, budget);
let total_lines = contents.lines().count();
let shown_lines = truncated.lines().count();
(
format!(
"{}\n// ... [truncated at line {}/{}, {} total chars]",
truncated,
shown_lines,
total_lines,
contents.len()
),
true,
)
};
if was_truncated {
files_truncated += 1;
} else {
files_full += 1;
}
total_signatures += signatures.len();
let sig_summary = if !signatures.is_empty() {
let sigs: String = signatures
.iter()
.take(20) .map(|s| format!(" {}", s))
.collect::<Vec<_>>()
.join("\n");
format!(
"\n// Function signatures ({} total):\n{}\n",
signatures.len(),
sigs
)
} else {
String::new()
};
context.push_str(&format!(
"\n### {}\n{}\n```rust\n{}\n```\n",
file.display(),
sig_summary,
display_content
));
}
log_phase(&format!(
"Source context: {} chars from {} files ({} full, {} truncated, {} signatures)",
context.len(),
files_full + files_truncated,
files_full,
files_truncated,
total_signatures,
));
context
}
fn add_line_numbers(source: &str) -> String {
let lines: Vec<&str> = source.lines().collect();
let width = format!("{}", lines.len()).len();
let mut out = String::with_capacity(source.len() + lines.len() * (width + 2));
for (i, line) in lines.iter().enumerate() {
out.push_str(&format!("{:>width$}| {}\n", i + 1, line, width = width));
}
out
}
fn truncate_to_line_boundary(s: &str, max_chars: usize) -> &str {
if s.len() <= max_chars {
return s;
}
let s = truncate_char_boundary(s, max_chars);
match s.rfind('\n') {
Some(pos) => &s[..pos],
None => s,
}
}
fn truncate_char_boundary(s: &str, max_bytes: usize) -> &str {
if s.len() <= max_bytes {
return s;
}
let mut end = max_bytes;
while !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
pub fn build_system_prompt(population_size: usize) -> String {
format!(
r#"You are an evolution engine that generates code mutation hypotheses for a Rust project called selfware.
Your task is to propose exactly {n} mutation hypotheses as improvements. Each hypothesis uses search-and-replace edits.
SOURCE CODE FORMAT:
- Each file is shown with line numbers like " 42| fn foo() {{"
- Line numbers are for your reference only — do NOT include them in search/replace strings
- Some files are truncated — only modify code you can see in full
- CRITICAL: The search string must match EXACT line content from the source files, NOT paraphrased or reformatted code
EDIT FORMAT (critical — edits that can't be found are discarded):
- Each hypothesis has an "edits" array of search-and-replace operations
- "search" must be an EXACT substring of the target file (copy-paste accuracy required)
- "replace" is what replaces that exact substring
- Keep edits small and focused — change the minimum necessary code
- The search string must be unique in the file (not ambiguous)
- Use \n for newlines inside strings (JSON escaped)
- Do NOT include line number prefixes (like "42| ") in search/replace strings
- CRITICAL: When constructing search strings, copy the EXACT text from the source code including:
- Exact whitespace (spaces vs tabs, indentation level)
- Exact punctuation and formatting
- Exact line breaks
- Do NOT reformat or paraphrase the code you're searching for
RULES:
1. Each hypothesis must target files from the provided source code
2. Never modify files under src/evolution/, src/safety/, system_tests/, or benches/sab_
3. Focus on: bug fixes, performance improvements, correctness, reducing allocations
4. Each hypothesis must be independent — do not assume other hypotheses are applied
5. Only modify code you can fully see — never guess at truncated content
Respond with a JSON array of exactly {n} objects:
- "description": string — what the change does and why
- "edits": array of {{"file": "relative/path.rs", "search": "exact old text", "replace": "new text"}}
- "target_files": string array — relative paths of files changed
- "property_test": string or null — optional property test
Return ONLY the JSON array. No markdown, no commentary, no thinking.
/no_think"#,
n = population_size
)
}
pub fn build_user_prompt(telemetry: &str, history: &str, source_context: &str) -> String {
let mut prompt = String::new();
if !telemetry.is_empty() {
prompt.push_str("## Current Telemetry\n\n");
prompt.push_str(telemetry);
prompt.push_str("\n\n");
}
if !history.is_empty() {
prompt.push_str(history);
prompt.push_str("\n\n");
}
prompt.push_str("## Source Code (mutation targets)\n");
prompt.push_str(source_context);
prompt
}
async fn call_llm(
llm: &LlmConfig,
system_prompt: &str,
user_prompt: &str,
) -> Result<String, String> {
crate::config::api_key::assert_credential_endpoint_safe(&llm.endpoint, llm.api_key.is_some())
.map_err(|e| e.to_string())?;
let url = format!("{}/chat/completions", llm.endpoint.trim_end_matches('/'));
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::CONTENT_TYPE,
reqwest::header::HeaderValue::from_static("application/json"),
);
if let Some(ref key) = llm.api_key {
headers.insert(
reqwest::header::AUTHORIZATION,
reqwest::header::HeaderValue::from_str(&format!("Bearer {}", key))
.map_err(|e| format!("Invalid API key header: {}", e))?,
);
}
let body = serde_json::json!({
"model": llm.model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
],
"max_tokens": llm.max_tokens,
"temperature": llm.temperature,
"chat_template_kwargs": {"enable_thinking": false},
});
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(300))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let resp = client
.post(&url)
.headers(headers)
.json(&body)
.send()
.await
.map_err(|e| format!("HTTP request failed: {}", e))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(format!("LLM API returned {}: {}", status, body));
}
let json: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Failed to parse LLM response JSON: {}", e))?;
json["choices"][0]["message"]["content"]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| "No content in LLM response".to_string())
}
pub fn parse_hypotheses_response(response: &str) -> Vec<Hypothesis> {
let json_str = match extract_json_array(response) {
Some(s) => s,
None => {
log_warning("Could not find JSON array in LLM response");
return vec![];
}
};
let parsed: Vec<serde_json::Value> = match serde_json::from_str(&json_str) {
Ok(v) => v,
Err(e) => {
log_warning(&format!("Failed to parse hypotheses JSON: {}", e));
return vec![];
}
};
parsed
.into_iter()
.enumerate()
.filter_map(|(i, v)| {
let description = v["description"].as_str()?.to_string();
let target_files: Vec<PathBuf> = v["target_files"]
.as_array()?
.iter()
.filter_map(|f| f.as_str().map(PathBuf::from))
.collect();
let property_test = v["property_test"].as_str().map(|s| s.to_string());
let patch = if let Some(edits) = v["edits"].as_array() {
serde_json::to_string(edits).ok()?
} else {
v["patch"].as_str()?.to_string()
};
if patch.is_empty() {
return None;
}
Some(Hypothesis {
id: format!("hyp-{}", i),
description,
patch,
target_files,
property_test,
})
})
.collect()
}
fn extract_json_array(text: &str) -> Option<String> {
let text = text.trim();
let stripped = if text.contains("```") {
let mut inside_fence = false;
let mut content = String::new();
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with("```") {
inside_fence = !inside_fence;
continue;
}
if inside_fence {
content.push_str(line);
content.push('\n');
}
}
if content.is_empty() {
text.to_string()
} else {
content
}
} else {
text.to_string()
};
let start = stripped.find('[')?;
let mut depth = 0;
let mut end = None;
for (i, ch) in stripped[start..].char_indices() {
match ch {
'[' => depth += 1,
']' => {
depth -= 1;
if depth == 0 {
end = Some(start + i + 1);
break;
}
}
_ => {}
}
}
end.map(|e| stripped[start..e].to_string())
}
pub fn format_evolution_history(hall_of_fame: &[GenerationWinner]) -> String {
if hall_of_fame.is_empty() {
return String::from("No evolution history yet. This is generation 1.");
}
let mut prompt = String::from("## Evolution History (most recent first)\n\n");
for winner in hall_of_fame.iter().rev().take(10) {
prompt.push_str(&format!(
"- Gen {}: {} (SAB +{:.1}, tokens {:.0})\n",
winner.generation, winner.description, winner.sab_delta, winner.token_delta
));
}
prompt
}
fn sanitize_patch(patch: &str) -> String {
let mut out = String::with_capacity(patch.len());
for line in patch.lines() {
if line.starts_with("@@")
|| line.starts_with("---")
|| line.starts_with("+++")
|| line.starts_with("diff ")
{
out.push_str(line);
out.push('\n');
continue;
}
let (prefix, rest) =
if let Some(r) = line.strip_prefix('+').or_else(|| line.strip_prefix('-')) {
(&line[..1], r)
} else if let Some(r) = line.strip_prefix(' ') {
(" ", r)
} else {
out.push_str(line);
out.push('\n');
continue;
};
let stripped = rest.trim_start();
if let Some(pipe_pos) = stripped.find('|') {
let before_pipe = &stripped[..pipe_pos];
if !before_pipe.is_empty() && before_pipe.chars().all(|c| c.is_ascii_digit()) {
let after_pipe = &stripped[pipe_pos + 1..];
let code = after_pipe.strip_prefix(' ').unwrap_or(after_pipe);
out.push_str(prefix);
out.push_str(code);
out.push('\n');
continue;
}
}
out.push_str(line);
out.push('\n');
}
out
}
fn patch_edited_paths(patch: &str) -> Vec<PathBuf> {
if let Ok(edits) = serde_json::from_str::<Vec<serde_json::Value>>(patch) {
if !edits.is_empty() && edits[0].get("search").is_some() {
return edits
.iter()
.filter_map(|e| e["file"].as_str().map(PathBuf::from))
.collect();
}
}
let mut paths = Vec::new();
for line in patch.lines() {
let (header, prefix) = if let Some(rest) = line.strip_prefix("+++ ") {
(rest, "b/")
} else if let Some(rest) = line.strip_prefix("--- ") {
(rest, "a/")
} else {
continue;
};
let p = header.trim().trim_start_matches(prefix);
if !p.is_empty() && p != "/dev/null" {
let path = PathBuf::from(p);
if !paths.contains(&path) {
paths.push(path);
}
}
}
paths
}
fn hypothesis_touches_protected(h: &Hypothesis) -> bool {
h.target_files.iter().any(|f| is_protected(f))
|| patch_edited_paths(&h.patch).iter().any(|f| is_protected(f))
}
pub fn apply_edits(dir: &Path, patch: &str) -> bool {
if let Ok(edits) = serde_json::from_str::<Vec<serde_json::Value>>(patch) {
if !edits.is_empty() && edits[0].get("search").is_some() {
return apply_search_replace(dir, &edits);
}
}
apply_unified_diff(dir, patch)
}
fn apply_search_replace(dir: &Path, edits: &[serde_json::Value]) -> bool {
let mut file_edits: std::collections::HashMap<String, Vec<(&str, &str)>> =
std::collections::HashMap::new();
for edit in edits {
let file = match edit["file"].as_str() {
Some(f) => f,
None => return false,
};
let search = match edit["search"].as_str() {
Some(s) => s,
None => return false,
};
let replace = match edit["replace"].as_str() {
Some(r) => r,
None => return false,
};
file_edits
.entry(file.to_string())
.or_default()
.push((search, replace));
}
for (file, edits) in &file_edits {
let Some(path) = contained_path(dir, Path::new(file)) else {
log_warning(&format!(
"Refusing edit to path outside the working directory: {}",
file
));
return false;
};
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return false,
};
let mut modified = content.clone();
for (search, replace) in edits {
if modified.contains(search) {
let count = modified.matches(search).count();
if count > 1 {
log_warning(&format!(
" Ambiguous search string in {} ({} matches): {:?}...",
file,
count,
truncate_char_boundary(search, 80)
));
return false;
}
modified = modified.replacen(search, replace, 1);
continue;
}
match fuzzy_find_and_replace(&modified, search, replace) {
Some(new_content) => {
modified = new_content;
continue;
}
None => {
log_warning(&format!(
" Search string not found in {}: {:?}...",
file,
truncate_char_boundary(search, 80)
));
return false;
}
}
}
if modified == content {
log_warning(&format!(" No changes made to {}", file));
return false;
}
if std::fs::write(&path, &modified).is_err() {
return false;
}
}
true
}
fn fuzzy_find_and_replace(content: &str, search: &str, replace: &str) -> Option<String> {
let search_lines: Vec<&str> = search.lines().collect();
if search_lines.is_empty() {
return None;
}
let content_lines: Vec<&str> = content.lines().collect();
let first_trimmed = search_lines[0].trim();
if first_trimmed.is_empty() {
return None;
}
for start_idx in 0..content_lines.len() {
let content_trimmed = content_lines[start_idx].trim();
if content_trimmed != first_trimmed {
continue;
}
if start_idx + search_lines.len() > content_lines.len() {
continue;
}
let mut all_match = true;
for (j, search_line) in search_lines.iter().enumerate() {
let cl = content_lines[start_idx + j].trim();
let sl = search_line.trim();
if cl != sl {
all_match = false;
break;
}
}
if !all_match {
continue;
}
let file_indent = leading_whitespace(content_lines[start_idx]);
let search_indent = leading_whitespace(search_lines[0]);
let replace_lines: Vec<&str> = replace.lines().collect();
let mut adjusted_replace = String::new();
for (k, rline) in replace_lines.iter().enumerate() {
let rline_trimmed_start = rline.trim_start();
if rline_trimmed_start.is_empty() {
adjusted_replace.push('\n');
continue;
}
let replace_indent = leading_whitespace(rline);
let new_indent = if let Some(extra) = replace_indent.strip_prefix(search_indent) {
format!("{}{}", file_indent, extra)
} else {
if k == 0 {
file_indent.to_string()
} else {
replace_indent.to_string()
}
};
adjusted_replace.push_str(&new_indent);
adjusted_replace.push_str(rline_trimmed_start);
adjusted_replace.push('\n');
}
if !search.ends_with('\n') && adjusted_replace.ends_with('\n') {
adjusted_replace.pop();
}
let mut result = String::new();
for line in &content_lines[..start_idx] {
result.push_str(line);
result.push('\n');
}
result.push_str(&adjusted_replace);
let end_idx = start_idx + search_lines.len();
if end_idx < content_lines.len() {
if !result.ends_with('\n') {
result.push('\n');
}
for (k, line) in content_lines[end_idx..].iter().enumerate() {
result.push_str(line);
if end_idx + k + 1 < content_lines.len() {
result.push('\n');
}
}
}
if content.ends_with('\n') && !result.ends_with('\n') {
result.push('\n');
}
return Some(result);
}
None
}
fn leading_whitespace(line: &str) -> &str {
let trimmed = line.trim_start();
&line[..line.len() - trimmed.len()]
}
fn apply_unified_diff(dir: &Path, patch: &str) -> bool {
let patch_file = dir.join(".evolution-patch");
if std::fs::write(&patch_file, patch).is_err() {
return false;
}
let strict = Command::new("git")
.args(["apply", ".evolution-patch"])
.current_dir(dir)
.output();
if strict.map(|o| o.status.success()).unwrap_or(false) {
let _ = std::fs::remove_file(&patch_file);
return true;
}
let relaxed = Command::new("git")
.args(["apply", "--ignore-whitespace", "-C1", ".evolution-patch"])
.current_dir(dir)
.output();
if relaxed.map(|o| o.status.success()).unwrap_or(false) {
let _ = std::fs::remove_file(&patch_file);
return true;
}
let fuzz = Command::new("patch")
.args([
"-p1",
"-F3",
"--batch",
"--silent",
"-i",
".evolution-patch",
])
.current_dir(dir)
.output();
let _ = std::fs::remove_file(&patch_file);
fuzz.map(|o| o.status.success()).unwrap_or(false)
}
fn apply_patch_to_worktree(worktree: &Path, patch: &str) -> bool {
apply_edits(worktree, patch)
}
fn apply_patch_to_repo(repo_root: &Path, patch: &str) -> bool {
let edited = patch_edited_paths(patch);
if let Some(p) = edited.iter().find(|f| is_protected(f)) {
log_error(&format!(
"Refusing to apply patch to repo: edits protected path {}",
p.display()
));
return false;
}
apply_edits(repo_root, patch)
}
struct WorktreeGuard<'a> {
repo_root: &'a Path,
path: PathBuf,
}
impl<'a> WorktreeGuard<'a> {
fn new(repo_root: &'a Path, path: PathBuf) -> Self {
Self { repo_root, path }
}
}
impl Drop for WorktreeGuard<'_> {
fn drop(&mut self) {
let _ = ast_tools::cleanup_worktree(self.repo_root, &self.path);
}
}
fn capture_tested_diff(worktree: &Path) -> Option<String> {
let add = Command::new("git")
.args(["add", "-A"])
.current_dir(worktree)
.output()
.ok()?;
if !add.status.success() {
return None;
}
let diff = Command::new("git")
.args(["diff", "--cached", "HEAD"])
.current_dir(worktree)
.output()
.ok()?;
if !diff.status.success() {
return None;
}
Some(String::from_utf8_lossy(&diff.stdout).into_owned())
}
fn commit_winner_to_repo(repo_root: &Path, tested_diff: &str, commit_msg: &str) -> bool {
if !apply_tested_diff_to_repo(repo_root, tested_diff) {
return false;
}
let edited = patch_edited_paths(tested_diff);
warn_unrelated_dirty_paths(repo_root, &edited);
if commit_scoped_paths(repo_root, &edited, commit_msg) {
return true;
}
log_error("Winner commit failed — reverting the applied diff to keep the worktree clean");
revert_applied_diff(repo_root, tested_diff);
false
}
fn apply_tested_diff_to_repo(repo_root: &Path, tested_diff: &str) -> bool {
let edited = patch_edited_paths(tested_diff);
if let Some(p) = edited.iter().find(|f| is_protected(f)) {
log_error(&format!(
"Refusing to apply tested diff to repo: edits protected path {}",
p.display()
));
return false;
}
let patch_file = repo_root.join(".evolution-tested.patch");
if std::fs::write(&patch_file, tested_diff).is_err() {
return false;
}
let applied = Command::new("git")
.args(["apply", ".evolution-tested.patch"])
.current_dir(repo_root)
.output();
let _ = std::fs::remove_file(&patch_file);
match applied {
Ok(o) if o.status.success() => true,
Ok(o) => {
log_warning(&format!(
" Tested diff does not apply cleanly to the repo (worktree drift?): {}",
String::from_utf8_lossy(&o.stderr).trim()
));
false
}
Err(e) => {
log_warning(&format!(" Failed to run git apply: {}", e));
false
}
}
}
fn revert_applied_diff(repo_root: &Path, tested_diff: &str) {
let patch_file = repo_root.join(".evolution-tested.patch");
if std::fs::write(&patch_file, tested_diff).is_err() {
return;
}
let _ = Command::new("git")
.args(["apply", "-R", ".evolution-tested.patch"])
.current_dir(repo_root)
.output();
let _ = std::fs::remove_file(&patch_file);
}
fn warn_unrelated_dirty_paths(repo_root: &Path, edited: &[PathBuf]) {
let status = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(repo_root)
.output();
let Ok(status) = status else { return };
if !status.status.success() {
return;
}
let stdout = String::from_utf8_lossy(&status.stdout);
let unrelated: Vec<&str> = stdout
.lines()
.filter_map(|line| line.get(3..))
.filter(|p| !p.is_empty())
.filter(|p| !edited.iter().any(|e| e == Path::new(p)))
.collect();
if !unrelated.is_empty() {
log_warning(&format!(
" {} unrelated dirty/untracked path(s) NOT included in the evolution commit: {}",
unrelated.len(),
unrelated
.iter()
.take(5)
.cloned()
.collect::<Vec<_>>()
.join(", ")
));
}
}
fn commit_scoped_paths(repo_root: &Path, paths: &[PathBuf], commit_msg: &str) -> bool {
if paths.is_empty() {
log_warning(" Winner patch edits no paths — nothing to commit");
return false;
}
let mut add = Command::new("git");
add.arg("add").arg("--");
for p in paths {
add.arg(p);
}
match add.current_dir(repo_root).output() {
Ok(o) if o.status.success() => {}
Ok(o) => {
log_warning(&format!(
" git add of winner paths failed: {}",
String::from_utf8_lossy(&o.stderr).trim()
));
return false;
}
Err(e) => {
log_warning(&format!(" Failed to run git add: {}", e));
return false;
}
}
let mut commit = Command::new("git");
commit.arg("commit").arg("-m").arg(commit_msg).arg("--");
for p in paths {
commit.arg(p);
}
match commit.current_dir(repo_root).output() {
Ok(o) if o.status.success() => true,
Ok(o) => {
log_warning(&format!(
" git commit of winner paths failed: {}",
String::from_utf8_lossy(&o.stderr).trim()
));
false
}
Err(e) => {
log_warning(&format!(" Failed to run git commit: {}", e));
false
}
}
}
fn log_phase(msg: &str) {
eprintln!(" 🌱 {}", msg);
}
fn log_warning(msg: &str) {
eprintln!(" 🥀 {}", msg);
}
fn log_error(msg: &str) {
eprintln!(" ❄️ {}", msg);
}
fn log_baseline(metrics: &FitnessMetrics, sab_mode: bool) {
let label = if sab_mode { "SAB" } else { "compile/test" };
eprintln!(
" 📊 Baseline: {} {:.0}/100 ({}) | {} tokens | {:.0}s",
label,
metrics.sab_score,
rating_from_score(metrics.sab_score),
metrics.tokens_used,
metrics.wall_clock_secs
);
}
fn log_generation_start(gen: usize) {
eprintln!(
"\n╭─── Generation {} ───────────────────────────────────╮",
gen
);
}
fn log_bloom(_gen: usize, description: &str, old_sab: f64, new_sab: f64) {
eprintln!(
"│ 🌸 BLOOM! SAB {:.0} → {:.0} (+{:.1})",
old_sab,
new_sab,
new_sab - old_sab
);
eprintln!("│ 📝 {}", description);
eprintln!("╰────────────────────────────────────────────────────╯");
}
fn chrono_now() -> String {
let d = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
format!("{}.{:03}", d.as_secs(), d.subsec_millis())
}
fn log_event(repo_root: &Path, event: &serde_json::Value) {
use std::io::Write;
let log_path = repo_root.join(".evolution-log.jsonl");
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(log_path)
{
let _ = writeln!(f, "{}", event);
}
}
fn log_frost(_gen: usize, reason: &str) {
eprintln!("│ ❄️ FROST: {}", reason);
eprintln!("╰────────────────────────────────────────────────────╯");
}
fn log_reject(_gen: usize, rating: &GenerationRating, winner_sab: f64, baseline_sab: f64) {
eprintln!(
"│ {} SAB {:.0} vs baseline {:.0} — rejected",
rating, winner_sab, baseline_sab
);
eprintln!("╰────────────────────────────────────────────────────╯");
}
#[cfg(test)]
#[path = "../../tests/unit/evolution/daemon/daemon_test.rs"]
mod tests;