jcp: energy traceability across operation, activity and outcome #138
9 changed files with 3210 additions and 0 deletions
142
jcp/SPEC.md
142
jcp/SPEC.md
|
|
@ -476,6 +476,148 @@ interceptor chain (`jcp-intercept`) carries it as `coverage`, beside its
|
|||
`"Partial"`; a verifier MUST refuse any other value, `"Whole"` included, rather
|
||||
than read it either way.
|
||||
|
||||
### 5.3 Traceability across operation, activity and outcome (REQUIRED where an outcome claims energy)
|
||||
|
||||
§5.1 grades a figure and §5.2 says how much of the work it covers. Neither says
|
||||
which *level* the figure belongs to, and a deployed system is answerable at
|
||||
three that are not interchangeable:
|
||||
|
||||
| level | artifact | what it answers |
|
||||
|---|---|---|
|
||||
| **operation** | the `jcp/2` work receipt (§8) | what one unit of work cost |
|
||||
| **activity** | the AR-1 action receipt | what one *authorised* thing did — grant checked, environment attested, work performed, effect committed, bytes egressed |
|
||||
| **outcome** | `efficiency/1` | whether any of it bought anything |
|
||||
|
||||
Two rules follow, and both exist because the levels were previously joined by
|
||||
nothing.
|
||||
|
||||
**An outcome's energy MUST name its activities by content-id.** An outcome that
|
||||
identifies its unit of work by a label can assert any total it likes, and the
|
||||
level where the number is most load-bearing is then the level with no binding
|
||||
at all. A verifier MUST be able to resolve each named activity and re-add the
|
||||
figures. An outcome naming no activity is not traceable and MUST NOT be
|
||||
presented as though it were.
|
||||
|
||||
**The difference MUST be named, not absorbed.** The sum of an outcome's
|
||||
operations is not the outcome's energy. Authorising, attesting, verifying and
|
||||
pushing bytes off the device all cost joules that no operation receipt claims —
|
||||
AR-1 carries exactly one energy figure and it belongs to the work. That
|
||||
difference is the cost of being accountable, and it is reported as a signed
|
||||
residual in exact integer microjoules:
|
||||
|
||||
```text
|
||||
residual_uj = outcome_total_uj − Σ activity.joules_uj
|
||||
```
|
||||
|
||||
The residual MUST NOT be clamped. Negative means the bound activities claim more
|
||||
than the outcome admits — double-counted work, or an activity bound to the wrong
|
||||
outcome — which is a finding. An activity bound twice MUST be refused before the
|
||||
sum: counting it twice inflates the accounted figure and shrinks the residual,
|
||||
flattering the trace in the one direction that matters.
|
||||
|
||||
The composite provenance is the **floor** across the outcome and every activity,
|
||||
never an average and never the best: one `Estimator` part makes the whole trace
|
||||
estimated, because a trace cannot be more measured than the part of it that was
|
||||
not measured. Coverage takes the floor the same way. Aggregation MUST NOT be a
|
||||
step at which a grade improves.
|
||||
|
||||
### 5.4 Dynamism: what a deviation establishes, and what it never does (OPTIONAL)
|
||||
|
||||
A total cannot distinguish a throttled part from a contended host from a cold
|
||||
cache, and on silicon nobody has profiled it cannot say what normal was. Where
|
||||
an implementation carries energy as a time-resolved series, the following hold.
|
||||
|
||||
**Provenance and coverage are per-window.** Counters do not have uniform
|
||||
resolution — a package counter may be continuous while an accelerator counter is
|
||||
live only while a queue is — so one grade for a series is a grade for its best
|
||||
window.
|
||||
|
||||
**A baseline MUST declare where it was measured, and MUST NOT be applied
|
||||
outside it.** An expectation is a fit, and a fit is only a fit somewhere.
|
||||
Comparing against a baseline taken on different silicon, at a different
|
||||
occupancy, or at a coarser sampling window manufactures deviations that say
|
||||
nothing about the workload. An undeclared baseline is inapplicable, so
|
||||
forgetting to declare under-claims rather than inventing findings.
|
||||
|
||||
**A cause explains a deviation only with evidence.** A named operational cause
|
||||
— thermal limit, host contention, cold start, frequency scaling, a meter
|
||||
coverage gap — MUST carry a content-id for the reading that supports it.
|
||||
Otherwise "explained" becomes the laundering channel: anything inconvenient is
|
||||
labelled and disappears.
|
||||
|
||||
**Absence of detection is not absence.** A phenomenon shorter than one sampling
|
||||
window is invisible to it. That case is *unresolvable*, never *within band*.
|
||||
Reporting nothing found at a resolution that could not have found it is the
|
||||
quietest available way to be wrong.
|
||||
|
||||
**There MUST NOT be a verdict of intent.** Energy does not carry intent. A
|
||||
deviation says the joules did not match the expectation; it cannot say why, and
|
||||
on an uncharted stack it cannot say the expectation was right. An unexplained
|
||||
window is a request for a look, not a finding of wrongdoing, and a conformant
|
||||
implementation MUST NOT collapse unexplained, inapplicable and unresolvable
|
||||
windows into a single score — they mean "look at this", "we have no yardstick"
|
||||
and "we could not have seen it", which call for different responses.
|
||||
|
||||
### 5.5 Conservation, not anomaly detection (REQUIRED where a grant is reconciled)
|
||||
|
||||
The strong instrument is conservation, and it needs no model of normal.
|
||||
|
||||
Because every figure is integer microjoules and the sums are exact, the smallest
|
||||
representable discrepancy against a grant of `N` µJ is `1_000_000 / N` parts per
|
||||
million — on a one-joule grant, **1 ppm**. A drift of a fraction of a percent is
|
||||
three orders of magnitude above that floor. The question "do these joules fit
|
||||
anywhere" is therefore answerable arithmetically, where "is this unusual" is not.
|
||||
|
||||
This property does not survive the usual construction, and that is the point.
|
||||
Where an energy figure is *derived from a performance proxy* — utilisation times
|
||||
a thermal design figure, tokens times a coefficient, instructions times
|
||||
picojoules-per-operation — its model error is tens of percent, and a
|
||||
fractional-percent drift is invisible by construction: it sits far inside the
|
||||
error bar of the estimator that produced it. Deriving energy from performance
|
||||
does not merely lose accuracy, it destroys the ability to notice that the books
|
||||
do not balance. A conformant implementation MUST report whether the floor it
|
||||
quotes rests on a measured figure or a modelled one; quoting a 1 ppm floor over
|
||||
`Estimator` windows is exact arithmetic over a model's opinion.
|
||||
|
||||
### 5.6 The physical bound: refutation, never verification (OPTIONAL)
|
||||
|
||||
The joules were spent and the heat left the die; that quantity is not
|
||||
negotiable. The *measurement* of it is exactly as forgeable as any other report
|
||||
— a counter can be lied about, a figure fabricated, a `HwShunt` grade stamped on
|
||||
a number from nowhere.
|
||||
|
||||
What energy has that a synthetic unit does not is an **external referent**. A
|
||||
token count, a call count, a request rate can be any number whatsoever: nothing
|
||||
outside the accounting system constrains them, so a fabricated one is
|
||||
indistinguishable from a true one. A joule count is bounded by the part's power
|
||||
envelope and the window's duration.
|
||||
|
||||
An implementation MAY therefore refute a figure that exceeds what the part could
|
||||
have drawn, or falls below what a powered, executing part must draw — the second
|
||||
being the shape a frozen counter takes, and the reason §5's liveness rule
|
||||
exists. Bounds SHOULD be deliberately permissive (burst rating for the ceiling,
|
||||
absolute idle for the floor) so that a refutation is unassailable.
|
||||
|
||||
Passing a physical bound MUST NOT be reported as verification. A fabricated
|
||||
figure inside the envelope passes, and must: the bound establishes only that the
|
||||
figure is not impossible.
|
||||
|
||||
**The one thing that cannot be faked is how the energy was consumed.** A report
|
||||
of a profile is just numbers, but a part cannot draw the profile of one
|
||||
computation while performing another. This closes the hole conservation leaves:
|
||||
a total can be balanced by padding — invent an operation and attribute the
|
||||
surplus to it — but a fictional operation contributes no draw, so the observed
|
||||
character stops matching the declared work mix.
|
||||
|
||||
Character is also what survives unfamiliar hardware. The magnitudes move with
|
||||
the process node, the power envelope and the memory hierarchy, which is why a
|
||||
band baseline fails there; a compute-bound steady state is flat on any part, a
|
||||
cold start ramps on any part, a queue-driven workload alternates on any part. A
|
||||
declared shape MUST be declared in advance — classifying the observed shape and
|
||||
then deciding what it must have been is post-hoc pattern matching. An
|
||||
inconsistency refutes the declaration. It attributes no cause and names no
|
||||
intent.
|
||||
|
||||
## 6. The Frame (REQUIRED)
|
||||
|
||||
Every message is a typed Frame — one envelope across the tool layer (MCP's job)
|
||||
|
|
|
|||
1003
jcp/conformance/v1/energy_trace.json
Normal file
1003
jcp/conformance/v1/energy_trace.json
Normal file
File diff suppressed because it is too large
Load diff
1
jcp/jcp-rs/Cargo.lock
generated
1
jcp/jcp-rs/Cargo.lock
generated
|
|
@ -596,6 +596,7 @@ dependencies = [
|
|||
"hex",
|
||||
"jcp-receipt",
|
||||
"jcp-schema",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ description = "The energy-efficiency spec for agentic work, surfaced as a sealed
|
|||
jcp-schema = { workspace = true }
|
||||
jcp-receipt = { workspace = true }
|
||||
ed25519-dalek = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
|
|
|||
235
jcp/jcp-rs/crates/jcp-efficiency/src/bin/gen_trace_vectors.rs
Normal file
235
jcp/jcp-rs/crates/jcp-efficiency/src/bin/gen_trace_vectors.rs
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
//! Emit the `energy_trace/1` conformance pack to `jcp/conformance/v1/`.
|
||||
//!
|
||||
//! SPEC §5.3–5.6 states rules; a rule nothing emits is a proposal. This writes
|
||||
//! the vectors a third party replays to check an independent implementation —
|
||||
//! positives *and* the refusals, since the refusals are the load-bearing half:
|
||||
//! an implementation that accepts an unbound outcome, a double-counted
|
||||
//! activity, or a frozen counter has failed in the direction that matters.
|
||||
//!
|
||||
//! Run: `cargo run -p jcp-efficiency --bin gen_trace_vectors -- ../conformance/v1`
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use jcp_efficiency::dynamics::{
|
||||
Baseline, DrawShape, EnergyWindow, OperationalCause, PowerEnvelope,
|
||||
};
|
||||
use jcp_efficiency::trace::{ActivityRef, OutcomeTrace};
|
||||
use jcp_schema::{ContentId, Coverage, Energy, Provenance};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
fn energy(uj: u64, p: Provenance, c: Coverage) -> Energy {
|
||||
Energy { measured_uj: Some(uj), estimated_uj: uj, provenance: p, coverage: c }
|
||||
}
|
||||
|
||||
fn act(tag: &[u8], uj: u64, p: Provenance, c: Coverage) -> ActivityRef {
|
||||
ActivityRef::new(ContentId::of_bytes(tag), uj, p, c)
|
||||
}
|
||||
|
||||
fn win(i: u64, uj: u64, p: Provenance) -> EnergyWindow {
|
||||
EnergyWindow {
|
||||
started_ns: i * 1_000_000,
|
||||
duration_ns: 1_000_000,
|
||||
joules_uj: uj,
|
||||
provenance: p,
|
||||
coverage: Coverage::Whole,
|
||||
declared_work: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn series(vals: &[u64]) -> Vec<EnergyWindow> {
|
||||
vals.iter().enumerate().map(|(i, &v)| win(i as u64, v, Provenance::HwShunt)).collect()
|
||||
}
|
||||
|
||||
/// A case carries the inputs and the verdict a conformant implementation must
|
||||
/// reach. Reproducing the verdict is the whole test; a case that only carried
|
||||
/// inputs would check that an implementation runs, not that it agrees.
|
||||
fn case(note: &str, input: Value, expected: Value) -> Value {
|
||||
json!({ "note": note, "input": input, "expected": expected })
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let out = std::env::args()
|
||||
.nth(1)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../conformance/v1")
|
||||
});
|
||||
std::fs::create_dir_all(&out).expect("create output dir");
|
||||
|
||||
// ---- §5.3 conservation ------------------------------------------------
|
||||
let overhead = OutcomeTrace::new(
|
||||
energy(1_000_000, Provenance::HwShunt, Coverage::Whole),
|
||||
true,
|
||||
vec![
|
||||
act(b"a1", 400_000, Provenance::HwShunt, Coverage::Whole),
|
||||
act(b"a2", 350_000, Provenance::HwShunt, Coverage::Whole),
|
||||
],
|
||||
);
|
||||
let r = overhead.reconcile().expect("reconciles");
|
||||
assert_eq!(r.residual_uj, 250_000);
|
||||
|
||||
let floored = OutcomeTrace::new(
|
||||
energy(1_000, Provenance::HwShunt, Coverage::Whole),
|
||||
true,
|
||||
vec![
|
||||
act(b"a1", 300, Provenance::HwShunt, Coverage::Whole),
|
||||
act(b"a2", 300, Provenance::Estimator, Coverage::Partial),
|
||||
],
|
||||
);
|
||||
let fr = floored.reconcile().expect("reconciles");
|
||||
|
||||
let unbound = OutcomeTrace::new(
|
||||
energy(1_000_000, Provenance::HwShunt, Coverage::Whole), true, vec![],
|
||||
);
|
||||
let dup_act = act(b"same", 100, Provenance::HwShunt, Coverage::Whole);
|
||||
let dup = OutcomeTrace::new(
|
||||
energy(1_000, Provenance::HwShunt, Coverage::Whole), true,
|
||||
vec![dup_act.clone(), dup_act],
|
||||
);
|
||||
let over = OutcomeTrace::new(
|
||||
energy(100, Provenance::HwShunt, Coverage::Whole), true,
|
||||
vec![act(b"a1", 90, Provenance::HwShunt, Coverage::Whole),
|
||||
act(b"a2", 50, Provenance::HwShunt, Coverage::Whole)],
|
||||
);
|
||||
|
||||
let conservation = json!({
|
||||
"positive": [
|
||||
case("the residual names what no operation claims",
|
||||
json!(&overhead),
|
||||
json!({"accounted_uj": r.accounted_uj, "residual_uj": r.residual_uj,
|
||||
"provenance_floor": r.provenance_floor, "coverage_floor": r.coverage_floor,
|
||||
"unattributed_ppm": r.unattributed_ppm(overhead.total_uj())})),
|
||||
case("provenance and coverage take the floor, never the average",
|
||||
json!(&floored),
|
||||
json!({"accounted_uj": fr.accounted_uj, "residual_uj": fr.residual_uj,
|
||||
"provenance_floor": fr.provenance_floor, "coverage_floor": fr.coverage_floor})),
|
||||
],
|
||||
"negative": [
|
||||
case("an outcome traceable to nothing is refused", json!(&unbound),
|
||||
json!({"error": "Unbound"})),
|
||||
case("a double-bound activity is refused before the sum", json!(&dup),
|
||||
json!({"error": "DuplicateActivity"})),
|
||||
case("activities claiming more than the outcome are refused", json!(&over),
|
||||
json!({"error": "OverAccounted", "accounted_uj": 140, "total_uj": 100})),
|
||||
]
|
||||
});
|
||||
|
||||
// ---- §5.5 conservation against a grant --------------------------------
|
||||
let budget = json!({
|
||||
"positive": [
|
||||
case("half a percent over a one-joule grant is 5000 ppm, floor 1 ppm",
|
||||
json!({"granted_uj": 1_000_000u64, "accounted_uj": 1_005_000u64,
|
||||
"provenance_floor": Provenance::HwShunt}),
|
||||
json!({"drift_ppm": 5_000, "detectable_floor_ppm": 1,
|
||||
"drift_is_resolvable": true, "floor_is_physical": true})),
|
||||
case("the same floor over modelled windows is exact and meaningless",
|
||||
json!({"granted_uj": 1_000_000u64, "accounted_uj": 1_000_005u64,
|
||||
"provenance_floor": Provenance::Estimator}),
|
||||
json!({"drift_ppm": 5, "detectable_floor_ppm": 1,
|
||||
"floor_is_physical": false})),
|
||||
case("underspend is signed, not clamped: allowed joules nothing claimed",
|
||||
json!({"granted_uj": 1_000_000u64, "accounted_uj": 300_000u64,
|
||||
"provenance_floor": Provenance::HwShunt}),
|
||||
json!({"drift_ppm": -700_000})),
|
||||
],
|
||||
"negative": [
|
||||
case("a ratio against a zero grant is not a figure",
|
||||
json!({"granted_uj": 0u64, "accounted_uj": 5u64}),
|
||||
json!({"drift_ppm": Value::Null, "detectable_floor_ppm": Value::Null})),
|
||||
]
|
||||
});
|
||||
|
||||
// ---- §5.6 physical refutation -----------------------------------------
|
||||
let env = PowerEnvelope {
|
||||
peak_mw: 60_000,
|
||||
idle_floor_mw: 1_000,
|
||||
source: "burst rating, deliberately permissive".into(),
|
||||
};
|
||||
let frozen = EnergyWindow { duration_ns: 400_000_000, joules_uj: 0, ..win(0, 0, Provenance::HwShunt) };
|
||||
let impossible = EnergyWindow { duration_ns: 1_000_000, joules_uj: 10_000_000, ..win(0, 0, Provenance::HwShunt) };
|
||||
let plausible = EnergyWindow { duration_ns: 1_000_000, joules_uj: 30_000, ..win(0, 0, Provenance::HwShunt) };
|
||||
let instant = EnergyWindow { duration_ns: 0, joules_uj: 999, ..win(0, 0, Provenance::HwShunt) };
|
||||
|
||||
let physical = json!({
|
||||
"envelope": &env,
|
||||
"cases": [
|
||||
case("a frozen counter on a busy window is refuted from below",
|
||||
json!(&frozen), json!({"verdict": "RefutedBelow", "floor_uj": 400_000})),
|
||||
case("a figure past the burst envelope is refuted from above",
|
||||
json!(&impossible), json!({"verdict": "RefutedAbove"})),
|
||||
case("inside the envelope is NotRefuted — which is not verification",
|
||||
json!(&plausible), json!({"verdict": "NotRefuted"})),
|
||||
case("a zero-duration window is unboundable, not clean",
|
||||
json!(&instant), json!({"verdict": "Unboundable"})),
|
||||
]
|
||||
});
|
||||
|
||||
// ---- §5.6 draw character ----------------------------------------------
|
||||
let padded = series(&[300_000, 10, 10, 10, 10, 10]);
|
||||
let flat = series(&[1_000, 1_020, 980, 1_010, 990, 1_000]);
|
||||
let scaled: Vec<u64> = [1_000u64, 1_020, 980, 1_010, 990, 1_000].iter().map(|v| v * 37).collect();
|
||||
let ramp = series(&[100, 400, 700, 1_000, 1_100, 1_120]);
|
||||
let bursty = series(&[100, 2_000, 100, 2_000, 100, 2_000]);
|
||||
|
||||
let shape = json!({
|
||||
"idle_floor_uj": 100,
|
||||
"cases": [
|
||||
case("a total balanced by padding does not read as sustained",
|
||||
json!({"declared": DrawShape::Sustained, "windows": &padded}),
|
||||
json!({"verdict": "Inconsistent"})),
|
||||
case("character is scale-invariant: same shape at 37x the magnitude",
|
||||
json!({"declared": DrawShape::Sustained, "windows": &series(&scaled)}),
|
||||
json!({"verdict": "Consistent", "shape": DrawShape::Sustained})),
|
||||
case("flat is sustained", json!({"declared": DrawShape::Sustained, "windows": &flat}),
|
||||
json!({"verdict": "Consistent", "shape": DrawShape::Sustained})),
|
||||
case("rising then settling is a ramp", json!({"declared": DrawShape::Ramping, "windows": &ramp}),
|
||||
json!({"verdict": "Consistent", "shape": DrawShape::Ramping})),
|
||||
case("alternating is bursty", json!({"declared": DrawShape::Bursty, "windows": &bursty}),
|
||||
json!({"verdict": "Consistent", "shape": DrawShape::Bursty})),
|
||||
case("under three windows character is unresolvable, never consistent",
|
||||
json!({"declared": DrawShape::Sustained, "windows": &series(&[1_000, 1_000])}),
|
||||
json!({"verdict": "Unresolvable", "windows": 2})),
|
||||
]
|
||||
});
|
||||
|
||||
// ---- §5.4 baselines and causes ----------------------------------------
|
||||
let w = win(0, 5_000, Provenance::HwShunt);
|
||||
let undeclared = Baseline::new(1_000, 100, "harness:run-1", 1_000);
|
||||
let declared = Baseline::new(1_000, 100, "harness:run-1", 1_000).declared_applicable();
|
||||
let coarse = Baseline::new(1_000, 100, "harness:coarse", 10_000_000).declared_applicable();
|
||||
let cause = OperationalCause::ThermalThrottle { reading: ContentId::of_bytes(b"thermal") };
|
||||
|
||||
let dynamism = json!({
|
||||
"cases": [
|
||||
case("an undeclared baseline makes no comparison",
|
||||
json!({"window": &w, "baseline": &undeclared}),
|
||||
json!({"deviation": "BaselineInapplicable"})),
|
||||
case("a baseline fit coarser than the trace samples does not apply",
|
||||
json!({"window": &w, "baseline": &coarse}),
|
||||
json!({"deviation": "BaselineInapplicable"})),
|
||||
case("outside the band with no evidenced cause stays unexplained",
|
||||
json!({"window": &w, "baseline": &declared, "cause": Value::Null}),
|
||||
json!({"deviation": "Unexplained", "excess_uj": 4_000})),
|
||||
case("a cause with evidence explains; the token is the claim vocabulary",
|
||||
json!({"window": &w, "baseline": &declared, "cause": &cause}),
|
||||
json!({"deviation": "Explained", "cause_token": cause.token(),
|
||||
"evidence": cause.evidence().to_hex(), "excess_uj": 4_000})),
|
||||
],
|
||||
"note": "there is no verdict of intent in this profile, by design"
|
||||
});
|
||||
|
||||
let pack = json!({
|
||||
"profile": "energy_trace/1",
|
||||
"spec": "JCP v0.3 SPEC.md §5.3-5.6",
|
||||
"conservation": conservation,
|
||||
"budget": budget,
|
||||
"physical": physical,
|
||||
"shape": shape,
|
||||
"dynamism": dynamism,
|
||||
});
|
||||
|
||||
let path = out.join("energy_trace.json");
|
||||
let body = serde_json::to_string_pretty(&pack).expect("serialize") + "\n";
|
||||
std::fs::write(&path, body).unwrap_or_else(|e| panic!("write {}: {e}", path.display()));
|
||||
println!("wrote {}", path.display());
|
||||
}
|
||||
904
jcp/jcp-rs/crates/jcp-efficiency/src/dynamics.rs
Normal file
904
jcp/jcp-rs/crates/jcp-efficiency/src/dynamics.rs
Normal file
|
|
@ -0,0 +1,904 @@
|
|||
//! Time-resolved energy, and the honest limit of what a deviation establishes.
|
||||
//!
|
||||
//! # Why a total is not enough
|
||||
//!
|
||||
//! [`crate::trace`] reconciles an outcome against its activities and names the
|
||||
//! residual. That is conservation, and it is static: one scalar per outcome.
|
||||
//! Real compute is not static. The same declared work costs different joules on
|
||||
//! a throttled part, a contended host, a cold cache, or a frequency-scaled
|
||||
//! core — and a single total cannot tell any of those apart from each other,
|
||||
//! let alone from interference.
|
||||
//!
|
||||
//! So energy has to be carried as a **series**, each window graded on its own,
|
||||
//! and compared against a baseline that says where it was measured.
|
||||
//!
|
||||
//! # The thing this module refuses to do
|
||||
//!
|
||||
//! There is no `Malicious` variant in [`Deviation`], and that is the most
|
||||
//! important line in this file.
|
||||
//!
|
||||
//! Energy does not carry intent. A deviation says the joules did not match the
|
||||
//! expectation; it cannot say why, and on a stack whose normal behaviour has
|
||||
//! never been charted it cannot even say the expectation was right. A type with
|
||||
//! a `Malicious` arm would be used — some caller would map `Unexplained` onto
|
||||
//! it, a dashboard would colour it red, and a number with no evidential basis
|
||||
//! would start informing decisions about people. That is the same failure as a
|
||||
//! prover returning `Proved` because a stand-in said so, moved into security.
|
||||
//!
|
||||
//! What this module produces is a **partition**: attributable, explained by a
|
||||
//! named cause *with evidence*, unexplained, or unresolvable. `Unexplained` is
|
||||
//! a request for a look, not a verdict. The difference between interference and
|
||||
//! an uncharted-but-legitimate stack is a question evidence answers, and this
|
||||
//! makes that question askable instead of pre-answering it wrongly.
|
||||
//!
|
||||
//! # Explaining requires evidence, not a label
|
||||
//!
|
||||
//! [`OperationalCause`] never explains a deviation on its own. Each cause
|
||||
//! carries a content-id for the reading that supports it — a thermal sample, a
|
||||
//! co-tenancy attestation, a cache-state record. A cause asserted without
|
||||
//! evidence leaves the window `Unexplained`, because otherwise "explained"
|
||||
//! becomes the laundering channel: anything inconvenient gets labelled
|
||||
//! `ThermalThrottle` and disappears.
|
||||
//!
|
||||
//! # Absence of detection is not absence
|
||||
//!
|
||||
//! A window is a sampling interval, and a phenomenon shorter than one window is
|
||||
//! invisible to it. That case is [`Deviation::Unresolvable`], never
|
||||
//! `WithinBand`. Reporting "nothing found" at a resolution that could not have
|
||||
//! found it is the quietest way to be wrong, and the one an auditor is least
|
||||
//! able to catch.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use jcp_schema::{ContentId, Coverage, Provenance};
|
||||
|
||||
/// One time-resolved energy sample.
|
||||
///
|
||||
/// Provenance and coverage are per-window on purpose. Counters do not have
|
||||
/// uniform resolution: a host may expose a package counter continuously and an
|
||||
/// accelerator counter only while a queue is active, so the same trace can be
|
||||
/// `HwShunt`/`Whole` in one window and `Estimator`/`Partial` in the next. A
|
||||
/// single grade for the series would be a grade for its best window.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EnergyWindow {
|
||||
/// Monotonic start, nanoseconds.
|
||||
pub started_ns: u64,
|
||||
/// Window length, nanoseconds. This is the trace's resolution floor.
|
||||
pub duration_ns: u64,
|
||||
/// Energy attributed to this window.
|
||||
pub joules_uj: u64,
|
||||
/// How this window's figure was acquired.
|
||||
pub provenance: Provenance,
|
||||
/// How much of the window's work the figure accounts for.
|
||||
pub coverage: Coverage,
|
||||
/// The operation this window is declared to cover, when one is named.
|
||||
pub declared_work: Option<ContentId>,
|
||||
}
|
||||
|
||||
/// Where a baseline was measured, so a reader can tell whether it applies.
|
||||
///
|
||||
/// The same discipline as `mesh-router`'s `Calibration`: an expectation is a
|
||||
/// fit, and a fit is only a fit somewhere. A baseline taken on different
|
||||
/// silicon, at a different occupancy, or before a toolchain change is not
|
||||
/// wrong — it is *inapplicable*, and the two must not be confused, because
|
||||
/// comparing against an inapplicable baseline manufactures deviations that say
|
||||
/// nothing about the workload.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Baseline {
|
||||
/// The expected energy for one window of this shape, in µJ.
|
||||
pub expected_uj: u64,
|
||||
/// Half-width of the accepted band, in µJ. Deviation is measured against
|
||||
/// the band, not the point: a point expectation would flag every window.
|
||||
pub band_uj: u64,
|
||||
/// Where the expectation came from — a harness run id, a receipt
|
||||
/// content-id, a document. Names the measurement; does not substitute for
|
||||
/// it.
|
||||
pub source: String,
|
||||
/// The window length the baseline was fit at. A baseline fit at a coarser
|
||||
/// resolution than the trace cannot speak to the trace's windows.
|
||||
pub fit_window_ns: u64,
|
||||
/// Whether the operator has declared this baseline applicable to the host
|
||||
/// and workload being traced. Defaults to false at construction: an
|
||||
/// undeclared baseline is treated as inapplicable, so forgetting
|
||||
/// under-claims rather than manufacturing findings.
|
||||
pub declared_applicable: bool,
|
||||
}
|
||||
|
||||
impl Baseline {
|
||||
/// A baseline that does not yet claim to apply anywhere.
|
||||
pub fn new(expected_uj: u64, band_uj: u64, source: impl Into<String>, fit_window_ns: u64) -> Self {
|
||||
Baseline {
|
||||
expected_uj,
|
||||
band_uj,
|
||||
source: source.into(),
|
||||
fit_window_ns,
|
||||
declared_applicable: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Declare this baseline applicable to the host and workload being traced.
|
||||
/// An explicit act, because it is the step that turns a number from
|
||||
/// somewhere else into a yardstick here.
|
||||
pub fn declared_applicable(mut self) -> Self {
|
||||
self.declared_applicable = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A named, evidenced reason a window's energy departed from expectation.
|
||||
///
|
||||
/// Every variant carries the content-id of a reading that supports it. The
|
||||
/// evidence is the whole point: without it a cause is a label, and labels
|
||||
/// explain anything.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum OperationalCause {
|
||||
/// The part was thermally limited. Evidence: a thermal sample covering the
|
||||
/// window.
|
||||
ThermalThrottle { reading: ContentId },
|
||||
/// The host was shared with work this trace does not account for.
|
||||
/// Evidence: a co-tenancy or utilisation record.
|
||||
ContendedHost { reading: ContentId },
|
||||
/// First execution: cold caches, JIT or kernel compilation. Evidence: a
|
||||
/// record showing this was a first run.
|
||||
ColdStart { reading: ContentId },
|
||||
/// The core ran at a different operating point than the baseline's.
|
||||
/// Evidence: a frequency or power-state sample.
|
||||
FrequencyScaled { reading: ContentId },
|
||||
/// The meter does not cover part of this window's work, so the figure is
|
||||
/// structurally low. Evidence: the coverage record.
|
||||
MeterCoverageGap { reading: ContentId },
|
||||
}
|
||||
|
||||
impl OperationalCause {
|
||||
/// The reading that supports this cause.
|
||||
pub fn evidence(&self) -> ContentId {
|
||||
match self {
|
||||
OperationalCause::ThermalThrottle { reading }
|
||||
| OperationalCause::ContendedHost { reading }
|
||||
| OperationalCause::ColdStart { reading }
|
||||
| OperationalCause::FrequencyScaled { reading }
|
||||
| OperationalCause::MeterCoverageGap { reading } => *reading,
|
||||
}
|
||||
}
|
||||
|
||||
/// The §-style token for this cause, for a receipt's claim vocabulary.
|
||||
pub fn token(&self) -> &'static str {
|
||||
match self {
|
||||
OperationalCause::ThermalThrottle { .. } => "thermal_throttle",
|
||||
OperationalCause::ContendedHost { .. } => "contended_host",
|
||||
OperationalCause::ColdStart { .. } => "cold_start",
|
||||
OperationalCause::FrequencyScaled { .. } => "frequency_scaled",
|
||||
OperationalCause::MeterCoverageGap { .. } => "meter_coverage_gap",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What one window's energy establishes against its baseline.
|
||||
///
|
||||
/// **There is deliberately no `Malicious` variant.** See the module docs: a
|
||||
/// deviation cannot carry intent, and a type that offered that arm would have it
|
||||
/// used.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Deviation {
|
||||
/// Inside the baseline's band. Says the window is unremarkable *at this
|
||||
/// resolution and against this baseline* — nothing more.
|
||||
WithinBand,
|
||||
/// Outside the band, and a named cause with evidence accounts for it.
|
||||
Explained { cause: OperationalCause, excess_uj: i128 },
|
||||
/// Outside the band with no evidenced cause. **This is not a finding of
|
||||
/// wrongdoing.** It is the set a human should look at, and on an uncharted
|
||||
/// stack it will often be the largest set — which is a statement about the
|
||||
/// stack's charting, not about the workload's honesty.
|
||||
Unexplained { excess_uj: i128 },
|
||||
/// The baseline does not apply here — undeclared, or fit at a resolution
|
||||
/// coarser than this window. No comparison is made, because comparing
|
||||
/// against an inapplicable baseline manufactures deviations.
|
||||
BaselineInapplicable { reason: &'static str },
|
||||
/// The window is longer than the phenomenon being asked about, so this
|
||||
/// window could not have shown it. Distinct from `WithinBand`: one is
|
||||
/// "looked and saw nothing", the other is "could not have seen it".
|
||||
Unresolvable { window_ns: u64, asked_ns: u64 },
|
||||
}
|
||||
|
||||
impl EnergyWindow {
|
||||
/// Classify this window against a baseline.
|
||||
pub fn classify(&self, baseline: &Baseline) -> Deviation {
|
||||
if !baseline.declared_applicable {
|
||||
return Deviation::BaselineInapplicable {
|
||||
reason: "baseline not declared applicable to this host and workload",
|
||||
};
|
||||
}
|
||||
if baseline.fit_window_ns > self.duration_ns {
|
||||
return Deviation::BaselineInapplicable {
|
||||
reason: "baseline fit at a coarser window than this trace samples",
|
||||
};
|
||||
}
|
||||
let lo = baseline.expected_uj.saturating_sub(baseline.band_uj);
|
||||
let hi = baseline.expected_uj.saturating_add(baseline.band_uj);
|
||||
if self.joules_uj >= lo && self.joules_uj <= hi {
|
||||
return Deviation::WithinBand;
|
||||
}
|
||||
Deviation::Unexplained {
|
||||
excess_uj: i128::from(self.joules_uj) - i128::from(baseline.expected_uj),
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify, offering a cause. The cause is accepted only because it
|
||||
/// carries evidence; `None` leaves the window unexplained rather than
|
||||
/// quietly excused.
|
||||
pub fn classify_with(
|
||||
&self,
|
||||
baseline: &Baseline,
|
||||
cause: Option<OperationalCause>,
|
||||
) -> Deviation {
|
||||
match (self.classify(baseline), cause) {
|
||||
(Deviation::Unexplained { excess_uj }, Some(cause)) => {
|
||||
Deviation::Explained { cause, excess_uj }
|
||||
}
|
||||
(other, _) => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this window could resolve a phenomenon of `asked_ns`.
|
||||
pub fn resolves(&self, asked_ns: u64) -> Deviation {
|
||||
if asked_ns < self.duration_ns {
|
||||
Deviation::Unresolvable { window_ns: self.duration_ns, asked_ns }
|
||||
} else {
|
||||
Deviation::WithinBand
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The partition over a window series. Counts, never a score.
|
||||
///
|
||||
/// There is no single number here, and that is deliberate. A "risk score" would
|
||||
/// collapse `unexplained` and `inapplicable` and `unresolvable` into one figure
|
||||
/// that reads as confidence, when the three mean "look at this", "we have no
|
||||
/// yardstick", and "we could not have seen it" — which call for completely
|
||||
/// different responses.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Partition {
|
||||
pub within_band: usize,
|
||||
pub explained: usize,
|
||||
pub unexplained: usize,
|
||||
pub baseline_inapplicable: usize,
|
||||
pub unresolvable: usize,
|
||||
/// Σ excess over the unexplained windows, signed.
|
||||
pub unexplained_excess_uj: i128,
|
||||
/// The weakest provenance across every window classified. `None` until a
|
||||
/// window has been folded in — an empty partition has no grade to report,
|
||||
/// and inventing one would be a claim about nothing.
|
||||
pub provenance_floor: Option<Provenance>,
|
||||
}
|
||||
|
||||
impl Partition {
|
||||
/// Fold one classified window in.
|
||||
pub fn record(&mut self, window: &EnergyWindow, d: &Deviation) {
|
||||
match d {
|
||||
Deviation::WithinBand => self.within_band += 1,
|
||||
Deviation::Explained { .. } => self.explained += 1,
|
||||
Deviation::Unexplained { excess_uj } => {
|
||||
self.unexplained += 1;
|
||||
self.unexplained_excess_uj += *excess_uj;
|
||||
}
|
||||
Deviation::BaselineInapplicable { .. } => self.baseline_inapplicable += 1,
|
||||
Deviation::Unresolvable { .. } => self.unresolvable += 1,
|
||||
}
|
||||
self.provenance_floor = Some(match self.provenance_floor {
|
||||
None => window.provenance,
|
||||
Some(have) => weakest(have, window.provenance),
|
||||
});
|
||||
}
|
||||
|
||||
/// How many windows were actually compared against an applicable baseline.
|
||||
///
|
||||
/// The denominator that matters. A partition reporting zero unexplained
|
||||
/// windows out of a hundred means nothing if ninety-nine of them had no
|
||||
/// applicable baseline — and that is the shape most "nothing anomalous
|
||||
/// found" reports take on an uncharted stack.
|
||||
pub fn compared(&self) -> usize {
|
||||
self.within_band + self.explained + self.unexplained
|
||||
}
|
||||
|
||||
/// Whether this partition supports saying anything at all about the series.
|
||||
///
|
||||
/// `false` when nothing was compared. A report built on an empty comparison
|
||||
/// is not a quiet report, it is a vacuous one.
|
||||
pub fn is_substantive(&self) -> bool {
|
||||
self.compared() > 0
|
||||
}
|
||||
}
|
||||
|
||||
fn weakest(a: Provenance, b: Provenance) -> Provenance {
|
||||
fn rank(p: Provenance) -> u8 {
|
||||
match p {
|
||||
Provenance::HwShunt => 2,
|
||||
Provenance::ModelBased => 1,
|
||||
Provenance::Estimator => 0,
|
||||
}
|
||||
}
|
||||
if rank(b) < rank(a) { b } else { a }
|
||||
}
|
||||
|
||||
/// A granted energy budget against what the windows actually account for.
|
||||
///
|
||||
/// # Why fractions of a percent are detectable here and nowhere else
|
||||
///
|
||||
/// Everything in this family carries energy as **integer microjoules**, and the
|
||||
/// sums are exact. That makes the detection floor a property of arithmetic
|
||||
/// rather than of statistics: the smallest representable discrepancy is 1 µJ,
|
||||
/// so against a budget of `N` µJ the floor is `1_000_000 / N` parts per
|
||||
/// million. On a one-joule budget that is **1 ppm — one ten-thousandth of a
|
||||
/// percent**. A drift of a fraction of a percent sits three orders of magnitude
|
||||
/// above the floor, which is why it is not a subtle signal but an obvious one.
|
||||
///
|
||||
/// This is the part that does not survive the usual construction. Almost
|
||||
/// everywhere in computing, an energy figure is *derived from a performance
|
||||
/// proxy* — utilisation times a thermal design figure, tokens times a
|
||||
/// coefficient, instructions times picojoules-per-op — and never measured. The
|
||||
/// model error in such a figure is tens of percent. A fractional-percent drift
|
||||
/// is then invisible by construction: it is far inside the error bar of the
|
||||
/// estimator that produced the number, and no amount of resolution recovers it.
|
||||
/// Deriving energy from performance does not merely lose accuracy; it destroys
|
||||
/// the ability to notice that the books do not balance.
|
||||
///
|
||||
/// So the instrument is conservation, not anomaly detection. A band comparison
|
||||
/// asks "is this unusual against a model of normal", which an uncharted stack
|
||||
/// cannot answer. Conservation asks "do these joules fit anywhere", which needs
|
||||
/// no model of normal — only exact arithmetic and a grant that said how many
|
||||
/// were allowed. [`Provenance`] remains load-bearing throughout: a budget
|
||||
/// reconciled against `Estimator` windows is reconciled against a model's
|
||||
/// opinion, and the floor below is then fiction. The floor is real only to the
|
||||
/// extent the windows were measured.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Budget {
|
||||
/// Microjoules the grant authorised.
|
||||
pub granted_uj: u64,
|
||||
/// Σ of the windows' energy.
|
||||
pub accounted_uj: u128,
|
||||
/// The weakest provenance across the windows that produced
|
||||
/// [`Self::accounted_uj`]. Carried because the detection floor is only as
|
||||
/// real as the measurement under it.
|
||||
pub provenance_floor: Option<Provenance>,
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
/// Reconcile a window series against a grant.
|
||||
pub fn reconcile(granted_uj: u64, windows: &[EnergyWindow]) -> Self {
|
||||
let accounted_uj: u128 = windows.iter().map(|w| u128::from(w.joules_uj)).sum();
|
||||
let provenance_floor = windows
|
||||
.iter()
|
||||
.map(|w| w.provenance)
|
||||
.reduce(weakest);
|
||||
Budget { granted_uj, accounted_uj, provenance_floor }
|
||||
}
|
||||
|
||||
/// `accounted - granted`, signed. Positive is overspend; negative is energy
|
||||
/// the grant allowed that nothing claimed. Never clamped — both directions
|
||||
/// are findings, and the negative one is how unattributed work hides.
|
||||
pub fn drift_uj(&self) -> i128 {
|
||||
self.accounted_uj as i128 - i128::from(self.granted_uj)
|
||||
}
|
||||
|
||||
/// The drift in parts per million of the grant. `None` for a zero grant: a
|
||||
/// ratio against nothing is not a figure.
|
||||
pub fn drift_ppm(&self) -> Option<i128> {
|
||||
if self.granted_uj == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(self.drift_uj() * 1_000_000 / i128::from(self.granted_uj))
|
||||
}
|
||||
|
||||
/// The smallest drift this budget can represent, in ppm — one microjoule
|
||||
/// expressed against the grant.
|
||||
///
|
||||
/// Exact, not estimated. This is the number that says whether a given
|
||||
/// tolerance is even askable: a 0.1 % tolerance on a 1 mJ grant is 1 ppm of
|
||||
/// nothing useful, while on a 1 J grant it is a thousand times the floor.
|
||||
pub fn detectable_floor_ppm(&self) -> Option<u64> {
|
||||
if self.granted_uj == 0 {
|
||||
return None;
|
||||
}
|
||||
Some((1_000_000 / self.granted_uj).max(1))
|
||||
}
|
||||
|
||||
/// Whether the observed drift is above this budget's representational floor.
|
||||
///
|
||||
/// `false` means the books balance to the limit of what integers can say at
|
||||
/// this grant size — which is a real and bounded statement, unlike "no
|
||||
/// anomaly detected".
|
||||
pub fn drift_is_resolvable(&self) -> bool {
|
||||
match self.detectable_floor_ppm() {
|
||||
None => false,
|
||||
Some(floor) => self.drift_ppm().is_some_and(|d| d.unsigned_abs() >= u128::from(floor)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the floor is a claim about measurement or about a model.
|
||||
///
|
||||
/// A budget whose windows are `Estimator` has a floor that is arithmetically
|
||||
/// exact and physically meaningless: the estimator's own error dwarfs it.
|
||||
/// Reporting a 1 ppm floor over modelled windows would be the most precise
|
||||
/// way to mislead available here.
|
||||
pub fn floor_is_physical(&self) -> bool {
|
||||
matches!(self.provenance_floor, Some(Provenance::HwShunt))
|
||||
}
|
||||
}
|
||||
|
||||
/// The physical envelope a part cannot leave, used to **refute** energy claims.
|
||||
///
|
||||
/// # Energy cannot be faked; the measurement of it can
|
||||
///
|
||||
/// The joules were spent. The heat left the die. That quantity is not
|
||||
/// negotiable — but the *report* of it is exactly as corruptible as any other
|
||||
/// report. A counter can be lied about, a figure can be fabricated, and a
|
||||
/// `HwShunt` grade can be stamped on a number that came from nowhere. A meter
|
||||
/// reading a frozen register while claiming hardware measurement is not a
|
||||
/// hypothetical: it is a defect that ships.
|
||||
///
|
||||
/// So energy buys no immunity. What it buys is something no synthetic unit has:
|
||||
/// **an external referent**. A token count, an API-call count, a
|
||||
/// request-per-second figure can be any number whatsoever — nothing outside the
|
||||
/// accounting system constrains them, so a fabricated one is indistinguishable
|
||||
/// from a true one. A joule count is bounded by physics. The part has a power
|
||||
/// envelope; the window has a duration; the product of the two is a ceiling no
|
||||
/// honest figure can exceed, and the idle draw is a floor no powered part can
|
||||
/// fall below while doing work.
|
||||
///
|
||||
/// That makes a faked energy figure *falsifiable*, which is a strictly weaker
|
||||
/// and strictly more honest property than unfakeable.
|
||||
///
|
||||
/// # Refutes, never verifies
|
||||
///
|
||||
/// [`PhysicalVerdict`] has no `Verified` arm, for the same reason
|
||||
/// `sandbox-attest`'s locality bound has none: passing a physical check means
|
||||
/// the figure is *not impossible*, which is not the same as true. A figure
|
||||
/// inside the envelope may still be fabricated, and the envelope will never
|
||||
/// say so.
|
||||
///
|
||||
/// The bounds are deliberately **permissive** — peak burst rating for the
|
||||
/// ceiling, not typical; absolute idle for the floor. A refutation from a
|
||||
/// generous bound is unassailable: nobody can argue the part was merely running
|
||||
/// hot. The same reason the locality check uses the speed of light in vacuum
|
||||
/// rather than in fibre.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PowerEnvelope {
|
||||
/// Absolute peak the part can draw, milliwatts. Use the burst rating, not
|
||||
/// the sustained or typical figure.
|
||||
pub peak_mw: u64,
|
||||
/// Floor the part cannot fall below while powered and executing,
|
||||
/// milliwatts. Zero disables the floor check for parts with no credible
|
||||
/// idle figure — honest, and it gives up the ability to catch a dead
|
||||
/// counter.
|
||||
pub idle_floor_mw: u64,
|
||||
/// Where these bounds come from — a datasheet, a measured sweep, a receipt.
|
||||
/// Names the source; does not substitute for it.
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
/// What a physical bound establishes about an energy figure.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PhysicalVerdict {
|
||||
/// The figure is inside the envelope. **Not** a finding that it is correct:
|
||||
/// a fabricated figure inside the envelope passes this check, and must.
|
||||
NotRefuted { headroom_uj: u64 },
|
||||
/// The figure exceeds what the part could draw for the window's duration.
|
||||
/// Physically impossible, whatever the counter said.
|
||||
RefutedAbove { claimed_uj: u64, ceiling_uj: u64 },
|
||||
/// The figure is below what a powered, executing part must draw. This is
|
||||
/// the shape a dead or frozen counter takes: a busy window reporting
|
||||
/// implausibly little.
|
||||
RefutedBelow { claimed_uj: u64, floor_uj: u64 },
|
||||
/// The window has no duration, so no bound can be computed. Not a pass.
|
||||
Unboundable,
|
||||
}
|
||||
|
||||
impl PowerEnvelope {
|
||||
/// The most the part could have spent over `duration_ns`, in µJ.
|
||||
///
|
||||
/// mW × ns = 1e-3 W × 1e-9 s = 1e-12 J = 1e-6 µJ, so the product divides by
|
||||
/// 1e6 to land in microjoules. Computed in `u128` and saturating: a
|
||||
/// ceiling that overflowed to a small number would refute honest figures.
|
||||
pub fn ceiling_uj(&self, duration_ns: u64) -> u128 {
|
||||
u128::from(self.peak_mw).saturating_mul(u128::from(duration_ns)) / 1_000_000
|
||||
}
|
||||
|
||||
/// The least a powered, executing part must have spent over `duration_ns`.
|
||||
pub fn floor_uj(&self, duration_ns: u64) -> u128 {
|
||||
u128::from(self.idle_floor_mw).saturating_mul(u128::from(duration_ns)) / 1_000_000
|
||||
}
|
||||
|
||||
/// Try to refute a window's energy figure against physics.
|
||||
pub fn check(&self, window: &EnergyWindow) -> PhysicalVerdict {
|
||||
if window.duration_ns == 0 {
|
||||
return PhysicalVerdict::Unboundable;
|
||||
}
|
||||
let claimed = u128::from(window.joules_uj);
|
||||
let ceiling = self.ceiling_uj(window.duration_ns);
|
||||
if claimed > ceiling {
|
||||
return PhysicalVerdict::RefutedAbove {
|
||||
claimed_uj: window.joules_uj,
|
||||
ceiling_uj: ceiling.min(u128::from(u64::MAX)) as u64,
|
||||
};
|
||||
}
|
||||
let floor = self.floor_uj(window.duration_ns);
|
||||
if floor > 0 && claimed < floor {
|
||||
return PhysicalVerdict::RefutedBelow {
|
||||
claimed_uj: window.joules_uj,
|
||||
floor_uj: floor.min(u128::from(u64::MAX)) as u64,
|
||||
};
|
||||
}
|
||||
PhysicalVerdict::NotRefuted {
|
||||
headroom_uj: (ceiling - claimed).min(u128::from(u64::MAX)) as u64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The character of a draw over time — the one thing here that cannot be faked.
|
||||
///
|
||||
/// # Why shape, and not the total
|
||||
///
|
||||
/// The quantity is physical and the measurement is forgeable, but **how** the
|
||||
/// energy was consumed is the signature of what the silicon actually did. You
|
||||
/// can report any number you like; you cannot make a part draw the profile of a
|
||||
/// sustained matmul while it executes a memory-bound scan. The shape is a
|
||||
/// property of the computation.
|
||||
///
|
||||
/// This closes a hole conservation alone leaves open. A total can be balanced
|
||||
/// by padding — invent an operation, attribute the surplus joules to it, and the
|
||||
/// books add up. The profile does not cooperate: a fictional operation
|
||||
/// contributes no draw, so the observed character stops matching the declared
|
||||
/// work mix.
|
||||
///
|
||||
/// # Why it survives an uncharted stack
|
||||
///
|
||||
/// This is the part that answers the hard question. On unfamiliar silicon the
|
||||
/// *magnitudes* move — a different process node, a different power envelope, a
|
||||
/// different memory hierarchy all shift the absolute joules, which is exactly
|
||||
/// why a band baseline fails there. The *character* does not: a compute-bound
|
||||
/// steady state is flat on any part, a cold start ramps on any part, a
|
||||
/// queue-driven workload alternates on any part. So a declaration can be
|
||||
/// refuted by shape on hardware nobody has ever profiled, without any model of
|
||||
/// what normal looks like on it.
|
||||
///
|
||||
/// # Refutes a declaration, not a person
|
||||
///
|
||||
/// An inconsistency says the declared work did not produce this draw. It does
|
||||
/// not say what did, and it never says intent. The declaration has to be made
|
||||
/// **in advance** — classifying the shape first and then deciding what it must
|
||||
/// have been is post-hoc pattern matching, which is the anomaly-detection trap
|
||||
/// this module exists to avoid.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DrawShape {
|
||||
/// Flat within tolerance. Compute-bound steady state.
|
||||
Sustained,
|
||||
/// Rising then settling. Cold caches, kernel or JIT compilation, page-in.
|
||||
Ramping,
|
||||
/// Alternating high and low. Queue-driven, memory-bound, or I/O-gated.
|
||||
Bursty,
|
||||
/// At or near the floor throughout. The part was not doing the declared
|
||||
/// work — or the counter is dead, which looks identical from here and is
|
||||
/// why [`PowerEnvelope::check`] is the companion test.
|
||||
Idle,
|
||||
}
|
||||
|
||||
/// What comparing a declared shape against an observed one establishes.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ShapeVerdict {
|
||||
/// Observed character matches what was declared. Not a finding of honesty:
|
||||
/// a different workload of the same character also matches.
|
||||
Consistent { shape: DrawShape },
|
||||
/// The declared work did not produce this draw. A refutation of the
|
||||
/// declaration — not an attribution of cause, and never of intent.
|
||||
Inconsistent { declared: DrawShape, observed: DrawShape },
|
||||
/// Too few windows to speak to character. Three is the minimum at which
|
||||
/// flat, rising and alternating are distinguishable at all; fewer is
|
||||
/// `Unresolvable`, never `Consistent`.
|
||||
Unresolvable { windows: usize },
|
||||
}
|
||||
|
||||
/// Classify the character of a window series.
|
||||
///
|
||||
/// Integer arithmetic throughout, in µJ. `idle_floor_uj` is the per-window
|
||||
/// floor from the [`PowerEnvelope`]; a series at or under it is `Idle`
|
||||
/// regardless of its variation.
|
||||
pub fn observe_shape(windows: &[EnergyWindow], idle_floor_uj: u64) -> Option<DrawShape> {
|
||||
if windows.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let v: Vec<u64> = windows.iter().map(|w| w.joules_uj).collect();
|
||||
let n = v.len() as u128;
|
||||
let sum: u128 = v.iter().map(|&x| u128::from(x)).sum();
|
||||
let mean = sum / n;
|
||||
|
||||
if mean <= u128::from(idle_floor_uj) {
|
||||
return Some(DrawShape::Idle);
|
||||
}
|
||||
|
||||
// Mean absolute deviation as a permille of the mean — integer, no floats,
|
||||
// so the classification is reproducible rather than platform-dependent.
|
||||
let mad: u128 = v
|
||||
.iter()
|
||||
.map(|&x| {
|
||||
let x = u128::from(x);
|
||||
if x > mean { x - mean } else { mean - x }
|
||||
})
|
||||
.sum::<u128>()
|
||||
/ n;
|
||||
let spread_permille = if mean == 0 { 0 } else { mad * 1000 / mean };
|
||||
|
||||
// Flat enough to be one steady state.
|
||||
if spread_permille <= 100 {
|
||||
return Some(DrawShape::Sustained);
|
||||
}
|
||||
|
||||
// Rising: the second half's mean clearly exceeds the first half's, and the
|
||||
// series does not swing back and forth.
|
||||
let half = v.len() / 2;
|
||||
let first: u128 = v[..half].iter().map(|&x| u128::from(x)).sum::<u128>() / half as u128;
|
||||
let last: u128 =
|
||||
v[v.len() - half..].iter().map(|&x| u128::from(x)).sum::<u128>() / half as u128;
|
||||
let direction_changes = v
|
||||
.windows(3)
|
||||
.filter(|w| (w[1] > w[0]) != (w[2] > w[1]))
|
||||
.count();
|
||||
if last > first + first / 4 && direction_changes * 4 < v.len() {
|
||||
return Some(DrawShape::Ramping);
|
||||
}
|
||||
|
||||
Some(DrawShape::Bursty)
|
||||
}
|
||||
|
||||
/// Compare a shape declared in advance against the series actually observed.
|
||||
pub fn check_shape(
|
||||
declared: DrawShape,
|
||||
windows: &[EnergyWindow],
|
||||
idle_floor_uj: u64,
|
||||
) -> ShapeVerdict {
|
||||
match observe_shape(windows, idle_floor_uj) {
|
||||
None => ShapeVerdict::Unresolvable { windows: windows.len() },
|
||||
Some(observed) if observed == declared => ShapeVerdict::Consistent { shape: observed },
|
||||
Some(observed) => ShapeVerdict::Inconsistent { declared, observed },
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn win(started_ns: u64, joules_uj: u64) -> EnergyWindow {
|
||||
EnergyWindow {
|
||||
started_ns,
|
||||
duration_ns: 1_000_000, // 1 ms
|
||||
joules_uj,
|
||||
provenance: Provenance::HwShunt,
|
||||
coverage: Coverage::Whole,
|
||||
declared_work: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn series(vals: &[u64]) -> Vec<EnergyWindow> {
|
||||
vals.iter().enumerate().map(|(i, &v)| win(i as u64 * 1_000_000, v)).collect()
|
||||
}
|
||||
|
||||
/// The claim this whole module rests on, stated as a test: fractions of a
|
||||
/// percent are detectable because the arithmetic is exact integers.
|
||||
#[test]
|
||||
fn a_fractional_percent_drift_is_three_orders_above_the_floor() {
|
||||
// A 1 J grant, overspent by 0.5 % — 5 mJ.
|
||||
let granted = 1_000_000u64; // 1 J in µJ
|
||||
let b = Budget {
|
||||
granted_uj: granted,
|
||||
accounted_uj: u128::from(granted) + 5_000,
|
||||
provenance_floor: Some(Provenance::HwShunt),
|
||||
};
|
||||
assert_eq!(b.drift_ppm(), Some(5_000), "0.5 % is 5000 ppm");
|
||||
assert_eq!(b.detectable_floor_ppm(), Some(1), "1 µJ of 1 J is 1 ppm");
|
||||
assert!(b.drift_is_resolvable());
|
||||
assert!(b.floor_is_physical(), "and the floor means something: measured windows");
|
||||
}
|
||||
|
||||
/// The floor is arithmetic, not physics, when the windows are modelled.
|
||||
/// Reporting 1 ppm over an estimator's output would be the most precise way
|
||||
/// to mislead available here.
|
||||
#[test]
|
||||
fn a_modelled_budget_has_an_exact_but_meaningless_floor() {
|
||||
let b = Budget {
|
||||
granted_uj: 1_000_000,
|
||||
accounted_uj: 1_000_005,
|
||||
provenance_floor: Some(Provenance::Estimator),
|
||||
};
|
||||
assert_eq!(b.detectable_floor_ppm(), Some(1), "arithmetically exact");
|
||||
assert!(!b.floor_is_physical(), "and physically meaningless — say so");
|
||||
}
|
||||
|
||||
/// Underspend is a finding too: energy the grant allowed that nothing
|
||||
/// claimed is how unattributed work hides.
|
||||
#[test]
|
||||
fn drift_is_signed_in_both_directions() {
|
||||
let under = Budget::reconcile(1_000_000, &series(&[100_000, 100_000, 100_000]));
|
||||
assert!(under.drift_uj() < 0, "300 mJ claimed against a 1 J grant");
|
||||
assert_eq!(under.drift_ppm(), Some(-700_000));
|
||||
assert_eq!(Budget::reconcile(0, &series(&[1, 2, 3])).drift_ppm(), None);
|
||||
}
|
||||
|
||||
/// The physical floor refutes a dead counter. This is not hypothetical —
|
||||
/// it is the shape of a real defect: a busy window whose counter is frozen
|
||||
/// reports implausibly little while the meter still claims hardware.
|
||||
#[test]
|
||||
fn the_physical_floor_refutes_a_frozen_counter() {
|
||||
let env = PowerEnvelope {
|
||||
peak_mw: 60_000, // 60 W burst — deliberately generous
|
||||
idle_floor_mw: 1_000, // 1 W floor while executing
|
||||
source: "datasheet burst rating".into(),
|
||||
};
|
||||
// A 400 ms busy window reporting zero.
|
||||
let dead = EnergyWindow { duration_ns: 400_000_000, joules_uj: 0, ..win(0, 0) };
|
||||
match env.check(&dead) {
|
||||
PhysicalVerdict::RefutedBelow { claimed_uj, floor_uj } => {
|
||||
assert_eq!(claimed_uj, 0);
|
||||
assert_eq!(floor_uj, 400_000, "1 W for 400 ms is 400 mJ");
|
||||
}
|
||||
other => panic!("a frozen counter must be refutable: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// And the ceiling refutes a fabricated figure no part could have drawn.
|
||||
#[test]
|
||||
fn the_ceiling_refutes_a_physically_impossible_figure() {
|
||||
let env = PowerEnvelope {
|
||||
peak_mw: 60_000,
|
||||
idle_floor_mw: 0,
|
||||
source: "burst rating".into(),
|
||||
};
|
||||
let absurd = EnergyWindow { duration_ns: 1_000_000, joules_uj: 10_000_000, ..win(0, 0) };
|
||||
assert!(matches!(env.check(&absurd), PhysicalVerdict::RefutedAbove { .. }));
|
||||
|
||||
// A plausible figure is NOT refuted — and that is not a finding of
|
||||
// correctness. There is no `Verified` arm, deliberately.
|
||||
let ok = EnergyWindow { duration_ns: 1_000_000, joules_uj: 30_000, ..win(0, 0) };
|
||||
assert!(matches!(env.check(&ok), PhysicalVerdict::NotRefuted { .. }));
|
||||
}
|
||||
|
||||
/// A zero-duration window yields no bound. Not a pass.
|
||||
#[test]
|
||||
fn an_instant_window_is_unboundable_not_clean() {
|
||||
let env = PowerEnvelope { peak_mw: 1, idle_floor_mw: 1, source: "x".into() };
|
||||
let instant = EnergyWindow { duration_ns: 0, joules_uj: 999, ..win(0, 0) };
|
||||
assert_eq!(env.check(&instant), PhysicalVerdict::Unboundable);
|
||||
}
|
||||
|
||||
/// The hole conservation leaves open: a total balanced by padding. The
|
||||
/// shape does not cooperate — a fictional operation contributes no draw, so
|
||||
/// the declared character stops matching.
|
||||
#[test]
|
||||
fn shape_catches_a_total_balanced_by_padding() {
|
||||
// Declared: a sustained compute-bound run.
|
||||
// Observed: a short burst and then nothing, padded to the same total.
|
||||
let padded = series(&[300_000, 10, 10, 10, 10, 10]);
|
||||
match check_shape(DrawShape::Sustained, &padded, 100) {
|
||||
ShapeVerdict::Inconsistent { declared, observed } => {
|
||||
assert_eq!(declared, DrawShape::Sustained);
|
||||
assert_ne!(observed, DrawShape::Sustained);
|
||||
}
|
||||
other => panic!("padding must not read as sustained: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Character survives a change of scale, which is why this works on silicon
|
||||
/// nobody has profiled: the magnitudes move, the shape does not.
|
||||
#[test]
|
||||
fn character_is_scale_invariant() {
|
||||
let small = series(&[1_000, 1_020, 980, 1_010, 990, 1_000]);
|
||||
let large: Vec<u64> = [1_000u64, 1_020, 980, 1_010, 990, 1_000]
|
||||
.iter()
|
||||
.map(|v| v * 37)
|
||||
.collect();
|
||||
assert_eq!(observe_shape(&small, 10), Some(DrawShape::Sustained));
|
||||
assert_eq!(observe_shape(&series(&large), 10), Some(DrawShape::Sustained));
|
||||
}
|
||||
|
||||
/// Ramping is distinguishable from alternating, and both from flat.
|
||||
#[test]
|
||||
fn the_three_characters_are_distinguishable() {
|
||||
assert_eq!(
|
||||
observe_shape(&series(&[100, 400, 700, 1_000, 1_100, 1_120]), 10),
|
||||
Some(DrawShape::Ramping)
|
||||
);
|
||||
assert_eq!(
|
||||
observe_shape(&series(&[100, 2_000, 100, 2_000, 100, 2_000]), 10),
|
||||
Some(DrawShape::Bursty)
|
||||
);
|
||||
assert_eq!(
|
||||
observe_shape(&series(&[1_000, 1_000, 1_000, 1_000]), 10),
|
||||
Some(DrawShape::Sustained)
|
||||
);
|
||||
}
|
||||
|
||||
/// Below three windows, character is unresolvable — never "consistent".
|
||||
/// Reporting agreement at a resolution that could not disagree is the
|
||||
/// quietest way to be wrong.
|
||||
#[test]
|
||||
fn two_windows_cannot_speak_to_character() {
|
||||
assert_eq!(
|
||||
check_shape(DrawShape::Sustained, &series(&[1_000, 1_000]), 10),
|
||||
ShapeVerdict::Unresolvable { windows: 2 }
|
||||
);
|
||||
assert_eq!(observe_shape(&series(&[1, 2]), 0), None);
|
||||
}
|
||||
|
||||
/// An undeclared baseline makes no comparison. Forgetting to declare must
|
||||
/// not manufacture findings.
|
||||
#[test]
|
||||
fn an_undeclared_baseline_refuses_to_compare() {
|
||||
let w = win(0, 5_000);
|
||||
let b = Baseline::new(1_000, 100, "harness:run-1", 1_000_000);
|
||||
assert!(matches!(w.classify(&b), Deviation::BaselineInapplicable { .. }));
|
||||
// Declared, and now the window is outside the band.
|
||||
let b = b.declared_applicable();
|
||||
assert!(matches!(w.classify(&b), Deviation::Unexplained { .. }));
|
||||
}
|
||||
|
||||
/// A baseline fit at a coarser window than the trace samples cannot speak
|
||||
/// to the trace's windows.
|
||||
#[test]
|
||||
fn a_coarser_baseline_does_not_apply_to_finer_windows() {
|
||||
let w = win(0, 5_000); // 1 ms window
|
||||
let b = Baseline::new(1_000, 100, "harness", 10_000_000).declared_applicable();
|
||||
assert!(matches!(w.classify(&b), Deviation::BaselineInapplicable { .. }));
|
||||
}
|
||||
|
||||
/// A cause explains a deviation only because it carries evidence. Without
|
||||
/// one the window stays unexplained rather than quietly excused.
|
||||
#[test]
|
||||
fn a_cause_without_evidence_does_not_explain() {
|
||||
let w = win(0, 5_000);
|
||||
let b = Baseline::new(1_000, 100, "harness", 1_000).declared_applicable();
|
||||
assert!(matches!(w.classify_with(&b, None), Deviation::Unexplained { .. }));
|
||||
|
||||
let cause = OperationalCause::ThermalThrottle { reading: ContentId::of_bytes(b"thermal") };
|
||||
match w.classify_with(&b, Some(cause.clone())) {
|
||||
Deviation::Explained { cause: got, .. } => {
|
||||
assert_eq!(got.token(), "thermal_throttle");
|
||||
assert_eq!(got.evidence(), ContentId::of_bytes(b"thermal"));
|
||||
}
|
||||
other => panic!("an evidenced cause explains: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The denominator that matters: zero unexplained windows means nothing if
|
||||
/// nothing was compared.
|
||||
#[test]
|
||||
fn a_partition_that_compared_nothing_is_vacuous_not_clean() {
|
||||
let mut p = Partition::default();
|
||||
let w = win(0, 5_000);
|
||||
let undeclared = Baseline::new(1_000, 100, "harness", 1_000);
|
||||
for _ in 0..99 {
|
||||
p.record(&w, &w.classify(&undeclared));
|
||||
}
|
||||
assert_eq!(p.unexplained, 0, "nothing was flagged");
|
||||
assert_eq!(p.compared(), 0, "because nothing was compared");
|
||||
assert!(!p.is_substantive(), "so the partition supports no claim");
|
||||
assert_eq!(p.baseline_inapplicable, 99);
|
||||
assert_eq!(p.provenance_floor, Some(Provenance::HwShunt));
|
||||
}
|
||||
|
||||
/// There is no `Malicious` verdict, and that is the load-bearing absence.
|
||||
/// Unexplained is a request for a look.
|
||||
#[test]
|
||||
fn unexplained_is_not_a_verdict_about_intent() {
|
||||
let w = win(0, 50_000);
|
||||
let b = Baseline::new(1_000, 100, "harness", 1_000).declared_applicable();
|
||||
let d = w.classify(&b);
|
||||
match d {
|
||||
Deviation::Unexplained { excess_uj } => assert_eq!(excess_uj, 49_000),
|
||||
other => panic!("expected unexplained: {other:?}"),
|
||||
}
|
||||
// The partition counts it; it produces no score, and there is no arm
|
||||
// anywhere in this module that names intent.
|
||||
let mut p = Partition::default();
|
||||
p.record(&w, &d);
|
||||
assert_eq!(p.unexplained, 1);
|
||||
assert_eq!(p.unexplained_excess_uj, 49_000);
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,10 @@ use jcp_receipt::jcr1;
|
|||
use jcp_schema::{ContentId, Energy};
|
||||
|
||||
pub mod avoided;
|
||||
/// Energy traceability across operation, activity and outcome.
|
||||
pub mod trace;
|
||||
/// Time-resolved energy and the limits of what a deviation establishes.
|
||||
pub mod dynamics;
|
||||
pub mod training;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
|
|
|
|||
736
jcp/jcp-rs/crates/jcp-efficiency/src/trace.rs
Normal file
736
jcp/jcp-rs/crates/jcp-efficiency/src/trace.rs
Normal file
|
|
@ -0,0 +1,736 @@
|
|||
//! Energy traceability across the three levels an AI system is accountable at:
|
||||
//! **operation**, **activity**, and **outcome**.
|
||||
//!
|
||||
//! # The levels, and where the family already measures
|
||||
//!
|
||||
//! A deployed system is answerable at three different grains, and they are not
|
||||
//! interchangeable:
|
||||
//!
|
||||
//! - **Operation** — one unit of work. `jcp-receipt`'s JCR-1 work receipt
|
||||
//! carries `joules_uj` with a [`Provenance`] grade. This level is measured.
|
||||
//! - **Activity** — one *authorised* thing done: a grant checked, an
|
||||
//! environment attested, work performed, an effect committed, bytes egressed.
|
||||
//! `jcp-action-receipt`'s AR-1 binds all five by content-id.
|
||||
//! - **Outcome** — whether any of it *bought* anything. `efficiency/1` anchors
|
||||
//! on `completed` and totals the energy the unit spent.
|
||||
//!
|
||||
//! # The two seams this module closes
|
||||
//!
|
||||
//! **An outcome's energy was unbound.** [`crate::Efficiency::task`] is a
|
||||
//! `String`. An outcome could claim any total it liked and nothing tied that
|
||||
//! figure to the activities that produced it — the one level where a number is
|
||||
//! most load-bearing was the one level identified by free text. Here an outcome
|
||||
//! names its activities by [`ContentId`], so a verifier can resolve each one
|
||||
//! and add the figures up itself.
|
||||
//!
|
||||
//! **An activity had no energy of its own.** AR-1 carries exactly one joules
|
||||
//! figure and it belongs to *binding 3: work*. The cost of the authorisation
|
||||
//! check, the attestation, the verification, and of pushing bytes off the
|
||||
//! device is real and was attributed nowhere. `Egress` counts bytes and no
|
||||
//! joules.
|
||||
//!
|
||||
//! So the sum of an outcome's operations is **not** the outcome's energy, and
|
||||
//! the difference is not noise — it is the cost of being accountable. This
|
||||
//! module names that difference instead of letting it vanish.
|
||||
//!
|
||||
//! # Conservation, with the residual named
|
||||
//!
|
||||
//! Reconciliation is exact integer arithmetic in µJ, summed in `u128`:
|
||||
//!
|
||||
//! ```text
|
||||
//! residual_uj = total_uj - Σ activity.joules_uj
|
||||
//! ```
|
||||
//!
|
||||
//! The residual is **signed** and never clamped. Negative means the bound
|
||||
//! activities claim more energy than the outcome admits — double-counted work,
|
||||
//! or a wrong binding — and that is a finding, not a rounding error. Clamping
|
||||
//! it to zero would hide exactly the case an auditor needs.
|
||||
//!
|
||||
//! This mirrors `eoc-carbon`'s allocation discipline, where
|
||||
//! `Σ shares + residual == total` holds exactly and the residual is a named
|
||||
//! field rather than a silent remainder.
|
||||
//!
|
||||
//! # A chain is only as graded as its weakest link
|
||||
//!
|
||||
//! The composite [`Provenance`] is the **floor** across the parts, never an
|
||||
//! average and never the best: one `Estimator` activity makes the whole trace
|
||||
//! estimated, because the trace cannot be more measured than the thing in it
|
||||
//! that was not measured. Coverage takes the floor the same way — one `Partial`
|
||||
//! part makes the composite `Partial`, since the missing share does not become
|
||||
//! accounted for by sitting next to accounted shares.
|
||||
//!
|
||||
//! This is the same rule as `Vmm::attained_tier` returning the floor and
|
||||
//! `Backing` defaulting to `Unstated`: aggregation must not be a laundering
|
||||
//! step.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use jcp_schema::{ContentId, Coverage, Energy, Provenance};
|
||||
|
||||
/// One activity bound into an outcome, as a verifier reads it.
|
||||
///
|
||||
/// The fields are copied from the AR-1 receipt named by [`Self::receipt`] so a
|
||||
/// reconciliation can be checked without resolving every receipt — but
|
||||
/// [`Self::receipt`] is what makes the claim falsifiable: resolve it and the
|
||||
/// copied figures must match.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ActivityRef {
|
||||
/// The AR-1 action receipt. The binding, and the only thing here that
|
||||
/// cannot be fabricated without also fabricating a signed receipt.
|
||||
pub receipt: ContentId,
|
||||
/// The work's energy, from AR-1 `joules_uj`.
|
||||
pub joules_uj: u64,
|
||||
/// How that figure was acquired, from AR-1 `energy_provenance`. Carried
|
||||
/// verbatim; never upgraded here.
|
||||
pub provenance: Provenance,
|
||||
/// How much of the activity's work the figure accounts for.
|
||||
pub coverage: Coverage,
|
||||
}
|
||||
|
||||
impl ActivityRef {
|
||||
/// Bind one activity.
|
||||
pub fn new(
|
||||
receipt: ContentId,
|
||||
joules_uj: u64,
|
||||
provenance: Provenance,
|
||||
coverage: Coverage,
|
||||
) -> Self {
|
||||
ActivityRef { receipt, joules_uj, provenance, coverage }
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a trace cannot be presented as traceable.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum TraceError {
|
||||
/// The outcome names no activities. Its energy total is then an assertion
|
||||
/// about nothing a verifier can resolve — the free-text case this module
|
||||
/// exists to retire.
|
||||
Unbound,
|
||||
/// The bound activities claim more energy than the outcome admits.
|
||||
/// Double-counted work, or an activity bound to the wrong outcome.
|
||||
OverAccounted { accounted_uj: u128, total_uj: u64 },
|
||||
/// The same activity receipt appears twice. Summing it twice inflates the
|
||||
/// accounted figure and shrinks the residual, which flatters the trace in
|
||||
/// exactly the direction that matters.
|
||||
DuplicateActivity { receipt: ContentId },
|
||||
}
|
||||
|
||||
impl core::fmt::Display for TraceError {
|
||||
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||
match self {
|
||||
TraceError::Unbound => write!(
|
||||
f,
|
||||
"outcome names no activities: its energy total is not traceable \
|
||||
to anything a verifier can resolve"
|
||||
),
|
||||
TraceError::OverAccounted { accounted_uj, total_uj } => write!(
|
||||
f,
|
||||
"bound activities account for {accounted_uj} uJ but the outcome \
|
||||
admits only {total_uj} uJ; the work is double-counted or an \
|
||||
activity is bound to the wrong outcome"
|
||||
),
|
||||
TraceError::DuplicateActivity { receipt } => write!(
|
||||
f,
|
||||
"activity {} is bound twice; summing it twice inflates the \
|
||||
accounted figure and shrinks the residual",
|
||||
receipt.to_hex()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for TraceError {}
|
||||
|
||||
/// What reconciling an outcome against its activities establishes.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Reconciliation {
|
||||
/// Σ of the bound activities' work energy.
|
||||
pub accounted_uj: u128,
|
||||
/// `total - accounted`. **Signed and never clamped.** Positive is the cost
|
||||
/// of being accountable — authorisation, attestation, verification, egress
|
||||
/// — which no operation receipt claims. Zero means the outcome admits
|
||||
/// exactly its operations and nothing else, which for a gated, attested,
|
||||
/// receipt-emitting system is itself worth a second look.
|
||||
pub residual_uj: i128,
|
||||
/// The weakest provenance across the outcome and every bound activity.
|
||||
pub provenance_floor: Provenance,
|
||||
/// `Partial` if the outcome or any activity is partial.
|
||||
pub coverage_floor: Coverage,
|
||||
/// How many activities were reconciled.
|
||||
pub activities: usize,
|
||||
}
|
||||
|
||||
impl Reconciliation {
|
||||
/// The share of the outcome's energy that no operation receipt claims, in
|
||||
/// parts per million.
|
||||
///
|
||||
/// This is the number the three-level question actually turns on: how much
|
||||
/// of what an outcome cost is attributable to a measured operation, and how
|
||||
/// much is the overhead of accountability itself. `None` when the total is
|
||||
/// zero — a ratio against nothing is not a figure.
|
||||
pub fn unattributed_ppm(&self, total_uj: u64) -> Option<i128> {
|
||||
if total_uj == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(self.residual_uj * 1_000_000 / i128::from(total_uj))
|
||||
}
|
||||
}
|
||||
|
||||
/// An outcome, the activities that produced it, and the arithmetic between them.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OutcomeTrace {
|
||||
/// The outcome's own energy figure, as `efficiency/1` records it.
|
||||
pub total: Energy,
|
||||
/// Whether the outcome was reached. Carried because energy spent on an
|
||||
/// outcome that did not complete is a different claim from energy spent on
|
||||
/// one that did, and a trace that drops it loses the anchor.
|
||||
pub completed: bool,
|
||||
/// The activities this outcome is traced to.
|
||||
pub activities: Vec<ActivityRef>,
|
||||
}
|
||||
|
||||
impl OutcomeTrace {
|
||||
/// Trace an outcome to its activities.
|
||||
pub fn new(total: Energy, completed: bool, activities: Vec<ActivityRef>) -> Self {
|
||||
OutcomeTrace { total, completed, activities }
|
||||
}
|
||||
|
||||
/// The outcome's energy in µJ, preferring the measured figure.
|
||||
///
|
||||
/// `measured_uj` when present, `estimated_uj` otherwise — and the choice is
|
||||
/// already graded by [`Energy::provenance`], so this does not re-decide it.
|
||||
pub fn total_uj(&self) -> u64 {
|
||||
self.total.measured_uj.unwrap_or(self.total.estimated_uj)
|
||||
}
|
||||
|
||||
/// Reconcile the outcome against its activities, or refuse to.
|
||||
///
|
||||
/// Refuses an unbound outcome, a duplicated activity, and an over-accounted
|
||||
/// total. Everything else reconciles, including a large positive residual —
|
||||
/// that is a legitimate and interesting result, not an error.
|
||||
pub fn reconcile(&self) -> Result<Reconciliation, TraceError> {
|
||||
if self.activities.is_empty() {
|
||||
return Err(TraceError::Unbound);
|
||||
}
|
||||
|
||||
// Duplicate detection before summing: the whole point of the residual
|
||||
// is that it is exact, and a double-counted activity corrupts it
|
||||
// silently in the flattering direction.
|
||||
for (i, a) in self.activities.iter().enumerate() {
|
||||
if self.activities[..i].iter().any(|b| b.receipt == a.receipt) {
|
||||
return Err(TraceError::DuplicateActivity { receipt: a.receipt });
|
||||
}
|
||||
}
|
||||
|
||||
// u128 so a long trace cannot overflow into a small number — a wrapped
|
||||
// sum would under-account and inflate the residual.
|
||||
let accounted_uj: u128 =
|
||||
self.activities.iter().map(|a| u128::from(a.joules_uj)).sum();
|
||||
let total_uj = self.total_uj();
|
||||
if accounted_uj > u128::from(total_uj) {
|
||||
return Err(TraceError::OverAccounted { accounted_uj, total_uj });
|
||||
}
|
||||
|
||||
let residual_uj = i128::from(total_uj) - accounted_uj as i128;
|
||||
|
||||
let provenance_floor = self
|
||||
.activities
|
||||
.iter()
|
||||
.map(|a| a.provenance)
|
||||
.fold(self.total.provenance, weakest_provenance);
|
||||
let coverage_floor = self
|
||||
.activities
|
||||
.iter()
|
||||
.map(|a| a.coverage)
|
||||
.fold(self.total.coverage, weakest_coverage);
|
||||
|
||||
Ok(Reconciliation {
|
||||
accounted_uj,
|
||||
residual_uj,
|
||||
provenance_floor,
|
||||
coverage_floor,
|
||||
activities: self.activities.len(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The weaker of two acquisition classes: `HwShunt` > `ModelBased` > `Estimator`.
|
||||
///
|
||||
/// A composite is never more measured than its least-measured part. Averaging
|
||||
/// would let one hardware reading launder a pile of estimates.
|
||||
fn weakest_provenance(a: Provenance, b: Provenance) -> Provenance {
|
||||
fn rank(p: Provenance) -> u8 {
|
||||
match p {
|
||||
Provenance::HwShunt => 2,
|
||||
Provenance::ModelBased => 1,
|
||||
Provenance::Estimator => 0,
|
||||
}
|
||||
}
|
||||
if rank(b) < rank(a) {
|
||||
b
|
||||
} else {
|
||||
a
|
||||
}
|
||||
}
|
||||
|
||||
/// `Partial` if either is partial. An unaccounted share does not become
|
||||
/// accounted for by being summed with accounted ones.
|
||||
fn weakest_coverage(a: Coverage, b: Coverage) -> Coverage {
|
||||
match (a, b) {
|
||||
(Coverage::Whole, Coverage::Whole) => Coverage::Whole,
|
||||
_ => Coverage::Partial,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod seal_gate_tests {
|
||||
use super::*;
|
||||
use crate::dynamics::{EnergyWindow, PowerEnvelope};
|
||||
|
||||
fn energy(uj: u64, p: Provenance, c: Coverage) -> Energy {
|
||||
Energy { measured_uj: Some(uj), estimated_uj: uj, provenance: p, coverage: c }
|
||||
}
|
||||
fn act(tag: &[u8], uj: u64, p: Provenance, c: Coverage) -> ActivityRef {
|
||||
ActivityRef::new(ContentId::of_bytes(tag), uj, p, c)
|
||||
}
|
||||
fn win(uj: u64, dur_ns: u64) -> EnergyWindow {
|
||||
EnergyWindow {
|
||||
started_ns: 0,
|
||||
duration_ns: dur_ns,
|
||||
joules_uj: uj,
|
||||
provenance: Provenance::HwShunt,
|
||||
coverage: Coverage::Whole,
|
||||
declared_work: None,
|
||||
}
|
||||
}
|
||||
fn env() -> PowerEnvelope {
|
||||
PowerEnvelope { peak_mw: 60_000, idle_floor_mw: 1_000, source: "burst".into() }
|
||||
}
|
||||
fn sound() -> OutcomeTrace {
|
||||
OutcomeTrace::new(
|
||||
energy(1_000_000, Provenance::HwShunt, Coverage::Whole),
|
||||
true,
|
||||
vec![act(b"a1", 400_000, Provenance::HwShunt, Coverage::Whole),
|
||||
act(b"a2", 350_000, Provenance::HwShunt, Coverage::Whole)],
|
||||
)
|
||||
}
|
||||
|
||||
/// The doctrine, as a test: traceability is a precondition. An untraceable
|
||||
/// outcome cannot be admitted for sealing at all — a signature over an
|
||||
/// unchecked claim is the most durable way to publish it.
|
||||
#[test]
|
||||
fn an_untraceable_outcome_cannot_be_sealed() {
|
||||
let t = OutcomeTrace::new(
|
||||
energy(1_000, Provenance::HwShunt, Coverage::Whole), true, vec![],
|
||||
);
|
||||
assert_eq!(
|
||||
t.admit(&[], None),
|
||||
Err(SealRefusal::NotTraceable(TraceError::Unbound))
|
||||
);
|
||||
}
|
||||
|
||||
/// A frozen counter cannot be sealed as anything. This is the live defect
|
||||
/// shape: a busy window reporting nothing while the meter claims hardware.
|
||||
#[test]
|
||||
fn a_frozen_counter_cannot_be_sealed() {
|
||||
let t = sound();
|
||||
let dead = win(0, 400_000_000); // 400 ms claiming zero
|
||||
match t.admit(&[dead], Some(&env())) {
|
||||
Err(SealRefusal::PhysicallyRefuted { window_index, detail }) => {
|
||||
assert_eq!(window_index, 0);
|
||||
assert!(detail.contains("frozen counter"), "{detail}");
|
||||
}
|
||||
other => panic!("a dead counter must not seal: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Nor can a figure no part could have drawn.
|
||||
#[test]
|
||||
fn a_physically_impossible_figure_cannot_be_sealed() {
|
||||
let t = sound();
|
||||
let absurd = win(10_000_000, 1_000_000);
|
||||
assert!(matches!(
|
||||
t.admit(&[absurd], Some(&env())),
|
||||
Err(SealRefusal::PhysicallyRefuted { .. })
|
||||
));
|
||||
}
|
||||
|
||||
/// The grade is derived, never supplied. Claiming hardware over modelled
|
||||
/// evidence is refused rather than quietly downgraded: the caller has a
|
||||
/// bug, and writing the weaker grade silently would hide it.
|
||||
#[test]
|
||||
fn a_grade_the_evidence_does_not_support_is_refused() {
|
||||
let t = OutcomeTrace::new(
|
||||
energy(1_000, Provenance::HwShunt, Coverage::Whole),
|
||||
true,
|
||||
vec![act(b"a1", 300, Provenance::Estimator, Coverage::Whole)],
|
||||
);
|
||||
assert_eq!(
|
||||
t.admit(&[], None),
|
||||
Err(SealRefusal::GradeNotEarned {
|
||||
claimed: Provenance::HwShunt,
|
||||
floor: Provenance::Estimator
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// A sound trace is admitted, and what comes back carries the grade the
|
||||
/// evidence earned — so the sealer writes that instead of the claim.
|
||||
#[test]
|
||||
fn an_admitted_trace_carries_the_earned_grade_not_the_claimed_one() {
|
||||
let t = OutcomeTrace::new(
|
||||
energy(1_000, Provenance::ModelBased, Coverage::Whole),
|
||||
true,
|
||||
vec![act(b"a1", 300, Provenance::ModelBased, Coverage::Partial)],
|
||||
);
|
||||
let a = t.admit(&[], None).expect("admits");
|
||||
assert_eq!(a.earned_provenance(), Provenance::ModelBased);
|
||||
assert_eq!(
|
||||
a.earned_coverage(), Coverage::Partial,
|
||||
"one partial part makes the receipt partial"
|
||||
);
|
||||
assert_eq!(a.reconciliation().residual_uj, 700);
|
||||
}
|
||||
|
||||
/// Claiming LESS than the evidence supports is allowed — under-claiming is
|
||||
/// always permitted, and the receipt still records the floor.
|
||||
#[test]
|
||||
fn under_claiming_is_permitted() {
|
||||
let t = OutcomeTrace::new(
|
||||
energy(1_000, Provenance::Estimator, Coverage::Whole),
|
||||
true,
|
||||
vec![act(b"a1", 300, Provenance::HwShunt, Coverage::Whole)],
|
||||
);
|
||||
let a = t.admit(&[], None).expect("under-claiming admits");
|
||||
assert_eq!(a.earned_provenance(), Provenance::Estimator, "the floor is the claim");
|
||||
}
|
||||
|
||||
/// With no envelope supplied no physical check runs, and that is not a
|
||||
/// pass — the trace is admitted on its arithmetic alone, which is exactly
|
||||
/// what the absence of an envelope means.
|
||||
#[test]
|
||||
fn no_envelope_means_no_physical_claim_was_made() {
|
||||
let t = sound();
|
||||
let implausible = win(0, 400_000_000);
|
||||
assert!(t.admit(&[implausible], None).is_ok(), "arithmetic closes; physics unchecked");
|
||||
assert!(t.admit(&[win(0, 400_000_000)], Some(&env())).is_err(), "with an envelope it does not");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn energy(uj: u64, p: Provenance, c: Coverage) -> Energy {
|
||||
Energy { measured_uj: Some(uj), estimated_uj: uj, provenance: p, coverage: c }
|
||||
}
|
||||
|
||||
fn act(tag: &[u8], uj: u64, p: Provenance, c: Coverage) -> ActivityRef {
|
||||
ActivityRef::new(ContentId::of_bytes(tag), uj, p, c)
|
||||
}
|
||||
|
||||
/// The seam this module closes: an outcome whose energy is traceable to
|
||||
/// nothing is refused, rather than published as a number.
|
||||
#[test]
|
||||
fn an_unbound_outcome_is_refused() {
|
||||
let t = OutcomeTrace::new(
|
||||
energy(1_000_000, Provenance::HwShunt, Coverage::Whole),
|
||||
true,
|
||||
vec![],
|
||||
);
|
||||
assert_eq!(t.reconcile(), Err(TraceError::Unbound));
|
||||
}
|
||||
|
||||
/// The residual is the cost of being accountable — authorisation,
|
||||
/// attestation, verification, egress — which no operation receipt claims.
|
||||
/// It is named, not absorbed.
|
||||
#[test]
|
||||
fn the_residual_names_what_no_operation_claims() {
|
||||
let t = OutcomeTrace::new(
|
||||
energy(1_000_000, Provenance::HwShunt, Coverage::Whole),
|
||||
true,
|
||||
vec![
|
||||
act(b"a1", 400_000, Provenance::HwShunt, Coverage::Whole),
|
||||
act(b"a2", 350_000, Provenance::HwShunt, Coverage::Whole),
|
||||
],
|
||||
);
|
||||
let r = t.reconcile().expect("reconciles");
|
||||
assert_eq!(r.accounted_uj, 750_000);
|
||||
assert_eq!(r.residual_uj, 250_000, "a quarter of the outcome is overhead");
|
||||
assert_eq!(r.activities, 2);
|
||||
// A quarter of a joule unattributed, expressed against the whole.
|
||||
assert_eq!(r.unattributed_ppm(t.total_uj()), Some(250_000));
|
||||
}
|
||||
|
||||
/// Over-accounting is a finding, not a rounding error: the work is
|
||||
/// double-counted or an activity is bound to the wrong outcome.
|
||||
#[test]
|
||||
fn activities_claiming_more_than_the_outcome_are_refused() {
|
||||
let t = OutcomeTrace::new(
|
||||
energy(100, Provenance::HwShunt, Coverage::Whole),
|
||||
true,
|
||||
vec![act(b"a1", 90, Provenance::HwShunt, Coverage::Whole),
|
||||
act(b"a2", 50, Provenance::HwShunt, Coverage::Whole)],
|
||||
);
|
||||
assert_eq!(
|
||||
t.reconcile(),
|
||||
Err(TraceError::OverAccounted { accounted_uj: 140, total_uj: 100 })
|
||||
);
|
||||
}
|
||||
|
||||
/// A duplicated activity inflates the accounted figure and shrinks the
|
||||
/// residual — flattering in exactly the direction that matters, so it is
|
||||
/// caught before the sum.
|
||||
#[test]
|
||||
fn a_duplicated_activity_is_refused_before_it_can_flatter() {
|
||||
let dup = act(b"same", 100, Provenance::HwShunt, Coverage::Whole);
|
||||
let t = OutcomeTrace::new(
|
||||
energy(1_000, Provenance::HwShunt, Coverage::Whole),
|
||||
true,
|
||||
vec![dup.clone(), dup.clone()],
|
||||
);
|
||||
match t.reconcile() {
|
||||
Err(TraceError::DuplicateActivity { receipt }) => {
|
||||
assert_eq!(receipt, ContentId::of_bytes(b"same"));
|
||||
}
|
||||
other => panic!("a double-bound activity must be refused: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A chain is only as graded as its weakest link. One estimate makes the
|
||||
/// whole trace estimated — averaging would let a hardware reading launder
|
||||
/// a pile of models.
|
||||
#[test]
|
||||
fn provenance_takes_the_floor_never_the_average() {
|
||||
let t = OutcomeTrace::new(
|
||||
energy(1_000, Provenance::HwShunt, Coverage::Whole),
|
||||
true,
|
||||
vec![
|
||||
act(b"a1", 300, Provenance::HwShunt, Coverage::Whole),
|
||||
act(b"a2", 300, Provenance::Estimator, Coverage::Whole),
|
||||
],
|
||||
);
|
||||
let r = t.reconcile().unwrap();
|
||||
assert_eq!(r.provenance_floor, Provenance::Estimator);
|
||||
}
|
||||
|
||||
/// An unaccounted share does not become accounted for by sitting next to
|
||||
/// accounted ones.
|
||||
#[test]
|
||||
fn coverage_takes_the_floor_too() {
|
||||
let t = OutcomeTrace::new(
|
||||
energy(1_000, Provenance::HwShunt, Coverage::Whole),
|
||||
true,
|
||||
vec![
|
||||
act(b"a1", 300, Provenance::HwShunt, Coverage::Whole),
|
||||
act(b"a2", 300, Provenance::HwShunt, Coverage::Partial),
|
||||
],
|
||||
);
|
||||
assert_eq!(t.reconcile().unwrap().coverage_floor, Coverage::Partial);
|
||||
}
|
||||
|
||||
/// A trace whose operations exactly equal its outcome admits no overhead at
|
||||
/// all — for a gated, attested, receipt-emitting system that is itself
|
||||
/// worth a second look, so the residual reports zero rather than hiding it.
|
||||
#[test]
|
||||
fn a_zero_residual_is_reported_not_hidden() {
|
||||
let t = OutcomeTrace::new(
|
||||
energy(500, Provenance::HwShunt, Coverage::Whole),
|
||||
true,
|
||||
vec![act(b"a1", 500, Provenance::HwShunt, Coverage::Whole)],
|
||||
);
|
||||
let r = t.reconcile().unwrap();
|
||||
assert_eq!(r.residual_uj, 0);
|
||||
assert_eq!(r.unattributed_ppm(500), Some(0));
|
||||
}
|
||||
|
||||
/// Energy spent on an outcome that did not complete is a different claim
|
||||
/// from energy spent on one that did; the anchor is carried, not dropped.
|
||||
#[test]
|
||||
fn the_outcome_anchor_survives_into_the_trace() {
|
||||
let t = OutcomeTrace::new(
|
||||
energy(900, Provenance::HwShunt, Coverage::Whole),
|
||||
false,
|
||||
vec![act(b"a1", 900, Provenance::HwShunt, Coverage::Whole)],
|
||||
);
|
||||
assert!(!t.completed, "the joules bought nothing, and the trace says so");
|
||||
assert!(t.reconcile().is_ok());
|
||||
}
|
||||
|
||||
/// A ratio against nothing is not a figure.
|
||||
#[test]
|
||||
fn no_ratio_against_a_zero_total() {
|
||||
let t = OutcomeTrace::new(
|
||||
energy(0, Provenance::Estimator, Coverage::Whole),
|
||||
true,
|
||||
vec![act(b"a1", 0, Provenance::Estimator, Coverage::Whole)],
|
||||
);
|
||||
assert_eq!(t.reconcile().unwrap().unattributed_ppm(0), None);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enforcement: a precondition, not a report
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Why an outcome receipt must not be sealed.
|
||||
///
|
||||
/// §5 of the spec says JCP "makes it a precondition, not a report". Until this
|
||||
/// existed, traceability was a report: [`crate::Efficiency::seal`] serialises
|
||||
/// and signs whatever it is handed, so an untraceable outcome, a
|
||||
/// double-counted activity or a physically impossible figure could all be
|
||||
/// sealed, signed, and indistinguishable afterwards from a sound one. A
|
||||
/// signature over an unchecked claim is the most durable way to publish it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum SealRefusal {
|
||||
/// The trace does not reconcile. Carries the reason.
|
||||
NotTraceable(TraceError),
|
||||
/// A window's figure is impossible for the part and duration — above the
|
||||
/// envelope, or below the floor a powered executing part must draw. The
|
||||
/// second is the shape a frozen counter takes, and §5's liveness rule
|
||||
/// exists because of it.
|
||||
PhysicallyRefuted { window_index: usize, detail: String },
|
||||
/// The receipt claims a hardware grade the evidence under it does not
|
||||
/// support. Refused rather than silently downgraded **in this direction**,
|
||||
/// because a caller asking to seal `HwShunt` over modelled windows has a
|
||||
/// bug, not a preference.
|
||||
GradeNotEarned { claimed: Provenance, floor: Provenance },
|
||||
}
|
||||
|
||||
impl core::fmt::Display for SealRefusal {
|
||||
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||
match self {
|
||||
SealRefusal::NotTraceable(e) => write!(f, "not traceable: {e}"),
|
||||
SealRefusal::PhysicallyRefuted { window_index, detail } => {
|
||||
write!(f, "window {window_index} is physically refuted: {detail}")
|
||||
}
|
||||
SealRefusal::GradeNotEarned { claimed, floor } => write!(
|
||||
f,
|
||||
"receipt claims {claimed:?} but the evidence under it supports only \
|
||||
{floor:?}; a signature would make the stronger claim permanent"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SealRefusal {}
|
||||
|
||||
/// What a trace establishes once every precondition has held.
|
||||
///
|
||||
/// Construction is the proof: there is no way to build one except through
|
||||
/// [`OutcomeTrace::admit`], so a function taking an `AdmittedTrace` knows the
|
||||
/// checks ran rather than hoping the caller remembered. The same reason
|
||||
/// `sandbox-attest` hands back a sealed run rather than a boolean.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AdmittedTrace {
|
||||
reconciliation: Reconciliation,
|
||||
/// The grade the evidence actually supports — **derived**, never the one
|
||||
/// the caller supplied. Written into the receipt in place of the claim, so
|
||||
/// aggregation cannot be the step at which a grade improves.
|
||||
earned_provenance: Provenance,
|
||||
earned_coverage: Coverage,
|
||||
}
|
||||
|
||||
impl AdmittedTrace {
|
||||
pub fn reconciliation(&self) -> &Reconciliation {
|
||||
&self.reconciliation
|
||||
}
|
||||
/// The provenance the receipt is entitled to carry.
|
||||
pub fn earned_provenance(&self) -> Provenance {
|
||||
self.earned_provenance
|
||||
}
|
||||
/// The coverage the receipt is entitled to carry.
|
||||
pub fn earned_coverage(&self) -> Coverage {
|
||||
self.earned_coverage
|
||||
}
|
||||
}
|
||||
|
||||
impl OutcomeTrace {
|
||||
/// Admit this trace for sealing, or refuse it.
|
||||
///
|
||||
/// Three gates, in the order a verifier would apply them:
|
||||
///
|
||||
/// 1. **It reconciles.** An unbound, duplicated or over-accounted trace is
|
||||
/// refused — the arithmetic has to close before anything else matters.
|
||||
/// 2. **No window is physically impossible.** Checked against the part's
|
||||
/// envelope when one is supplied. A figure above the burst ceiling or
|
||||
/// below the idle floor is refused regardless of what the counter said,
|
||||
/// because the counter is the thing in question.
|
||||
/// 3. **The grade is earned, not asserted.** The receipt may carry no
|
||||
/// stronger provenance than the floor across its parts. A caller asking
|
||||
/// for more is refused rather than quietly downgraded: asking to seal
|
||||
/// `HwShunt` over `Estimator` evidence is a bug in the caller, and
|
||||
/// silently writing the weaker grade would hide it.
|
||||
///
|
||||
/// The returned [`AdmittedTrace`] carries the *derived* grade, so the
|
||||
/// sealer writes what the evidence supports instead of what it was handed.
|
||||
pub fn admit(
|
||||
&self,
|
||||
windows: &[crate::dynamics::EnergyWindow],
|
||||
envelope: Option<&crate::dynamics::PowerEnvelope>,
|
||||
) -> Result<AdmittedTrace, SealRefusal> {
|
||||
use crate::dynamics::PhysicalVerdict;
|
||||
|
||||
let reconciliation = self.reconcile().map_err(SealRefusal::NotTraceable)?;
|
||||
|
||||
if let Some(env) = envelope {
|
||||
for (i, w) in windows.iter().enumerate() {
|
||||
match env.check(w) {
|
||||
PhysicalVerdict::RefutedAbove { claimed_uj, ceiling_uj } => {
|
||||
return Err(SealRefusal::PhysicallyRefuted {
|
||||
window_index: i,
|
||||
detail: format!(
|
||||
"{claimed_uj} uJ exceeds the {ceiling_uj} uJ the part \
|
||||
could draw in {} ns",
|
||||
w.duration_ns
|
||||
),
|
||||
})
|
||||
}
|
||||
PhysicalVerdict::RefutedBelow { claimed_uj, floor_uj } => {
|
||||
return Err(SealRefusal::PhysicallyRefuted {
|
||||
window_index: i,
|
||||
detail: format!(
|
||||
"{claimed_uj} uJ is below the {floor_uj} uJ a powered, \
|
||||
executing part must draw in {} ns — the shape a frozen \
|
||||
counter takes",
|
||||
w.duration_ns
|
||||
),
|
||||
})
|
||||
}
|
||||
// Inside the envelope, or unboundable. Neither is a
|
||||
// verification; both are permitted to seal, because the
|
||||
// envelope only ever refutes.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.total.provenance != reconciliation.provenance_floor
|
||||
&& stronger(self.total.provenance, reconciliation.provenance_floor)
|
||||
{
|
||||
return Err(SealRefusal::GradeNotEarned {
|
||||
claimed: self.total.provenance,
|
||||
floor: reconciliation.provenance_floor,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(AdmittedTrace {
|
||||
earned_provenance: reconciliation.provenance_floor,
|
||||
earned_coverage: reconciliation.coverage_floor,
|
||||
reconciliation,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `a` claims a stronger acquisition class than `b`.
|
||||
fn stronger(a: Provenance, b: Provenance) -> bool {
|
||||
fn rank(p: Provenance) -> u8 {
|
||||
match p {
|
||||
Provenance::HwShunt => 2,
|
||||
Provenance::ModelBased => 1,
|
||||
Provenance::Estimator => 0,
|
||||
}
|
||||
}
|
||||
rank(a) > rank(b)
|
||||
}
|
||||
184
jcp/jcp-rs/crates/jcp-efficiency/tests/energy_trace_pack.rs
Normal file
184
jcp/jcp-rs/crates/jcp-efficiency/tests/energy_trace_pack.rs
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
//! Replay the published `energy_trace/1` pack from disk.
|
||||
//!
|
||||
//! SPEC §5.3–5.6 states rules, and `gen_trace_vectors` emits the vectors. This
|
||||
//! reads those bytes back — the same bytes a third party would — and checks the
|
||||
//! reference implementation reproduces every recorded verdict. A pack only its
|
||||
//! own generator can read is not a conformance pack.
|
||||
//!
|
||||
//! The refusals matter more than the acceptances. An implementation that admits
|
||||
//! an unbound outcome, a double-counted activity or a frozen counter has failed
|
||||
//! in the direction that flatters, which is the direction nobody notices.
|
||||
|
||||
use jcp_efficiency::dynamics::{
|
||||
DrawShape, EnergyWindow, PhysicalVerdict, PowerEnvelope,
|
||||
};
|
||||
use jcp_efficiency::trace::{OutcomeTrace, TraceError};
|
||||
use serde_json::Value;
|
||||
|
||||
fn pack() -> Value {
|
||||
let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../../conformance/v1/energy_trace.json");
|
||||
// Assert, not skip. A `return` here would make this test pass green in the
|
||||
// one case it exists to catch — the pack going missing or moving.
|
||||
assert!(
|
||||
p.exists(),
|
||||
"conformance pack missing at {} — it is what this test replays",
|
||||
p.display()
|
||||
);
|
||||
let bytes = std::fs::read(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display()));
|
||||
serde_json::from_slice(&bytes).expect("pack parses")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pack_declares_its_profile_and_spec() {
|
||||
let v = pack();
|
||||
assert_eq!(v["profile"], "energy_trace/1");
|
||||
assert!(v["spec"].as_str().unwrap().contains("5.3-5.6"));
|
||||
}
|
||||
|
||||
/// Every positive conservation vector reproduces its recorded reconciliation.
|
||||
#[test]
|
||||
fn conservation_positives_reproduce_their_recorded_verdicts() {
|
||||
let v = pack();
|
||||
let cases = v["conservation"]["positive"].as_array().expect("positives");
|
||||
assert!(cases.len() >= 2, "expected the published positive cases");
|
||||
for c in cases {
|
||||
let note = c["note"].as_str().unwrap_or("?");
|
||||
let t: OutcomeTrace =
|
||||
serde_json::from_value(c["input"].clone()).unwrap_or_else(|e| panic!("{note}: {e}"));
|
||||
let r = t.reconcile().unwrap_or_else(|e| panic!("{note}: must reconcile: {e}"));
|
||||
let exp = &c["expected"];
|
||||
assert_eq!(
|
||||
serde_json::to_value(r.accounted_uj).unwrap(), exp["accounted_uj"],
|
||||
"{note}: accounted"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(r.residual_uj).unwrap(), exp["residual_uj"],
|
||||
"{note}: the residual is the whole point"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(r.provenance_floor).unwrap(), exp["provenance_floor"],
|
||||
"{note}: provenance takes the floor"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(r.coverage_floor).unwrap(), exp["coverage_floor"],
|
||||
"{note}: coverage takes the floor"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every refusal is actually refused, and for the recorded reason. An
|
||||
/// implementation that accepted these would be wrong in the flattering
|
||||
/// direction.
|
||||
#[test]
|
||||
fn conservation_negatives_are_refused_for_the_recorded_reason() {
|
||||
let v = pack();
|
||||
let cases = v["conservation"]["negative"].as_array().expect("negatives");
|
||||
assert!(cases.len() >= 3, "expected the three published refusals");
|
||||
for c in cases {
|
||||
let note = c["note"].as_str().unwrap_or("?");
|
||||
let t: OutcomeTrace =
|
||||
serde_json::from_value(c["input"].clone()).unwrap_or_else(|e| panic!("{note}: {e}"));
|
||||
let err = t.reconcile().expect_err(&format!("{note}: must be refused"));
|
||||
let want = c["expected"]["error"].as_str().unwrap();
|
||||
let got = match err {
|
||||
TraceError::Unbound => "Unbound",
|
||||
TraceError::DuplicateActivity { .. } => "DuplicateActivity",
|
||||
TraceError::OverAccounted { .. } => "OverAccounted",
|
||||
};
|
||||
assert_eq!(got, want, "{note}: refused, but for the wrong reason");
|
||||
}
|
||||
}
|
||||
|
||||
/// The physical bound refutes above the envelope and below the floor — the
|
||||
/// second being the shape a frozen counter takes.
|
||||
#[test]
|
||||
fn physical_bounds_reproduce_their_recorded_verdicts() {
|
||||
let v = pack();
|
||||
let env: PowerEnvelope =
|
||||
serde_json::from_value(v["physical"]["envelope"].clone()).expect("envelope");
|
||||
let cases = v["physical"]["cases"].as_array().expect("cases");
|
||||
assert!(cases.len() >= 4);
|
||||
let mut saw_below = false;
|
||||
for c in cases {
|
||||
let note = c["note"].as_str().unwrap_or("?");
|
||||
let w: EnergyWindow =
|
||||
serde_json::from_value(c["input"].clone()).unwrap_or_else(|e| panic!("{note}: {e}"));
|
||||
let got = match env.check(&w) {
|
||||
PhysicalVerdict::NotRefuted { .. } => "NotRefuted",
|
||||
PhysicalVerdict::RefutedAbove { .. } => "RefutedAbove",
|
||||
PhysicalVerdict::RefutedBelow { claimed_uj, floor_uj } => {
|
||||
saw_below = true;
|
||||
if let Some(f) = c["expected"]["floor_uj"].as_u64() {
|
||||
assert_eq!(floor_uj, f, "{note}: floor");
|
||||
assert_eq!(claimed_uj, 0, "{note}: a frozen counter claims nothing");
|
||||
}
|
||||
"RefutedBelow"
|
||||
}
|
||||
PhysicalVerdict::Unboundable => "Unboundable",
|
||||
};
|
||||
assert_eq!(got, c["expected"]["verdict"].as_str().unwrap(), "{note}");
|
||||
}
|
||||
assert!(saw_below, "the frozen-counter refutation must be in the pack");
|
||||
}
|
||||
|
||||
/// Character survives a change of scale, and a padded total does not read as
|
||||
/// sustained. These two together are what the shape check is for.
|
||||
#[test]
|
||||
fn draw_character_reproduces_its_recorded_verdicts() {
|
||||
let v = pack();
|
||||
let floor = v["shape"]["idle_floor_uj"].as_u64().expect("idle floor");
|
||||
let cases = v["shape"]["cases"].as_array().expect("cases");
|
||||
assert!(cases.len() >= 6);
|
||||
let mut saw_inconsistent = false;
|
||||
for c in cases {
|
||||
let note = c["note"].as_str().unwrap_or("?");
|
||||
let declared: DrawShape =
|
||||
serde_json::from_value(c["input"]["declared"].clone()).expect("declared");
|
||||
let windows: Vec<EnergyWindow> =
|
||||
serde_json::from_value(c["input"]["windows"].clone()).expect("windows");
|
||||
let got = jcp_efficiency::dynamics::check_shape(declared, &windows, floor);
|
||||
let want = c["expected"]["verdict"].as_str().unwrap();
|
||||
let name = match &got {
|
||||
jcp_efficiency::dynamics::ShapeVerdict::Consistent { shape } => {
|
||||
if let Some(s) = c["expected"]["shape"].as_str() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(shape).unwrap().as_str().unwrap(), s,
|
||||
"{note}: shape"
|
||||
);
|
||||
}
|
||||
"Consistent"
|
||||
}
|
||||
jcp_efficiency::dynamics::ShapeVerdict::Inconsistent { .. } => {
|
||||
saw_inconsistent = true;
|
||||
"Inconsistent"
|
||||
}
|
||||
jcp_efficiency::dynamics::ShapeVerdict::Unresolvable { windows } => {
|
||||
if let Some(n) = c["expected"]["windows"].as_u64() {
|
||||
assert_eq!(*windows as u64, n, "{note}: window count");
|
||||
}
|
||||
"Unresolvable"
|
||||
}
|
||||
};
|
||||
assert_eq!(name, want, "{note}");
|
||||
}
|
||||
assert!(saw_inconsistent, "the padding refutation must be in the pack");
|
||||
}
|
||||
|
||||
/// Anti-vacuity across the whole pack: a wrong path, a renamed key or an empty
|
||||
/// array would otherwise let every test above pass having checked nothing.
|
||||
#[test]
|
||||
fn the_pack_carries_every_section_it_claims() {
|
||||
let v = pack();
|
||||
for section in ["conservation", "budget", "physical", "shape", "dynamism"] {
|
||||
assert!(!v[section].is_null(), "section {section} missing from the pack");
|
||||
}
|
||||
let total: usize = v["conservation"]["positive"].as_array().map_or(0, Vec::len)
|
||||
+ v["conservation"]["negative"].as_array().map_or(0, Vec::len)
|
||||
+ v["physical"]["cases"].as_array().map_or(0, Vec::len)
|
||||
+ v["shape"]["cases"].as_array().map_or(0, Vec::len)
|
||||
+ v["dynamism"]["cases"].as_array().map_or(0, Vec::len)
|
||||
+ v["budget"]["positive"].as_array().map_or(0, Vec::len)
|
||||
+ v["budget"]["negative"].as_array().map_or(0, Vec::len);
|
||||
assert!(total >= 20, "expected the full published pack, replayed {total} cases");
|
||||
}
|
||||
Loading…
Reference in a new issue