examples/funnel_decompose.rs
//! **Funnel decomposition simulation** (EPIC #45) — the offline, model-free,
//! network-free demo of the compiler-as-oracle loop.
//!
//! Run it with:
//! ```text
//! cargo run --example funnel_decompose
//! ```
//!
//! It builds a tiny in-memory code index (the RAG anchor) + a tiny *real* crate
//! on disk (the CodegenJudge target), decomposes a task into atomic subtasks,
//! and drives each subtask through the `generate → judge → retry` loop. To make
//! BOTH verdict paths visible without a model, it uses a deterministic local
//! "generator" that returns a known-good body for one subtask and a
//! non-compiling body for another — the CodegenJudge (a real `cargo build` +
//! `cargo test`) is the accept/reject oracle, exactly as the CLI's `nornir
//! funnel decompose --gen mock` would be (the mock simply always fails to
//! compile, so this example uses canned bodies to show the green path too).
//!
//! No network, no LLM weights, no warehouse — just cargo (already required to
//! run the example) deciding correctness.
use anyhow::Result;
use nornir::funnel::decompose::{
CodeIndexRetriever, Retriever, SimulationReport, Subtask, SubtaskVerdict, decompose,
run_subtask_loop,
};
use nornir::knowledge::query::KnowledgeView;
use nornir::knowledge::symbols::SymbolRow;
use nornir::warehouse::codegen_judge::{CodegenJudge, CodegenSpec};
use nornir::warehouse::generator::{Backend, GenAnswer, GenRequest, Generator};
/// A deterministic offline "generator" that returns a fixed body — stands in for
/// a real local model so the demo is reproducible AND can show a PASS. (The CLI's
/// `--gen mock` uses the shipped MockGenerator, whose echo never compiles → all
/// FAIL; this canned one shows the green path too.)
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: self.body.split_whitespace().count() as i64,
tokens_per_s: 1.0,
latency_ms: 1.0,
})
}
}
/// Build a tiny real crate on disk (the judge target) whose `lib.rs` is `body`.
fn scratch_crate(body: &str) -> Result<tempfile::TempDir> {
let dir = tempfile::tempdir()?;
let root = dir.path();
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname=\"demo\"\nversion=\"0.0.0\"\nedition=\"2021\"\n[lib]\npath=\"src/lib.rs\"\n",
)?;
std::fs::create_dir_all(root.join("src"))?;
std::fs::write(root.join("src/lib.rs"), body)?;
Ok(dir)
}
const ACCEPT_TESTS: &str = r#"
#[cfg(test)]
mod t { use super::*;
#[test] fn add_works() { assert_eq!(add(2, 2), 4); }
}
"#;
fn sym(name: &str, file: &str, line: u32, sig: &str) -> SymbolRow {
SymbolRow {
crate_name: "demo".into(),
module_path: "demo".into(),
item_kind: "fn".into(),
item_name: name.into(),
visibility: "pub".into(),
file: file.into(),
line,
doc_lines: 0,
signature: Some(sig.into()),
}
}
fn main() -> Result<()> {
println!("=== funnel decompose simulation (offline, compiler-as-oracle) ===\n");
// The RAG code index: two symbols the task touches.
let view = KnowledgeView::new(
vec![
sym("add", "src/lib.rs", 1, "pub fn add(a: i32, b: i32) -> i32"),
sym("sub", "src/sub.rs", 1, "pub fn sub(a: i32, b: i32) -> i32"),
],
vec![],
);
// Decompose the task into anchored subtasks.
let code = CodeIndexRetriever::new(&view);
let retrievers: Vec<&dyn Retriever> = vec![&code];
let task = "implement add and sub arithmetic";
let decomp = decompose(task, &retrievers, 5, 2);
println!(
"decomposed `{task}` into {} subtask(s):",
decomp.subtasks.len()
);
for s in &decomp.subtasks {
println!(
" #{}: {} (anchor {} in {})",
s.index, s.title, s.anchor_symbol, s.target_file
);
}
println!();
// Drive each subtask through the loop. Subtask 0 (the `add` anchor) gets a
// generator that returns a CORRECT body → ACCEPTED; subtask 1 gets one that
// returns a NON-COMPILING body → REJECTED after the bounded retries.
let mut verdicts: Vec<SubtaskVerdict> = Vec::new();
for sub in &decomp.subtasks {
let v = run_one(sub, task)?;
let mark = if v.accepted { "PASS" } else { "FAIL" };
println!(
" subtask #{} {}: score {:.2} — {} ({} round(s))",
v.subtask_index,
mark,
v.best.score,
v.best.message,
v.rounds.len()
);
verdicts.push(v);
}
// Assemble + print the full report (the same shape the CLI / viz consume).
let accepted = verdicts.iter().filter(|v| v.accepted).count();
let total = decomp.subtasks.len();
let report = SimulationReport {
decomposition: decomp,
verdicts,
accepted,
total,
};
println!("\n--- subtask DAG + verdicts ---");
print!("{}", report.render());
println!("\nstate_json:");
println!("{}", serde_json::to_string_pretty(&report.to_json())?);
Ok(())
}
/// Run one subtask with a body chosen by its anchor: the `add` subtask gets a
/// correct body, anything else gets a non-compiling one — so the demo shows both
/// the accept and reject paths of the SAME real oracle.
fn run_one(sub: &Subtask, task: &str) -> Result<SubtaskVerdict> {
let good = format!("pub fn add(a:i32,b:i32)->i32{{a+b}}\n{ACCEPT_TESTS}");
let bad = format!("pub fn add(_a:i32,_b:i32)->i32{{\"nope\"}}\n{ACCEPT_TESTS}");
let (body, id) = if sub.anchor_symbol == "add" {
(good, "canned-good")
} else {
(bad, "canned-bad")
};
// The judge always gates on the same acceptance test (`add(2,2)==4`); the
// crate starts wrong so there is something real to fix.
let scratch = scratch_crate("pub fn add(_a:i32,_b:i32)->i32{0}\n")?;
let judge = CodegenJudge::new(CodegenSpec::write_file(
scratch.path().to_path_buf(),
"src/lib.rs",
));
let r#gen = CannedGenerator {
id: id.into(),
body,
};
run_subtask_loop(task, sub, &r#gen, &judge, 2)
}