use anyhow::{anyhow, Result};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use crate::knowledge::query::KnowledgeView;
use crate::knowledge::symbols::SymbolRow;
use crate::warehouse::codegen_judge::{AnswerJudge, Verdict};
use crate::warehouse::generator::{GenRequest, Generator};
use super::event::{Event, ItemKind, NodeStatus, PlanStatus};
use super::ids::{IdeaId, NodeId, PlanId};
use super::store::Store;
pub const MAX_SUBTASKS: usize = 20;
pub const DEFAULT_MAX_ROUNDS: usize = 3;
pub const DEFAULT_TOP_K: usize = 5;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Anchor {
pub symbol: String,
pub file: String,
pub line: u32,
pub kind: String,
pub excerpt: Option<String>,
pub score: f32,
pub source: String,
}
impl Anchor {
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"symbol": self.symbol,
"file": self.file,
"line": self.line,
"kind": self.kind,
"excerpt": self.excerpt,
"score": self.score,
"source": self.source,
})
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RetrievedContext {
pub query: String,
pub anchors: Vec<Anchor>,
pub retrievers: Vec<String>,
}
impl RetrievedContext {
pub fn as_prompt_block(&self) -> String {
if self.anchors.is_empty() {
return "(no anchors retrieved)".to_string();
}
let mut s = String::new();
for a in &self.anchors {
s.push_str(&format!("- {} ({}:{}) [{}]", a.symbol, a.file, a.line, a.kind));
if let Some(ex) = &a.excerpt {
s.push_str(&format!(" — {}", ex.trim()));
}
s.push('\n');
}
s
}
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"query": self.query,
"anchors": self.anchors.iter().map(Anchor::to_json).collect::<Vec<_>>(),
"retrievers": self.retrievers,
})
}
}
pub trait Retriever {
fn id(&self) -> &str;
fn retrieve(&self, query: &str, k: usize) -> Vec<Anchor>;
}
pub struct CodeIndexRetriever<'a> {
view: &'a KnowledgeView,
}
impl<'a> CodeIndexRetriever<'a> {
pub fn new(view: &'a KnowledgeView) -> Self {
Self { view }
}
}
fn tokens(s: &str) -> Vec<String> {
s.split(|c: char| !c.is_alphanumeric())
.filter(|t| t.len() >= 2)
.map(|t| t.to_ascii_lowercase())
.collect()
}
fn lexical_score(query_tokens: &[String], sym: &SymbolRow) -> f32 {
let name_tokens = tokens(&sym.item_name);
let file_tokens = tokens(&sym.file);
let sig_tokens = sym.signature.as_deref().map(tokens).unwrap_or_default();
let mut score = 0.0f32;
for qt in query_tokens {
if name_tokens.iter().any(|t| t == qt || t.contains(qt.as_str())) {
score += 3.0;
} else if sig_tokens.iter().any(|t| t == qt) {
score += 1.5;
} else if file_tokens.iter().any(|t| t == qt || t.contains(qt.as_str())) {
score += 1.0;
}
}
score
}
impl Retriever for CodeIndexRetriever<'_> {
fn id(&self) -> &str {
"code_index"
}
fn retrieve(&self, query: &str, k: usize) -> Vec<Anchor> {
let qt = tokens(query);
let mut scored: Vec<(f32, &SymbolRow)> = self
.view
.symbols
.iter()
.map(|s| (lexical_score(&qt, s), s))
.filter(|(score, _)| *score > 0.0)
.collect();
scored.sort_by(|a, b| {
b.0.partial_cmp(&a.0)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.1.file.cmp(&b.1.file))
.then_with(|| a.1.line.cmp(&b.1.line))
});
scored
.into_iter()
.take(k)
.map(|(score, s)| Anchor {
symbol: s.item_name.clone(),
file: s.file.clone(),
line: s.line,
kind: s.item_kind.clone(),
excerpt: s.signature.clone(),
score,
source: "code_index".to_string(),
})
.collect()
}
}
pub fn retrieve(retrievers: &[&dyn Retriever], query: &str, k: usize) -> RetrievedContext {
let mut all: Vec<Anchor> = Vec::new();
let mut contributed: Vec<String> = Vec::new();
for r in retrievers {
let hits = r.retrieve(query, k);
if !hits.is_empty() && !contributed.iter().any(|c| c == r.id()) {
contributed.push(r.id().to_string());
}
all.extend(hits);
}
all.sort_by(|a, b| {
a.file
.cmp(&b.file)
.then_with(|| a.symbol.cmp(&b.symbol))
.then_with(|| {
b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal)
})
});
all.dedup_by(|a, b| a.file == b.file && a.symbol == b.symbol);
all.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.file.cmp(&b.file))
.then_with(|| a.line.cmp(&b.line))
});
all.truncate(k);
RetrievedContext { query: query.to_string(), anchors: all, retrievers: contributed }
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Subtask {
pub index: usize,
pub title: String,
pub target_file: String,
pub anchor_symbol: String,
pub acceptance: String,
pub context: RetrievedContext,
}
impl Subtask {
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"index": self.index,
"title": self.title,
"target_file": self.target_file,
"anchor_symbol": self.anchor_symbol,
"acceptance": self.acceptance,
"context": self.context.to_json(),
})
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Decomposition {
pub task: String,
pub subtasks: Vec<Subtask>,
}
impl Decomposition {
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"task": self.task,
"subtask_count": self.subtasks.len(),
"subtasks": self.subtasks.iter().map(Subtask::to_json).collect::<Vec<_>>(),
})
}
}
pub fn decompose(
task: &str,
retrievers: &[&dyn Retriever],
top_k: usize,
max: usize,
) -> Decomposition {
let max = max.min(MAX_SUBTASKS).max(1);
let ctx = retrieve(retrievers, task, top_k.max(max));
let mut subtasks = Vec::new();
for (i, anchor) in ctx.anchors.iter().take(max).enumerate() {
let sub_ctx = RetrievedContext {
query: format!("{task} :: {}", anchor.symbol),
anchors: vec![anchor.clone()],
retrievers: ctx.retrievers.clone(),
};
subtasks.push(Subtask {
index: i,
title: format!("implement `{}` in {}", anchor.symbol, anchor.file),
target_file: anchor.file.clone(),
anchor_symbol: anchor.symbol.clone(),
acceptance: format!(
"cargo build + acceptance tests green after editing {} ({})",
anchor.file, anchor.symbol
),
context: sub_ctx,
});
}
if subtasks.is_empty() {
subtasks.push(Subtask {
index: 0,
title: format!("implement task: {}", first_line(task)),
target_file: String::new(),
anchor_symbol: String::new(),
acceptance: "cargo build + acceptance tests green".to_string(),
context: ctx.clone(),
});
}
Decomposition { task: task.to_string(), subtasks }
}
fn first_line(s: &str) -> String {
s.lines().next().unwrap_or("").trim().to_string()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Round {
pub round: usize,
pub prompt_excerpt: String,
pub verdict: Verdict,
}
impl Round {
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"round": self.round,
"prompt_excerpt": self.prompt_excerpt,
"verdict": self.verdict,
})
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SubtaskVerdict {
pub subtask_index: usize,
pub accepted: bool,
pub best: Verdict,
pub rounds: Vec<Round>,
}
impl SubtaskVerdict {
pub fn node_status(&self) -> NodeStatus {
if self.accepted {
NodeStatus::Done
} else {
NodeStatus::Failed
}
}
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"subtask_index": self.subtask_index,
"accepted": self.accepted,
"best": self.best,
"rounds": self.rounds.iter().map(Round::to_json).collect::<Vec<_>>(),
})
}
}
pub fn build_codegen_prompt(
parent_task: &str,
sub: &Subtask,
prev_error: Option<&str>,
) -> String {
let mut p = String::new();
p.push_str("You are writing Rust code for one atomic subtask. Reply with ONLY ");
p.push_str("the new file body (no prose, no fences).\n\n");
p.push_str(&format!("Parent task: {parent_task}\n"));
p.push_str(&format!("Subtask: {}\n", sub.title));
p.push_str(&format!("Target file: {}\n", sub.target_file));
p.push_str(&format!("Acceptance: {}\n\n", sub.acceptance));
p.push_str("Relevant code (RAG anchors):\n");
p.push_str(&sub.context.as_prompt_block());
if let Some(err) = prev_error {
p.push_str("\nThe previous attempt FAILED the compiler/tests:\n");
p.push_str(err.trim());
p.push_str("\nFix it.\n");
}
p
}
pub fn run_subtask_loop(
parent_task: &str,
sub: &Subtask,
generator: &dyn Generator,
judge: &dyn AnswerJudge,
max_rounds: usize,
) -> Result<SubtaskVerdict> {
let max_rounds = max_rounds.max(1);
let mut rounds = Vec::new();
let mut prev_error: Option<String> = None;
let mut best: Option<Verdict> = None;
for round in 1..=max_rounds {
let prompt = build_codegen_prompt(parent_task, sub, prev_error.as_deref());
let req = GenRequest::new(prompt.clone()).with_max_tokens(1024);
let answer = generator
.complete(&req)
.map_err(|e| anyhow!("generator failed on subtask {}: {e}", sub.index))?;
let verdict = judge.judge(&answer.text)?;
rounds.push(Round {
round,
prompt_excerpt: first_line(&prompt),
verdict: verdict.clone(),
});
let improved = best
.as_ref()
.map(|b| verdict.score > b.score)
.unwrap_or(true);
if improved {
best = Some(verdict.clone());
}
if verdict.accepted() {
return Ok(SubtaskVerdict {
subtask_index: sub.index,
accepted: true,
best: verdict,
rounds,
});
}
prev_error = Some(verdict.message.clone());
}
Ok(SubtaskVerdict {
subtask_index: sub.index,
accepted: false,
best: best.unwrap_or_else(|| Verdict::rejected("no rounds run")),
rounds,
})
}
pub fn store_subtasks(
store: &mut Store,
idea_id: &IdeaId,
decomp: &Decomposition,
) -> Result<(PlanId, Vec<NodeId>)> {
let kind = store
.funnel
.ideas
.get(idea_id)
.map(|i| i.item_kind)
.unwrap_or(ItemKind::Idea);
let node_kind = kind.node_kind().to_string();
let base = Utc::now();
let mut tick: i64 = 0;
let mut at = || {
let t = base + chrono::Duration::microseconds(tick);
tick += 1;
t
};
let plan_id = PlanId::seq(store.funnel.next_plan.max(1));
store.record(Event::PlanCreated {
id: plan_id.clone(),
idea_id: idea_id.clone(),
summary: format!(
"decompose ({}) → {} atomic subtask(s)",
kind.as_str(),
decomp.subtasks.len()
),
planner: "decompose".into(),
ts: at(),
})?;
store.record(Event::PlanStatusChanged {
plan_id: plan_id.clone(),
status: PlanStatus::Active,
why: Some("auto-planned from task decomposition".into()),
ts: at(),
})?;
let mut node_ids = Vec::with_capacity(decomp.subtasks.len());
let mut prev: Option<NodeId> = None;
for sub in &decomp.subtasks {
let node_id = NodeId::seq(store.funnel.next_node.max(1));
let mut params = serde_json::Map::new();
params.insert("title".into(), serde_json::Value::String(sub.title.clone()));
params.insert("subtask_index".into(), serde_json::Value::from(sub.index));
params.insert("acceptance".into(), serde_json::Value::String(sub.acceptance.clone()));
params.insert("anchor".into(), serde_json::Value::String(sub.anchor_symbol.clone()));
params.insert("state_json".into(), sub.to_json());
let targets = if sub.target_file.is_empty() {
vec![]
} else {
vec![sub.target_file.clone()]
};
store.record(Event::NodeAdded {
plan_id: plan_id.clone(),
node_id: node_id.clone(),
kind: node_kind.clone(),
params,
targets,
prompt_excerpt: Some(sub.title.clone()),
ts: at(),
})?;
if let Some(from) = prev {
store.record(Event::EdgeAdded {
plan_id: plan_id.clone(),
from_node: from,
to_node: node_id.clone(),
ts: at(),
})?;
}
prev = Some(node_id.clone());
node_ids.push(node_id);
}
store.funnel.promote_ready();
Ok((plan_id, node_ids))
}
pub fn apply_verdicts(
store: &mut Store,
plan_id: &PlanId,
node_ids: &[NodeId],
verdicts: &[SubtaskVerdict],
) -> Result<usize> {
let base = Utc::now();
let mut tick = 0i64;
let mut at = || {
let t = base + chrono::Duration::microseconds(tick);
tick += 1;
t
};
let mut n = 0;
for v in verdicts {
let node_id = node_ids
.get(v.subtask_index)
.ok_or_else(|| anyhow!("verdict for subtask {} has no node", v.subtask_index))?;
store.record(Event::NodeStatusChanged {
plan_id: plan_id.clone(),
node_id: node_id.clone(),
status: v.node_status(),
why: Some(format!(
"judge: {} (score {:.2}, {}/{} tests)",
v.best.message, v.best.score, v.best.tests_passed, v.best.tests_total
)),
ts: at(),
})?;
n += 1;
}
Ok(n)
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SimulationReport {
pub decomposition: Decomposition,
pub verdicts: Vec<SubtaskVerdict>,
pub accepted: usize,
pub total: usize,
}
impl SimulationReport {
pub fn all_accepted(&self) -> bool {
self.total > 0 && self.accepted == self.total
}
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"decomposition": self.decomposition.to_json(),
"verdicts": self.verdicts.iter().map(SubtaskVerdict::to_json).collect::<Vec<_>>(),
"accepted": self.accepted,
"total": self.total,
})
}
pub fn render(&self) -> String {
let mut s = String::new();
s.push_str(&format!(
"task: {}\nsubtasks: {} ({} accepted by the compiler-oracle)\n",
first_line(&self.decomposition.task),
self.total,
self.accepted
));
for (sub, v) in self.decomposition.subtasks.iter().zip(&self.verdicts) {
let mark = if v.accepted { "PASS" } else { "FAIL" };
let dep = if sub.index == 0 {
"(root)".to_string()
} else {
format!("(needs #{})", sub.index - 1)
};
s.push_str(&format!(
" #{} {} {} {}\n target: {} | anchor: {}\n verdict: score {:.2} — {} (rounds: {})\n",
sub.index,
mark,
sub.title,
dep,
if sub.target_file.is_empty() { "-" } else { &sub.target_file },
if sub.anchor_symbol.is_empty() { "-" } else { &sub.anchor_symbol },
v.best.score,
v.best.message,
v.rounds.len(),
));
}
s
}
}
pub fn simulate(
task: &str,
view: &KnowledgeView,
generator: &dyn Generator,
judge: &dyn AnswerJudge,
top_k: usize,
max_subtasks: usize,
max_rounds: usize,
) -> Result<SimulationReport> {
let code = CodeIndexRetriever::new(view);
let retrievers: Vec<&dyn Retriever> = vec![&code];
let decomp = decompose(task, &retrievers, top_k, max_subtasks);
let mut verdicts = Vec::with_capacity(decomp.subtasks.len());
let mut accepted = 0;
for sub in &decomp.subtasks {
let v = run_subtask_loop(task, sub, generator, judge, max_rounds)?;
if v.accepted {
accepted += 1;
}
verdicts.push(v);
}
let total = decomp.subtasks.len();
Ok(SimulationReport { decomposition: decomp, verdicts, accepted, total })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::knowledge::symbols::{CallEdgeRow, SymbolRow};
use crate::warehouse::codegen_judge::{CodegenJudge, CodegenSpec};
use crate::warehouse::generator::{Backend, GenAnswer, MockGenerator};
fn sym(name: &str, kind: &str, file: &str, line: u32, sig: &str) -> SymbolRow {
SymbolRow {
crate_name: "fixture".into(),
module_path: "fixture".into(),
item_kind: kind.into(),
item_name: name.into(),
visibility: "pub".into(),
file: file.into(),
line,
doc_lines: 0,
signature: Some(sig.into()),
}
}
fn view() -> KnowledgeView {
KnowledgeView {
symbols: vec![
sym("add", "fn", "src/math.rs", 3, "fn add(a: i32, b: i32) -> i32"),
sym("retry_fetch", "fn", "src/http.rs", 10, "fn retry_fetch(url: &str) -> Result<String>"),
sym("Config", "struct", "src/config.rs", 1, "struct Config { retries: u32 }"),
],
calls: vec![CallEdgeRow {
crate_name: "fixture".into(),
caller_path: "retry_fetch".into(),
callee_ident: "add".into(),
call_kind: "call".into(),
file: "src/http.rs".into(),
line: 12,
}],
}
}
#[test]
fn code_index_retrieval_ranks_relevant_symbol_first() {
let v = view();
let r = CodeIndexRetriever::new(&v);
let hits = r.retrieve("retry the http fetch on failure", 3);
assert!(!hits.is_empty(), "must retrieve at least one anchor");
assert_eq!(hits[0].symbol, "retry_fetch", "most relevant symbol first");
assert_eq!(hits[0].file, "src/http.rs");
assert_eq!(hits[0].source, "code_index");
assert!(hits[0].score > 0.0, "real positive relevance score");
}
#[test]
fn code_index_retrieval_discriminates_by_query() {
let v = view();
let r = CodeIndexRetriever::new(&v);
let hits = r.retrieve("add two integers", 1);
assert_eq!(hits[0].symbol, "add");
assert_eq!(hits[0].file, "src/math.rs");
}
#[test]
fn retrieve_merges_dedups_and_ranks() {
let v = view();
let r = CodeIndexRetriever::new(&v);
let ctx = retrieve(&[&r, &r], "config retries struct", 5);
let keys: Vec<(String, String)> =
ctx.anchors.iter().map(|a| (a.file.clone(), a.symbol.clone())).collect();
let mut uniq = keys.clone();
uniq.sort();
uniq.dedup();
assert_eq!(keys.len(), uniq.len(), "anchors deduped by (file, symbol)");
assert_eq!(ctx.anchors[0].symbol, "Config", "config query anchors on Config");
assert_eq!(ctx.retrievers, vec!["code_index"], "only code index contributed");
}
#[test]
fn decompose_yields_anchored_subtasks_capped() {
let v = view();
let r = CodeIndexRetriever::new(&v);
let retrievers: Vec<&dyn Retriever> = vec![&r];
let d = decompose("rework add and retry_fetch and Config", &retrievers, 5, 2);
assert_eq!(d.subtasks.len(), 2, "capped at max=2");
let known: std::collections::HashSet<&str> =
["src/math.rs", "src/http.rs", "src/config.rs"].into_iter().collect();
for (i, s) in d.subtasks.iter().enumerate() {
assert_eq!(s.index, i, "subtasks indexed in order");
assert!(known.contains(s.target_file.as_str()), "anchored to real file: {}", s.target_file);
assert!(!s.anchor_symbol.is_empty(), "has a concrete anchor symbol");
assert!(s.acceptance.contains("cargo"), "has an acceptance check");
assert_eq!(s.context.anchors.len(), 1, "per-subtask single anchor");
}
}
#[test]
fn decompose_respects_max_subtasks_cap() {
let mut symbols = Vec::new();
for i in 0..25 {
symbols.push(sym(
&format!("widget_{i}"),
"fn",
&format!("src/w{i}.rs"),
1,
"fn widget()",
));
}
let v = KnowledgeView { symbols, calls: vec![] };
let r = CodeIndexRetriever::new(&v);
let retrievers: Vec<&dyn Retriever> = vec![&r];
let d = decompose("touch every widget", &retrievers, 100, 100);
assert!(d.subtasks.len() <= MAX_SUBTASKS, "never exceeds the hard cap");
}
#[test]
fn decompose_empty_index_yields_fallback_subtask() {
let v = KnowledgeView { symbols: vec![], calls: vec![] };
let r = CodeIndexRetriever::new(&v);
let retrievers: Vec<&dyn Retriever> = vec![&r];
let d = decompose("do the thing", &retrievers, 5, 5);
assert_eq!(d.subtasks.len(), 1, "fallback single subtask");
assert!(d.subtasks[0].title.contains("do the thing"));
}
fn scratch_crate() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname=\"fix\"\nversion=\"0.0.0\"\nedition=\"2021\"\n[lib]\npath=\"src/lib.rs\"\n",
)
.unwrap();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/lib.rs"), "pub fn add(_a:i32,_b:i32)->i32{0}\n").unwrap();
dir
}
const ACCEPT_TESTS: &str = r#"
#[cfg(test)]
mod t { use super::*;
#[test] fn ok() { assert_eq!(add(2,2), 4); }
}
"#;
struct CannedGenerator {
id: String,
body: String,
}
impl Backend for CannedGenerator {
fn id(&self) -> &str {
&self.id
}
fn available(&self) -> bool {
true
}
}
impl Generator for CannedGenerator {
fn complete(&self, _req: &GenRequest) -> Result<GenAnswer> {
Ok(GenAnswer {
text: self.body.clone(),
tokens_in: 1,
tokens_out: 1,
tokens_per_s: 1.0,
latency_ms: 1.0,
})
}
}
fn subtask_for(file: &str) -> Subtask {
Subtask {
index: 0,
title: format!("implement add in {file}"),
target_file: file.into(),
anchor_symbol: "add".into(),
acceptance: "cargo build + tests".into(),
context: RetrievedContext {
query: "add".into(),
anchors: vec![],
retrievers: vec!["code_index".into()],
},
}
}
#[test]
fn loop_accepts_known_good_patch() {
let scratch = scratch_crate();
let judge = CodegenJudge::new(CodegenSpec::write_file(
scratch.path().to_path_buf(),
"src/lib.rs",
));
let good = format!("pub fn add(a:i32,b:i32)->i32{{a+b}}\n{ACCEPT_TESTS}");
let gen = CannedGenerator { id: "good".into(), body: good };
let sub = subtask_for("src/lib.rs");
let v = run_subtask_loop("ship add", &sub, &gen, &judge, 3).unwrap();
assert!(v.accepted, "good patch accepted: {}", v.best.message);
assert_eq!(v.best.score, 1.0);
assert_eq!(v.node_status(), NodeStatus::Done);
assert_eq!(v.rounds.len(), 1, "accepted on the first round, no retries");
}
#[test]
fn loop_rejects_noncompiling_patch_and_retries() {
let scratch = scratch_crate();
let judge = CodegenJudge::new(CodegenSpec::write_file(
scratch.path().to_path_buf(),
"src/lib.rs",
));
let bad = format!("pub fn add(_a:i32,_b:i32)->i32{{\"nope\"}}\n{ACCEPT_TESTS}");
let gen = CannedGenerator { id: "bad".into(), body: bad };
let sub = subtask_for("src/lib.rs");
let v = run_subtask_loop("ship add", &sub, &gen, &judge, 3).unwrap();
assert!(!v.accepted, "non-compiling patch rejected");
assert_eq!(v.best.score, 0.0);
assert!(!v.best.compiled);
assert_eq!(v.node_status(), NodeStatus::Failed);
assert_eq!(v.rounds.len(), 3, "exhausted all bounded rounds on failure");
}
#[test]
fn retry_prompt_carries_prev_error() {
let sub = subtask_for("src/lib.rs");
let first = build_codegen_prompt("t", &sub, None);
assert!(!first.contains("FAILED"), "first round has no error");
let retry = build_codegen_prompt("t", &sub, Some("error[E0308]: mismatched types"));
assert!(retry.contains("E0308"), "retry re-feeds the compiler error");
assert!(retry.contains("Fix it"));
}
#[test]
fn store_subtasks_builds_child_dag_and_applies_verdicts() {
let dir = tempfile::tempdir().unwrap();
let mut store = Store::open(dir.path().join("wh")).unwrap();
let idea = IdeaId::seq(store.funnel.next_idea.max(1));
store
.record(Event::IdeaSubmitted {
id: idea.clone(),
source: "test".into(),
text: "rework add and retry_fetch".into(),
refs: vec![],
item_kind: ItemKind::Idea,
ts: Utc::now(),
})
.unwrap();
let v = view();
let r = CodeIndexRetriever::new(&v);
let retrievers: Vec<&dyn Retriever> = vec![&r];
let decomp = decompose("rework add and retry_fetch", &retrievers, 5, 2);
assert_eq!(decomp.subtasks.len(), 2);
let (plan_id, node_ids) = store_subtasks(&mut store, &idea, &decomp).unwrap();
let plan = store.funnel.plans.get(&plan_id).expect("child plan created");
assert_eq!(plan.idea_id, idea, "child plan hangs under the parent idea");
assert_eq!(plan.status, PlanStatus::Active);
assert_eq!(plan.nodes.len(), 2, "one node per subtask");
assert_eq!(plan.edges.len(), 1, "chained: 2 nodes → 1 edge");
for n in plan.nodes.values() {
assert_eq!(n.kind, "code:write", "idea → code:write child node");
assert_eq!(n.targets.len(), 1, "node anchored to its file");
assert!(n.params.contains_key("state_json"), "node emits state_json");
}
store.funnel.promote_ready();
let plan = store.funnel.plans.get(&plan_id).unwrap();
let ready: Vec<NodeId> = plan
.nodes
.values()
.filter(|n| n.status == NodeStatus::Ready)
.map(|n| n.id.clone())
.collect();
assert_eq!(ready, vec![node_ids[0].clone()], "only the root subtask is Ready");
let verdicts = vec![
SubtaskVerdict {
subtask_index: 0,
accepted: true,
best: Verdict {
compiled: true,
tests_passed: 1,
tests_total: 1,
score: 1.0,
message: "all pass".into(),
},
rounds: vec![],
},
SubtaskVerdict {
subtask_index: 1,
accepted: false,
best: Verdict::rejected("did not compile"),
rounds: vec![],
},
];
let n = apply_verdicts(&mut store, &plan_id, &node_ids, &verdicts).unwrap();
assert_eq!(n, 2, "both verdicts written");
let plan = store.funnel.plans.get(&plan_id).unwrap();
assert_eq!(plan.nodes.get(&node_ids[0]).unwrap().status, NodeStatus::Done);
assert_eq!(plan.nodes.get(&node_ids[1]).unwrap().status, NodeStatus::Failed);
}
#[test]
fn simulate_runs_end_to_end_offline() {
let scratch = scratch_crate();
let v = KnowledgeView {
symbols: vec![sym("add", "fn", "src/lib.rs", 1, "fn add(a:i32,b:i32)->i32")],
calls: vec![],
};
let judge = CodegenJudge::new(CodegenSpec::write_file(
scratch.path().to_path_buf(),
"src/lib.rs",
));
let good = format!("pub fn add(a:i32,b:i32)->i32{{a+b}}\n{ACCEPT_TESTS}");
let gen = CannedGenerator { id: "good".into(), body: good };
let report = simulate("make add correct", &v, &gen, &judge, 5, 5, 2).unwrap();
assert_eq!(report.total, 1, "one subtask from the one-symbol index");
assert_eq!(report.accepted, 1, "the compiler-oracle accepted it");
assert!(report.all_accepted());
assert_eq!(report.verdicts[0].best.score, 1.0);
let out = report.render();
assert!(out.contains("PASS"), "render shows the verdict: {out}");
assert!(out.contains("add"), "render names the anchored symbol");
let j = report.to_json();
assert_eq!(j["accepted"], 1);
assert_eq!(j["total"], 1);
}
#[test]
fn simulate_rejects_when_mock_output_does_not_compile() {
let scratch = scratch_crate();
let v = KnowledgeView {
symbols: vec![sym("add", "fn", "src/lib.rs", 1, "fn add()")],
calls: vec![],
};
let judge = CodegenJudge::new(CodegenSpec::write_file(
scratch.path().to_path_buf(),
"src/lib.rs",
));
let gen = MockGenerator::new("mock");
let report = simulate("make add correct", &v, &gen, &judge, 5, 5, 2).unwrap();
assert_eq!(report.total, 1);
assert_eq!(report.accepted, 0, "mock echo is not valid Rust → rejected");
assert!(!report.all_accepted());
assert_eq!(report.verdicts[0].rounds.len(), 2);
}
}