Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

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)
}