All checks were successful
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 2m10s
standards workspaces build + test / aac-rs (pull_request) Successful in 39s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 44s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 36s
standards workspaces build + test / map-rs (pull_request) Successful in 31s
standards workspaces build + test / mesh-rs (pull_request) Successful in 25s
standards workspaces build + test / openpay (pull_request) Successful in 1m23s
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Successful in 1m29s
jouleclaw public surface must stay documented / Public-surface audit (jouleclaw-rs) (pull_request) Successful in 1m1s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m47s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m17s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m16s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m9s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m35s
standards workspaces build + test / arl-rs (pull_request) Successful in 2m17s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 2m49s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 4m29s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 2m44s
standards workspaces build + test / sense-rs (pull_request) Successful in 2m47s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Successful in 2m22s
`detect_in_process_meter` is for work that runs on this process's own threads. Each caller that meters model work was checked against that, and adopted it only where it holds. - jouleclaw-harness-econ: `local_model_econ`, `swebench_local_coder` and `swebench_local_suite` meter a decode that runs in this process, on the loader's CPU kernels and rayon pool; the examples build the loader with `rayon` only, without its GPU `fabric`, and the metered window is the decode alone (the `python3` judge, a subprocess, runs outside it). They now use the in-process meter, so on macOS 27.0.1, where the host meter is the load-average estimator, a dispatch is a live hardware reading of its own CPU energy. `window_joules` already refuses a window the counter did not advance across. `meter_probe` reports both meters. Run here on a host under load average ~50: the host meter was the estimator (115.6 J over 2.89 s, Estimator, SocTotal), the in-process meter process-cpu (2.397 J over 2.84 s, HwShunt, ProcessCpu) for the same one-thread spin. - jouledesk-github stays on `detect_meter`: its model runs in a `llama-completion` subprocess, which a per-process counter would bill nothing. Its docs say so, and a test pins the meter to the host meter. - l3-jouleclaw's `run` binary stays on the host counter, and says why: `llama` is a subprocess, and the in-process `jouleclaw-runtime` engine boots the Apple backend's GPU kernels (Metal and MPS matmuls, the quantized GEMVs that serve decode), whose energy is the GPU's. - jouleclaw-loader-gguf is unchanged: its metered paths (`tune_shape_live`, `fabric_calibrate`, `mpp_energy`) compare or calibrate GPU against CPU members, so they need the whole SoC, not one process's CPU. Ran: `cargo test -p jouleclaw-harness-econ`, `cargo check` of its examples, `cargo test -p jouledesk-github` (13 passed), and `cargo test` in l3-jouleclaw. No model was loaded; the examples' model runs were not repeated. Co-Authored-By: Claude Opus 5.5 <[email protected]>
355 lines
15 KiB
Rust
355 lines
15 KiB
Rust
//! A varied multi-instance joules/$-per-verified-fix benchmark on a real local
|
|
//! code model. Five distinct bug classes (wrong-operator, off-by-one,
|
|
//! empty-guard, boundary-comparison, identity-vs-abs) — each a real Python file
|
|
//! the Granite coder must fix as a whole-file replacement, judged by a real
|
|
//! `python3` test ([`WholeFileValidator`]), with the decode energy metered.
|
|
//!
|
|
//! This is the multi-instance generalisation of `swebench_local_coder`: instead
|
|
//! of one fix it reports a metered curve across bug types — which classes a 4B
|
|
//! local model resolves, and the joules/$ each verified fix actually cost. The
|
|
//! denominator is fixes that PASS their test, never attempts. Energy covers the decode alone,
|
|
//! which runs on this process's own threads (the loader's CPU kernels; built
|
|
//! without its GPU `fabric`), so the meter is `jouleclaw-energy`'s in-process meter
|
|
//! (`detect_in_process_meter`) at its own provenance: this process's own
|
|
//! CPU-energy counter (`process-cpu`, `HwShunt` over `ProcessCpu`) where it is
|
|
//! live, as on macOS 27.0.1 on an M5 Max and an M3 Ultra; else the host meter —
|
|
//! the IOReport counter (`HwShunt`) where its CPU rail is live, or the
|
|
//! load-derived estimator (`Estimator`). The `python3` judge is a subprocess and
|
|
//! runs outside the metered window. The report says which meter ran (decode time
|
|
//! + tokens are exact). A local model is priced energy-only.
|
|
//!
|
|
//! Run (defaults to the granite-4.0-h-micro snapshot if present):
|
|
//! ```text
|
|
//! cargo run --release -p jouleclaw-harness-econ --example swebench_local_suite
|
|
//! cargo run --release -p jouleclaw-harness-econ --example swebench_local_suite -- /path/to/snapshot
|
|
//! ```
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
use std::sync::atomic::{AtomicU32, Ordering};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Instant;
|
|
|
|
use jouleclaw_cascade::{
|
|
Answer, AnswerError, AnswerOutput, Cascade, ContextRef, ExecutionTrace, JouleBudget,
|
|
L3ModelId, QualityFloor, Query, QueryInput, Runtime, Tier, TierEstimate, TierId,
|
|
VerificationStatus,
|
|
};
|
|
use jouleclaw_energy::meter::detect_in_process_meter;
|
|
use jouleclaw_energy::Provenance;
|
|
use jouleclaw_harness_econ::swebench::exec::{ShellTestRunner, WholeFileValidator};
|
|
use jouleclaw_harness_econ::swebench::{PatchValidator, SweInstance};
|
|
use jouleclaw_harness_econ::{joules_obtained, window_joules, EconHarness, PriceModel, Task};
|
|
use jouleclaw_loader_gguf::bpe_tokenizer::BpeTokenizer;
|
|
use jouleclaw_loader_gguf::granite_h::{GraniteHConfig, GraniteHModel};
|
|
use jouleclaw_loader_gguf::safetensors_reader::read_safetensors_file;
|
|
use jouleclaw_skill::{ConstantSkillInducer, SkillTier};
|
|
|
|
const DEFAULT_SNAPSHOT: &str = "/Volumes/macos_4TB_external_1/hf_hub/models--ibm-granite--granite-4.0-h-micro/snapshots/d5f01a3ea75f088947be3aae039f4ad52837dfde";
|
|
|
|
/// One bug instance: a module name, its buggy source, a one-line description of
|
|
/// the fix, and the python assertion that judges it.
|
|
struct Bug {
|
|
id: &'static str,
|
|
bug_desc: &'static str,
|
|
buggy: &'static str,
|
|
assertion: &'static str,
|
|
}
|
|
|
|
const BUGS: &[Bug] = &[
|
|
Bug {
|
|
id: "calc",
|
|
bug_desc: "`add` must return the sum a + b, but it returns a - b",
|
|
buggy: "def add(a, b):\n return a - b\n",
|
|
assertion: "import calc; assert calc.add(2,3)==5 and calc.add(10,4)==14",
|
|
},
|
|
Bug {
|
|
id: "rng",
|
|
bug_desc: "`upto(n)` must return [0..n-1] (n elements) but drops the last via range(n-1)",
|
|
buggy: "def upto(n):\n return list(range(n - 1))\n",
|
|
assertion: "import rng; assert rng.upto(3)==[0,1,2] and rng.upto(1)==[0]",
|
|
},
|
|
Bug {
|
|
id: "first",
|
|
bug_desc: "`first(xs)` must return None on empty input, currently raises IndexError",
|
|
buggy: "def first(xs):\n return xs[0]\n",
|
|
assertion: "import first; assert first.first([5,6])==5 and first.first([]) is None",
|
|
},
|
|
Bug {
|
|
id: "adult",
|
|
bug_desc: "`is_adult(age)` must be True at exactly 18 (>=), currently uses >",
|
|
buggy: "def is_adult(age):\n return age > 18\n",
|
|
assertion: "import adult; assert adult.is_adult(18) and not adult.is_adult(17)",
|
|
},
|
|
Bug {
|
|
id: "absval",
|
|
bug_desc: "`my_abs(x)` must return the absolute value, currently returns x unchanged",
|
|
buggy: "def my_abs(x):\n return x\n",
|
|
assertion: "import absval; assert absval.my_abs(-5)==5 and absval.my_abs(3)==3",
|
|
},
|
|
];
|
|
|
|
struct DispatchStat {
|
|
secs: f64,
|
|
tokens: usize,
|
|
joules: f64,
|
|
/// The meter that metered the decode, and the provenance of its readings.
|
|
meter: &'static str,
|
|
provenance: Provenance,
|
|
text: String,
|
|
}
|
|
|
|
/// Granite-backed coder tier: chat-template the problem, greedy-decode the
|
|
/// corrected file until end-of-text, meter the decode window.
|
|
struct LocalCoderTier {
|
|
model: GraniteHModel,
|
|
tok: BpeTokenizer,
|
|
max_tokens: usize,
|
|
eot: Option<u32>,
|
|
log: Arc<Mutex<Vec<DispatchStat>>>,
|
|
}
|
|
|
|
impl Tier for LocalCoderTier {
|
|
fn id(&self) -> TierId {
|
|
TierId::L3(L3ModelId(0))
|
|
}
|
|
fn estimate_cost(&self, _q: &Query) -> Option<TierEstimate> {
|
|
Some(TierEstimate {
|
|
joules: 30.0,
|
|
latency: std::time::Duration::from_secs(20),
|
|
confidence_floor: 0.8,
|
|
})
|
|
}
|
|
fn try_answer(&mut self, q: &Query, _budget: f64) -> Result<Answer, AnswerError> {
|
|
let user = match &q.input {
|
|
QueryInput::Text(s) => s.clone(),
|
|
_ => return Err(AnswerError::TierFailed { tier: self.id(), cause: "non-text".into() }),
|
|
};
|
|
let prompt = format!(
|
|
"<|start_of_role|>user<|end_of_role|>{user}<|end_of_text|>\n\
|
|
<|start_of_role|>assistant<|end_of_role|>"
|
|
);
|
|
let ids = self.tok.encode(&prompt);
|
|
|
|
// The decode runs on this process's threads, so the in-process meter
|
|
// covers it. A window the meter put no figure on stops the run
|
|
// (`window_joules`): a failed read is not a zero-energy reading, and a
|
|
// counter that did not advance did not see the work.
|
|
let meter = detect_in_process_meter();
|
|
let e0 = meter.read();
|
|
let t = Instant::now();
|
|
|
|
let mut state = self.model.new_state();
|
|
let mut logits = self.model.forward_prompt(&ids, &mut state);
|
|
let mut generated: Vec<u32> = Vec::new();
|
|
for step in 0..self.max_tokens {
|
|
let l = if step == 0 {
|
|
std::mem::take(&mut logits)
|
|
} else {
|
|
self.model.forward_token(*generated.last().unwrap(), &mut state)
|
|
};
|
|
let (arg, _) = l.iter().enumerate().fold((0usize, f32::NEG_INFINITY), |(bi, bv), (i, &v)| {
|
|
if v > bv { (i, v) } else { (bi, bv) }
|
|
});
|
|
let tok = arg as u32;
|
|
if Some(tok) == self.eot {
|
|
break;
|
|
}
|
|
generated.push(tok);
|
|
}
|
|
|
|
let secs = t.elapsed().as_secs_f64();
|
|
let (joules, provenance) = window_joules(e0, meter.read())
|
|
.unwrap_or_else(|why| panic!("no energy figure for this dispatch from `{}`: {why}", meter.active_name()));
|
|
let text = self.tok.decode(&generated);
|
|
self.log.lock().unwrap().push(DispatchStat {
|
|
secs,
|
|
tokens: generated.len(),
|
|
joules,
|
|
meter: meter.active_name(),
|
|
provenance,
|
|
text: text.clone(),
|
|
});
|
|
|
|
Ok(Answer {
|
|
output: AnswerOutput::Text(text),
|
|
tier_used: self.id(),
|
|
joules_spent: joules,
|
|
confidence: 0.8,
|
|
trace: ExecutionTrace::default(),
|
|
verification: VerificationStatus::Resolved,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn git(dir: &Path, args: &[&str]) -> std::process::Output {
|
|
Command::new("git").current_dir(dir).args(args).output().expect("git runs")
|
|
}
|
|
|
|
fn main() {
|
|
if Command::new("python3").arg("--version").output().is_err() {
|
|
eprintln!("python3 not found — this benchmark runs real python tests");
|
|
return;
|
|
}
|
|
let dir = PathBuf::from(std::env::args().nth(1).unwrap_or_else(|| DEFAULT_SNAPSHOT.into()));
|
|
if !dir.join("config.json").is_file() {
|
|
eprintln!("model snapshot not found at {}", dir.display());
|
|
return;
|
|
}
|
|
|
|
// ---- build one repo with all buggy files at the base commit ----
|
|
static C: AtomicU32 = AtomicU32::new(0);
|
|
let repo = std::env::temp_dir()
|
|
.join(format!("jc-suite-{}-{}", std::process::id(), C.fetch_add(1, Ordering::SeqCst)));
|
|
std::fs::create_dir_all(&repo).unwrap();
|
|
git(&repo, &["init", "-q"]);
|
|
git(&repo, &["config", "user.email", "[email protected]"]);
|
|
git(&repo, &["config", "user.name", "t"]);
|
|
for b in BUGS {
|
|
std::fs::write(repo.join(format!("{}.py", b.id)), b.buggy).unwrap();
|
|
}
|
|
git(&repo, &["add", "-A"]);
|
|
git(&repo, &["commit", "-q", "-m", "base (buggy)"]);
|
|
let base = String::from_utf8(git(&repo, &["rev-parse", "HEAD"]).stdout).unwrap().trim().to_string();
|
|
|
|
// one test runner over all instances' assertions, keyed by node id
|
|
let cmds: BTreeMap<String, String> = BUGS
|
|
.iter()
|
|
.map(|b| (format!("test::{}", b.id), format!("python3 -c '{}'", b.assertion)))
|
|
.collect();
|
|
let runner = ShellTestRunner::new(move |node: &str| cmds.get(node).cloned().unwrap_or_else(|| "false".into()));
|
|
// target file is "<instance_id>.py"
|
|
let validator: Arc<dyn PatchValidator> = Arc::new(WholeFileValidator::new(
|
|
&repo,
|
|
|inst: &SweInstance| PathBuf::from(format!("{}.py", inst.instance_id)),
|
|
runner,
|
|
));
|
|
|
|
// ---- instances + tasks ----
|
|
let mut tasks = Vec::new();
|
|
for b in BUGS {
|
|
let problem = format!(
|
|
"Fix the bug in the Python file `{0}.py`: {1}. Output ONLY the complete corrected \
|
|
contents of {0}.py inside a single ```python code block.\n\n{0}.py:\n```python\n{2}```",
|
|
b.id, b.bug_desc, b.buggy
|
|
);
|
|
let inst = SweInstance {
|
|
instance_id: b.id.into(),
|
|
repo: "local/suite".into(),
|
|
base_commit: base.clone(),
|
|
problem_statement: problem.clone(),
|
|
patch: String::new(),
|
|
test_patch: String::new(),
|
|
fail_to_pass: vec![format!("test::{}", b.id)],
|
|
pass_to_pass: vec![],
|
|
version: None,
|
|
environment_setup_commit: None,
|
|
};
|
|
let v = validator.clone();
|
|
let inst_v = inst.clone();
|
|
tasks.push(Task::text(
|
|
b.id,
|
|
b.id, // each its own class (one-shot, no skill compilation)
|
|
Query {
|
|
input: QueryInput::Text(problem),
|
|
// Budget must cover a real ~200-token local decode (~hundreds of
|
|
// J) so no dispatch is truncated/rejected mid-flight — every
|
|
// attempt's energy is then counted, including failed ones.
|
|
budget: JouleBudget { hard_limit: 2000.0, soft_target: 200.0 },
|
|
quality: QualityFloor::any(),
|
|
context: ContextRef::fresh(),
|
|
deadline: None,
|
|
},
|
|
move |o| matches!(o, AnswerOutput::Text(t) if v.validate(&inst_v, t)),
|
|
));
|
|
}
|
|
|
|
// ---- load the real code model ----
|
|
let load = Instant::now();
|
|
let config: GraniteHConfig =
|
|
serde_json::from_slice(&std::fs::read(dir.join("config.json")).unwrap()).unwrap();
|
|
let mut shard_paths: Vec<PathBuf> = std::fs::read_dir(&dir)
|
|
.unwrap()
|
|
.filter_map(|e| e.ok().map(|e| e.path()))
|
|
.filter(|p| {
|
|
p.extension().is_some_and(|e| e == "safetensors")
|
|
&& p.file_name().is_some_and(|n| n.to_string_lossy().starts_with("model"))
|
|
})
|
|
.collect();
|
|
shard_paths.sort();
|
|
let shards: Vec<_> = shard_paths
|
|
.iter()
|
|
.map(|p| read_safetensors_file(p).unwrap_or_else(|e| panic!("{p:?}: {e:?}")))
|
|
.collect();
|
|
let model = GraniteHModel::bind_sharded(config, shards).expect("bind");
|
|
let tok = BpeTokenizer::from_file(dir.join("tokenizer.json")).expect("tokenizer");
|
|
let eot = tok.special_id("<|end_of_text|>");
|
|
println!("loaded granite in {:.2}s", load.elapsed().as_secs_f64());
|
|
|
|
let log = Arc::new(Mutex::new(Vec::<DispatchStat>::new()));
|
|
let tier = LocalCoderTier { model, tok, max_tokens: 220, eot, log: log.clone() };
|
|
let slot = Mutex::new(Some(tier));
|
|
|
|
let mut harness = EconHarness::new(Box::new(ConstantSkillInducer), 3);
|
|
harness.price = PriceModel { usd_per_kwh: 0.15, l3_call_usd: 0.0, l4_call_usd: 0.0 };
|
|
|
|
let report = harness.run(
|
|
move |store| {
|
|
let mut casc = Cascade::new();
|
|
casc.register(Box::new(SkillTier::new(store)));
|
|
casc.register(Box::new(slot.lock().unwrap().take().expect("built once")));
|
|
Runtime::new_without_l0(casc)
|
|
},
|
|
&tasks,
|
|
1,
|
|
);
|
|
|
|
// ---- report ----
|
|
println!("\n=== Multi-instance local-model econ benchmark — real Granite, real python tests ===\n");
|
|
let stats = log.lock().unwrap();
|
|
println!("{:<8} {:>8} {:>7} {:>9} {}", "bug", "tokens", "secs", "joules", "result");
|
|
for (i, b) in BUGS.iter().enumerate() {
|
|
let s = &stats[i];
|
|
let resolved = report.rows.iter().find(|r| r.task_id == b.id).map(|r| r.verified).unwrap_or(false);
|
|
println!(
|
|
"{:<8} {:>8} {:>7.1} {:>9.3} {}",
|
|
b.id,
|
|
s.tokens,
|
|
s.secs,
|
|
s.joules,
|
|
if resolved { "RESOLVED" } else { "unresolved" }
|
|
);
|
|
}
|
|
println!(
|
|
"\nresolved (python tests really ran): {}/{}",
|
|
report.verified, report.total
|
|
);
|
|
let model_j: f64 = stats.iter().map(|s| s.joules).sum();
|
|
let toks: usize = stats.iter().map(|s| s.tokens).sum();
|
|
let secs: f64 = stats.iter().map(|s| s.secs).sum();
|
|
println!(
|
|
"totals: {toks} tok in {secs:.1}s ({:.1} tok/s), {model_j:.3} J spent",
|
|
if secs > 0.0 { toks as f64 / secs } else { 0.0 }
|
|
);
|
|
for s in stats.iter() {
|
|
println!(" {:.3} J {} by {} ({})", s.joules, joules_obtained(s.provenance), s.meter, s.provenance);
|
|
}
|
|
if report.verified > 0 {
|
|
println!(
|
|
"joules per VERIFIED fix : {:.3} J ({:?})",
|
|
report.joules_per_verified_fix, report.energy_provenance
|
|
);
|
|
println!("dollars per VERIFIED fix: ${:.8} (local → energy-only)", report.usd_per_verified_fix);
|
|
} else {
|
|
println!("0 verified — honest cascade signal: this tier is insufficient here, escalate.");
|
|
}
|
|
println!(
|
|
"\nThe denominator is fixes that PASS their test, not attempts. Energy is real\n\
|
|
inference on this machine, at each dispatch's meter and provenance above; swap a\n\
|
|
stronger model tier to raise the resolve rate and lower the joules-per-verified-fix."
|
|
);
|
|
|
|
drop(stats);
|
|
let _ = std::fs::remove_dir_all(&repo);
|
|
}
|