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]>
133 lines
5.7 KiB
Rust
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();
|
|
}
|