use std::collections::BTreeMap;
use std::path::PathBuf;
use crate::provenance::sha256_hex;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileEdit {
pub path: PathBuf,
pub content: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EditSet {
pub edits: Vec<FileEdit>,
}
impl EditSet {
pub fn new() -> Self {
Self::default()
}
pub fn with(mut self, path: impl Into<PathBuf>, content: impl Into<String>) -> Self {
self.edits.push(FileEdit {
path: path.into(),
content: content.into(),
});
self
}
pub fn is_empty(&self) -> bool {
self.edits.is_empty()
}
pub fn fingerprint(&self) -> String {
let mut latest: BTreeMap<&PathBuf, &String> = BTreeMap::new();
for e in &self.edits {
latest.insert(&e.path, &e.content); }
let mut buf = String::new();
for (p, c) in latest {
buf.push_str(&p.to_string_lossy());
buf.push('\0');
buf.push_str(c);
buf.push('\u{1}');
}
sha256_hex(buf.as_bytes())
}
}
pub struct Transaction {
snapshot: BTreeMap<PathBuf, Option<Vec<u8>>>,
applied: bool,
}
impl Transaction {
pub fn apply(edits: &EditSet) -> std::io::Result<Self> {
let mut tx = Transaction {
snapshot: BTreeMap::new(),
applied: false,
};
for edit in &edits.edits {
if tx.snapshot.contains_key(&edit.path) {
} else {
let original = std::fs::read(&edit.path).ok();
tx.snapshot.insert(edit.path.clone(), original);
}
if let Some(parent) = edit.path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
if let Err(e) = std::fs::write(&edit.path, &edit.content) {
tx.rollback();
return Err(e);
}
}
tx.applied = true;
Ok(tx)
}
pub fn commit(mut self) {
self.applied = false; self.snapshot.clear();
}
pub fn rollback(&mut self) {
for (path, original) in &self.snapshot {
match original {
Some(bytes) => {
let _ = std::fs::write(path, bytes);
}
None => {
let _ = std::fs::remove_file(path);
}
}
}
self.applied = false;
}
}
impl Drop for Transaction {
fn drop(&mut self) {
if self.applied {
self.rollback();
}
}
}
#[derive(Debug, Clone, Default)]
pub struct AttemptMemory {
rejected: BTreeMap<String, Vec<String>>, }
impl AttemptMemory {
pub fn new() -> Self {
Self::default()
}
pub fn was_rejected(&self, objective: &str, edits: &EditSet) -> bool {
self.rejected
.get(objective)
.map(|v| v.contains(&edits.fingerprint()))
.unwrap_or(false)
}
pub fn record_rejected(&mut self, objective: &str, edits: &EditSet) {
let fp = edits.fingerprint();
let list = self.rejected.entry(objective.to_string()).or_default();
if !list.contains(&fp) {
list.push(fp);
}
}
#[allow(dead_code)] pub fn rejected_count(&self, objective: &str) -> usize {
self.rejected.get(objective).map(|v| v.len()).unwrap_or(0)
}
}
#[derive(Debug, Clone)]
pub struct ProposeContext<'a> {
pub objective: &'a str,
pub last_failure: &'a str,
pub attempt: usize,
pub rejected_so_far: usize,
}
pub trait Proposer {
fn propose(&mut self, ctx: &ProposeContext) -> Option<EditSet>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgenticOutcome {
pub solved: bool,
pub attempts: usize,
pub rejected: usize,
}
pub fn agentic_loop<P, V, C>(
objective: &str,
max_attempts: usize,
memory: &mut AttemptMemory,
proposer: &mut P,
verify: V,
capture: C,
) -> AgenticOutcome
where
P: Proposer,
V: FnMut() -> bool,
C: FnMut() -> String,
{
run_agentic(objective, max_attempts, memory, proposer, verify, capture)
}
pub fn agentic_loop_with_holdout<P, Vt, Vh, C>(
objective: &str,
max_attempts: usize,
memory: &mut AttemptMemory,
proposer: &mut P,
mut verify_train: Vt,
mut verify_holdout: Vh,
capture: C,
) -> AgenticOutcome
where
P: Proposer,
Vt: FnMut() -> bool,
Vh: FnMut() -> bool,
C: FnMut() -> String,
{
run_agentic(
objective,
max_attempts,
memory,
proposer,
|| verify_train() && verify_holdout(),
capture,
)
}
fn run_agentic<P, A, C>(
objective: &str,
max_attempts: usize,
memory: &mut AttemptMemory,
proposer: &mut P,
mut accept: A,
mut capture: C,
) -> AgenticOutcome
where
P: Proposer,
A: FnMut() -> bool,
C: FnMut() -> String,
{
let mut last_failure = capture();
let mut rejected = 0usize;
let max = max_attempts.max(1);
for attempt in 1..=max {
let ctx = ProposeContext {
objective,
last_failure: &last_failure,
attempt,
rejected_so_far: rejected,
};
let Some(edits) = proposer.propose(&ctx) else {
continue;
};
if edits.is_empty() || memory.was_rejected(objective, &edits) {
continue;
}
let Ok(tx) = Transaction::apply(&edits) else {
memory.record_rejected(objective, &edits);
rejected += 1;
continue;
};
if accept() {
tx.commit();
return AgenticOutcome {
solved: true,
attempts: attempt,
rejected,
};
}
drop(tx); memory.record_rejected(objective, &edits);
rejected += 1;
last_failure = capture();
}
AgenticOutcome {
solved: false,
attempts: max,
rejected,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp(tag: &str) -> PathBuf {
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("axiom_agentic_{tag}_{n}"))
}
#[test]
fn transaction_commit_keeps_changes() {
let dir = tmp("commit");
std::fs::create_dir_all(&dir).unwrap();
let a = dir.join("a.txt");
std::fs::write(&a, "old").unwrap();
let edits = EditSet::new().with(a.clone(), "new");
let tx = Transaction::apply(&edits).unwrap();
tx.commit();
assert_eq!(std::fs::read_to_string(&a).unwrap(), "new");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn transaction_rollback_restores_all_files_byte_for_byte() {
let dir = tmp("rollback");
std::fs::create_dir_all(&dir).unwrap();
let a = dir.join("a.txt");
let b = dir.join("sub/b.txt"); std::fs::write(&a, "A0").unwrap();
let edits = EditSet::new().with(a.clone(), "A1").with(b.clone(), "B1");
{
let _tx = Transaction::apply(&edits).unwrap();
assert_eq!(std::fs::read_to_string(&a).unwrap(), "A1");
assert_eq!(std::fs::read_to_string(&b).unwrap(), "B1");
}
assert_eq!(std::fs::read_to_string(&a).unwrap(), "A0", "existing file restored");
assert!(!b.exists(), "newly created file removed on rollback");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn edit_set_fingerprint_is_order_independent() {
let f1 = EditSet::new().with("a", "x").with("b", "y").fingerprint();
let f2 = EditSet::new().with("b", "y").with("a", "x").fingerprint();
assert_eq!(f1, f2, "same files+bytes hash identically regardless of order");
let f3 = EditSet::new().with("a", "x").with("b", "z").fingerprint();
assert_ne!(f1, f3, "different bytes hash differently");
}
#[test]
fn edit_set_fingerprint_collapses_duplicate_paths_last_write_wins() {
let dup = EditSet::new().with("a", "first").with("a", "final").fingerprint();
let single = EditSet::new().with("a", "final").fingerprint();
assert_eq!(dup, single, "redundant earlier write must not change identity");
}
#[test]
fn transaction_restores_non_utf8_files_byte_for_byte() {
let dir = tmp("binary");
std::fs::create_dir_all(&dir).unwrap();
let bin = dir.join("data.bin");
let original: &[u8] = &[0xff, 0x00, 0xfe, 0x80, b'h', b'i'];
std::fs::write(&bin, original).unwrap();
let edits = EditSet::new().with(bin.clone(), "text overwrite");
{
let _tx = Transaction::apply(&edits).unwrap();
assert_eq!(std::fs::read_to_string(&bin).unwrap(), "text overwrite");
}
assert_eq!(
std::fs::read(&bin).unwrap(),
original,
"a non-UTF8 original must be restored byte-for-byte, not deleted"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn attempt_memory_dedups_per_objective() {
let mut m = AttemptMemory::new();
let e = EditSet::new().with("a", "x");
assert!(!m.was_rejected("goal", &e));
m.record_rejected("goal", &e);
m.record_rejected("goal", &e); assert!(m.was_rejected("goal", &e));
assert_eq!(m.rejected_count("goal"), 1);
assert!(!m.was_rejected("other-goal", &e), "scoped by objective");
}
struct ScriptedProposer {
scripted: Vec<EditSet>,
idx: usize,
}
impl Proposer for ScriptedProposer {
fn propose(&mut self, _ctx: &ProposeContext) -> Option<EditSet> {
let e = self.scripted.get(self.idx).cloned();
self.idx += 1;
e
}
}
#[test]
fn agentic_loop_keeps_only_the_verifying_edit_set() {
let dir = tmp("loop");
std::fs::create_dir_all(&dir).unwrap();
let target = dir.join("prog.txt");
std::fs::write(&target, "BROKEN").unwrap();
let mut proposer = ScriptedProposer {
scripted: vec![
EditSet::new().with(target.clone(), "STILL BAD"),
EditSet::new().with(target.clone(), "GOOD"),
],
idx: 0,
};
let mut mem = AttemptMemory::new();
let t = target.clone();
let verify = move || std::fs::read_to_string(&t).map(|s| s == "GOOD").unwrap_or(false);
let outcome = agentic_loop("make it GOOD", 5, &mut mem, &mut proposer, verify, String::new);
assert!(outcome.solved);
assert_eq!(outcome.attempts, 2);
assert_eq!(outcome.rejected, 1, "the first wrong set was rolled back");
assert_eq!(std::fs::read_to_string(&target).unwrap(), "GOOD");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn agentic_loop_restores_source_when_nothing_verifies() {
let dir = tmp("loopfail");
std::fs::create_dir_all(&dir).unwrap();
let target = dir.join("prog.txt");
std::fs::write(&target, "ORIGINAL").unwrap();
let mut proposer = ScriptedProposer {
scripted: vec![EditSet::new().with(target.clone(), "NOPE")],
idx: 0,
};
let mut mem = AttemptMemory::new();
let outcome = agentic_loop("impossible", 3, &mut mem, &mut proposer, || false, String::new);
assert!(!outcome.solved);
assert_eq!(outcome.rejected, 1);
assert_eq!(
std::fs::read_to_string(&target).unwrap(),
"ORIGINAL",
"a never-verifying run leaves the source byte-for-byte intact"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn holdout_rejects_a_patch_that_overfits_the_train_subset() {
let dir = tmp("holdout");
std::fs::create_dir_all(&dir).unwrap();
let target = dir.join("prog.txt");
std::fs::write(&target, "BROKEN").unwrap();
let mut proposer = ScriptedProposer {
scripted: vec![
EditSet::new().with(target.clone(), "TRAIN"), EditSet::new().with(target.clone(), "TRAIN HOLD"), ],
idx: 0,
};
let mut mem = AttemptMemory::new();
let t1 = target.clone();
let verify_train =
move || std::fs::read_to_string(&t1).map(|s| s.contains("TRAIN")).unwrap_or(false);
let t2 = target.clone();
let verify_holdout =
move || std::fs::read_to_string(&t2).map(|s| s.contains("HOLD")).unwrap_or(false);
let outcome = agentic_loop_with_holdout(
"fix it",
5,
&mut mem,
&mut proposer,
verify_train,
verify_holdout,
String::new,
);
assert!(outcome.solved);
assert_eq!(outcome.attempts, 2);
assert_eq!(
outcome.rejected, 1,
"the train-only (overfit) patch must be rejected by the held-out gate"
);
assert_eq!(std::fs::read_to_string(&target).unwrap(), "TRAIN HOLD");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn holdout_leaves_source_intact_when_only_train_ever_passes() {
let dir = tmp("holdoutfail");
std::fs::create_dir_all(&dir).unwrap();
let target = dir.join("prog.txt");
std::fs::write(&target, "ORIGINAL").unwrap();
let mut proposer = ScriptedProposer {
scripted: vec![EditSet::new().with(target.clone(), "TRAIN")],
idx: 0,
};
let mut mem = AttemptMemory::new();
let outcome = agentic_loop_with_holdout(
"impossible",
3,
&mut mem,
&mut proposer,
|| true, || false, String::new,
);
assert!(!outcome.solved);
assert_eq!(outcome.rejected, 1);
assert_eq!(std::fs::read_to_string(&target).unwrap(), "ORIGINAL");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn agentic_loop_skips_already_rejected_edit_sets() {
let dir = tmp("loopskip");
std::fs::create_dir_all(&dir).unwrap();
let target = dir.join("prog.txt");
std::fs::write(&target, "X").unwrap();
let same = EditSet::new().with(target.clone(), "WRONG");
let mut proposer = ScriptedProposer {
scripted: vec![same.clone(), same.clone(), same.clone()],
idx: 0,
};
let mut mem = AttemptMemory::new();
let outcome = agentic_loop("goal", 3, &mut mem, &mut proposer, || false, String::new);
assert!(!outcome.solved);
assert_eq!(outcome.rejected, 1, "identical proposal only counted/applied once");
let _ = std::fs::remove_dir_all(&dir);
}
}