722d80d9listed two defects here as not done. `llamacpp` took a failed first counter read as 0 (`unwrap_or(0)`), so the next successful read reported the counter's whole since-boot total as the generation's energy. Its drain then tagged that figure measured unless some chunk had seen a zero delta. This is the defect467acb38fixed in eoc-streaming. And `mlc` and `onnx` reported 0 µJ for inference that ran whenever nothing was measured: tagged estimated, but still a zero presented as a figure. The rule now lives once, in a new `span` module, and every backend meters through it: - `MeterSpan::open` reads the counter; `reading` gives `Some(delta)` only when that opening read succeeded, the read now succeeds, and the counter advanced between them. A failed read at either end, a counter that held, one that went down, or the stub: `None`, never 0 and never a since-boot total. - `span_cost` makes a reading `JouleCost::measured` and asks for the estimate only when there was none. - `per_token_estimate` is tokens × `fallback_joules_per_token`, to the nearest µJ, and never 0: at least one token is counted, a coefficient that is not a finite positive number gives way to the default (0.05 J/token, mlx's figure since the start), and a product under half a µJ is 1 µJ. Per backend: - `llamacpp`: `TokenChunk::cumulative_microjoules` is now `Option<u64>`, the reading so far, `None` when nothing was measured. The inference's cost is the final chunk's reading, measured, or the per-token estimate over the tokens streamed. An earlier chunk's reading covers part of the generation, so it is never taken as the cost of the whole. The backend gains `fallback_joules_per_token` and its builder. Nothing outside this file uses `TokenChunk`. - `mlc` and `onnx` gain `fallback_joules_per_token` (default 0.05 J) and its builder, and estimate over the placeholder's own token count, as `mlx` does. - `mlx` already had the right rule; it now goes through the same functions, so a zero coefficient no longer yields 0 µJ. Factored so it tests without native code. Neither the `llamacpp` nor the `mlx` module binds a single symbol of its native crate (both generators are placeholders), so this crate's tests compile them without their features (`cfg(any(feature, test))`; mlx on macOS). Their metering now runs in every `cargo test --workspace`, where before it ran in none: llama.cpp is not built here, and `--features mlx` fails in mlx-sys's Metal kernel build on this toolchain. The `not(feature = "llamacpp")` error stub, unreachable since the module itself was gated on the feature, is gone. Tests, all on a scripted counter that reads a list (`None` is a failed read) and counts its reads: - span: an advance of 450 µJ reads 450; a failed first read followed by 9,000,000 reads nothing; so does a failed last read, a held counter, a counter that went down, and the stub. The estimate is 800,000 µJ for 8 × 0.1 J, 100,000 for 0 tokens at 0.1 J, the default for 0, -1, NaN and infinity, 1 µJ for 1e-12 J, and saturates at u64::MAX. - llamacpp, through `stream` and `infer`: the stub gives the 4-token estimate (200,000 µJ, estimated) and no chunk a reading; a failed first read then 9 J gives no chunk a reading and the estimate; 1,000 -> 1,100 ... 1,450 gives chunk readings 100, 200, 300, 400 and a measured 450 over exactly six reads; a held counter and a failed last read give the estimate. A table test pins that only the final chunk's reading is a cost. - mlc, onnx, mlx: measured 450 for an advance; the estimate (800,000 µJ) for a held counter, a failed first read and a failed last read; the stub's estimate, never 0. The `joule_attribution` integration test now asserts the mlc stub's estimate (500,000 µJ, estimated) where it asserted 0. Falsified, each mutant applied to the uncommitted tree, run, and restored byte-for-byte (sha256 checked): - a failed start taken as 0 fails 5 tests (llamacpp, mlc, mlx, onnx, span), the llamacpp one with every chunk at `Some(9000000)`; - `>=` in place of `>` (a held counter measured) fails 9; - llamacpp reading the counter's absolute value per chunk (HEAD's rule with a 0 baseline) fails 3; - a partial chunk reading kept as the cost fails the tally test; - mlc's and onnx's HEAD estimate of 0 fail 2 each; - the estimate without its 1 µJ floor, without the one-token minimum, or without the coefficient check fails the estimate test. One mutant survived and is equivalent: `final_reading = reading.or(previous)` cannot differ, since nothing sets the field before the final chunk. `cargo test -p eoc-local` passes (36 + 1), with `--features mlc,gguf` (47 + 3) and `--features onnx --lib` (43). The onnx leg runs `--lib` because the `joule_attribution` test binary cannot link ort-sys without ONNX Runtime installed when mlc is also enabled; that is unchanged. No clippy warning in this crate, before or after. Not done: the `llamacpp` error path still returns 0 µJ, estimated, with the error text. Whether any work ran before an error is unknown there. Co-Authored-By: Claude Opus 5.5 <[email protected]>
121 lines
4.3 KiB
Rust
121 lines
4.3 KiB
Rust
//! Joule-attribution integration test.
|
|
//!
|
|
//! Verifies the contract: when the meter is a [`StubCounter`] nothing
|
|
//! was measured, so the cost is the backend's per-token estimate, never
|
|
//! 0 µJ; when the meter is a non-stub counter whose readings monotonically
|
|
//! advance, the reported joule cost is non-zero and measured.
|
|
//!
|
|
//! These tests exercise the *plumbing* — the backends themselves use
|
|
//! their respective placeholder implementations in the reference
|
|
//! build. The contract is the same regardless of whether the backend
|
|
//! is wired to a real native runtime or to a placeholder.
|
|
|
|
#![forbid(unsafe_code)]
|
|
#![allow(unused_imports)]
|
|
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
|
use eoc_core::{JouleSource, Query};
|
|
use eoc_meter::{JouleCounter, StubCounter};
|
|
|
|
/// A counter that returns a monotonically increasing value each time
|
|
/// it is read. Useful for asserting that the sandwich-meter logic in
|
|
/// the backends actually picks up a non-zero delta.
|
|
#[derive(Debug, Default)]
|
|
struct TickingCounter {
|
|
state: AtomicU64,
|
|
step: u64,
|
|
}
|
|
|
|
impl TickingCounter {
|
|
fn new(step: u64) -> Self {
|
|
Self {
|
|
state: AtomicU64::new(0),
|
|
step,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl JouleCounter for TickingCounter {
|
|
fn read_microjoules(&self) -> eoc_core::Result<u64> {
|
|
Ok(self.state.fetch_add(self.step, Ordering::Relaxed) + self.step)
|
|
}
|
|
fn name(&self) -> &'static str {
|
|
"ticking-test-counter"
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "mlc")]
|
|
#[tokio::test]
|
|
async fn mlc_stub_counter_yields_an_estimate_never_zero() {
|
|
use eoc_local::MlcBackend;
|
|
use eoc_neural::NeuralBackend;
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let b = MlcBackend::new(dir.path(), Arc::new(StubCounter))
|
|
.unwrap()
|
|
.with_max_tokens(10)
|
|
.with_fallback_joules_per_token(0.05);
|
|
let q = Query::new("hello");
|
|
let r = b.infer(&q).await;
|
|
// Nothing measured the inference, which ran: 10 * 0.05 J = 500,000 µJ,
|
|
// an estimate.
|
|
assert_eq!(r.joule_cost.microjoules, 500_000);
|
|
assert_eq!(r.joule_cost.source, JouleSource::Estimated);
|
|
}
|
|
|
|
#[cfg(feature = "mlc")]
|
|
#[tokio::test]
|
|
async fn mlc_ticking_counter_yields_measured_nonzero_cost() {
|
|
use eoc_local::MlcBackend;
|
|
use eoc_neural::NeuralBackend;
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let meter: Arc<dyn JouleCounter> = Arc::new(TickingCounter::new(10_000));
|
|
let b = MlcBackend::new(dir.path(), meter).unwrap();
|
|
let q = Query::new("hello");
|
|
let r = b.infer(&q).await;
|
|
assert!(r.joule_cost.microjoules > 0);
|
|
assert_eq!(r.joule_cost.source, JouleSource::Measured);
|
|
}
|
|
|
|
#[cfg(all(feature = "mlx", target_os = "macos"))]
|
|
#[tokio::test]
|
|
async fn mlx_stub_counter_uses_estimated_fallback() {
|
|
use eoc_local::MlxBackend;
|
|
use eoc_neural::NeuralBackend;
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let model = dir.path().join("model");
|
|
std::fs::create_dir(&model).unwrap();
|
|
let tok = model.join("tokenizer.json");
|
|
std::fs::write(&tok, b"{}").unwrap();
|
|
let b = MlxBackend::new(&model, &tok, Arc::new(StubCounter))
|
|
.unwrap()
|
|
.with_max_tokens(10)
|
|
.with_fallback_joules_per_token(0.05);
|
|
let q = Query::new("ping");
|
|
let r = b.infer(&q).await;
|
|
// With a stub counter the backend should fall back to its estimated
|
|
// coefficient: 10 * 0.05 J = 500,000 µJ.
|
|
assert_eq!(r.joule_cost.microjoules, 500_000);
|
|
assert_eq!(r.joule_cost.source, JouleSource::Estimated);
|
|
}
|
|
|
|
// Note: an onnx-backend test could mirror the mlc one above, but the
|
|
// `ort-sys` crate requires either a `download-binaries` feature or an
|
|
// ORT_LIB_PATH pointing at libonnxruntime to *link* a test binary.
|
|
// Library compilation succeeds without that — see the unit tests in
|
|
// `src/onnx.rs` — but integration tests cannot be linked in a
|
|
// hermetic sandbox without the runtime present. Operators with ONNX
|
|
// Runtime installed can re-enable the test by flipping the `ort`
|
|
// dependency's `download-binaries` feature in `Cargo.toml`.
|
|
|
|
/// When no backend feature is enabled, this test crate still compiles
|
|
/// and one trivial test runs so cargo test does not complain about an
|
|
/// empty suite.
|
|
#[test]
|
|
fn ticking_counter_advances() {
|
|
let c = TickingCounter::new(7);
|
|
let a = c.read_microjoules().unwrap();
|
|
let b = c.read_microjoules().unwrap();
|
|
assert!(b > a);
|
|
}
|