agent-joule/crates/joule_coded/tests/headless_run.rs
David Charlot 6362476bc9 joule_coded: Measure the tokens the metered path was reporting as zero
Two defects in the run identity and the headline metric, both found by
running the new agent adapter and reading its output rather than trusting
it.

`RunIdentity::detect` finds the model through the environment, which is
how the daemon is configured. The `agent` subcommand takes `--model`
directly, so every run started that way reported `no model` while holding
a loaded one. An identity that is wrong exactly when a model was used is
worse than no identity: it would have labelled every benchmark row as
modelless. It now stamps the model it actually loaded.

The metered loop passed `None` for a generation step's token count, so
`summary.tokens` was 0, `microjoules_per_token` was `None`, and the JSON
reported `output_tokens: 0` — which reads as "generated nothing". Every
run down this path silently disabled joules-per-token, the metric this
project exists to report.

The counts were never missing, only discarded. `l3_generate_counted`
already returns them, and its own doc says it exists so that
µJ/accepted-token is "measured rather than left at zero or invented" —
the metered path just called `l3_generate` instead. It now takes a
generator returning `(text, tokens_out)` and records the count on the
step.

First measured figures from the agent path, on a 2.6B local model:
192 output tokens, 6.49 kJ, 111 s — 33.8 J/token at 1.73 tokens/sec. The
joules figure is now real. The token rate is roughly twenty times slower
than this model should run on this hardware, which is a separate thread
to pull.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-07 06:33:46 -04:00

133 lines
5.7 KiB
Rust

//! The headless keystone, deterministically: drive the worker's real `run_loop`
//! (which writes edits to disk through the same `FsDocument` path the daemon uses)
//! and `record_trace` (which seals each edit into a signed session ledger), with a
//! scripted on-device model standing in for JouleClaw's GGUF runtime. Proves a
//! real coding op closes headlessly and produces an intact, signed receipt — no
//! GUI, no window, no model download.
use ed25519_dalek::SigningKey;
use joule_coded::{record_trace, run_loop, run_loop_metered, seed_from};
use joule_core::journal::EnergyReader;
use joule_core::sessions::{SessionGrant, SessionManager};
use std::cell::Cell;
use std::rc::Rc;
#[test]
fn headless_run_edits_a_file_and_seals_a_signed_op() {
// A scratch checkout with one file the agent will rewrite. The primary session
// makes it accountable without needing a git worktree.
let root = std::env::temp_dir().join(format!("jc-headless-{}", std::process::id()));
let src = root.join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("lib.rs"), "fn answer() -> i32 { 0 }\n").unwrap();
let key = SigningKey::from_bytes(&seed_from(&root.to_string_lossy()));
let author = key.verifying_key().to_bytes();
let grant = SessionGrant {
tiers: vec!["l0".into(), "l1".into(), "l2".into(), "l3".into()],
joule_ceiling_uj: 1_000_000_000,
};
let mut sessions = SessionManager::with_primary(
root.clone(),
key,
root.join(".joule").join("worktrees"),
grant,
"test-worker",
);
// The scripted "model": edit the file, then finish. Exactly the wire the
// on-device runtime would emit, so this exercises the real loop + tool parsing.
let turns = [
"<<<TOOL edit_file>>>\nsrc/lib.rs\nfn answer() -> i32 { 42 }\n<<<END>>>",
"<<<TOOL finish>>>\n",
];
let mut step = 0usize;
let trace = run_loop(&root, "make answer return 42", 8, |_prompt| {
let turn = turns[step.min(turns.len() - 1)];
step += 1;
Ok(turn.to_string())
});
// The loop applied exactly one edit, and it is already on disk (the daemon's
// apply_edit wrote it through FsDocument).
assert_eq!(trace.applied.len(), 1, "expected one applied edit");
assert_eq!(trace.applied[0].0, "src/lib.rs");
let on_disk = std::fs::read_to_string(src.join("lib.rs")).unwrap();
assert!(on_disk.contains("{ 42 }"), "edit not on disk: {on_disk:?}");
// Recording seals the edit as a content-addressed op into the primary
// session's signed chain — the headless receipt.
let before = sessions
.primary()
.unwrap()
.events()
.iter()
.filter(|e| e.kind == "op")
.count();
let recorded = record_trace(&mut sessions, None, author, "make answer return 42", 12_345, &trace);
assert_eq!(recorded, 1, "expected one op recorded");
let primary = sessions.primary().unwrap();
let ops_after = primary.events().iter().filter(|e| e.kind == "op").count();
assert_eq!(ops_after, before + 1, "ledger did not grow by one op");
assert!(primary.verify_chain(), "the signed ledger chain must verify");
assert_eq!(primary.energy_uj(), 12_345, "op energy folded into the session");
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn metered_run_records_per_step_energy() {
let root = std::env::temp_dir().join(format!("jc-metered-{}", std::process::id()));
let src = root.join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("lib.rs"), "fn answer() -> i32 { 0 }\n").unwrap();
// A fake host counter that advances 1000 µJ per read. `measure` reads it twice
// per step (before + after), so each recorded step's energy is exactly 1000 µJ —
// a deterministic stand-in for a real RAPL/IOReport counter.
let reader: EnergyReader = Rc::new({
let cumulative = Cell::new(0u64);
move || {
let value = cumulative.get();
cumulative.set(value + 1000);
(value, "hw_shunt")
}
});
// Scripted model: edit the file, then finish. Each turn is one `generate` step;
// the edit is one `edit` step — all metered independently.
let turns = [
"<<<TOOL edit_file>>>\nsrc/lib.rs\nfn answer() -> i32 { 42 }\n<<<END>>>",
"<<<TOOL finish>>>\n",
];
let mut step = 0usize;
let (trace, journal) = run_loop_metered(&root, "make answer return 42", 8, reader, |_prompt| {
let turn = turns[step.min(turns.len() - 1)];
step += 1;
// A scripted model still reports a token count, so the journal's
// joules-per-token path is exercised rather than left at zero.
Ok((turn.to_string(), turn.split_whitespace().count() as u32))
});
assert_eq!(trace.applied.len(), 1, "one edit applied");
assert!(!journal.is_empty(), "journal recorded steps");
// Every step carries its own measured energy + provenance — not one lump.
for record in &journal.steps {
assert_eq!(record.energy_uj, 1000, "{} step energy", record.kind);
assert_eq!(record.provenance, "hw_shunt");
assert!(record.ok, "{} step should succeed", record.kind);
}
let kinds: Vec<&str> = journal.steps.iter().map(|record| record.kind.as_str()).collect();
assert!(kinds.contains(&"generate"), "a generate step was metered: {kinds:?}");
assert!(kinds.contains(&"edit"), "the edit step was metered: {kinds:?}");
// The summary sums the per-step energy and splits it by kind.
let summary = journal.summary();
assert_eq!(summary.total_energy_uj, journal.steps.len() as u64 * 1000);
assert_eq!(summary.errors, 0);
assert!(summary.energy_by_kind.get("edit").copied().unwrap_or(0) >= 1000);
std::fs::remove_dir_all(&root).ok();
}