Three places where joule-ui let a figure be read as more than it was. The `Meter` trait defaulted `provenance()` to `HwShunt`. Any meter that did not think to override it, a deterministic stub included, had its figures sealed as hardware readings with `measured_uj` set. The default is now `Estimator`: a meter opts in to `ModelBased` for a model and to `HwShunt` only for readings of a hardware counter it has seen to be live. In this workspace the only meters that are hardware readings are the two `SpanMeter` adapters (the examples' and joule-ui-serve's), and both already return their span's own grade, so nothing that reads hardware changes. The stubs in the joule-ui-interaction, joule-ui-serve and joule-ui-mcp tests and the `metered_surface` example now come out `Estimator`, as they should. The conformance pack's `StepMeter` now declares `HwShunt` explicitly. C2 and C4 were sealed with it standing in for a hardware counter, so they carry `measured_uj` and exercise that path of the signed core; the explicit override, documented as a fixture grade that attests nothing (like the pack's steward key), is what keeps them byte-identical. SPEC §5 and §8 and the conformance README say so. `Authorizer::charge(action, measured_uj: u64)` handed the authorizer a bare number, so an authorizer that keeps its own record of the debit (a JCP grant) could not tell an estimate from a reading; the `jcp_metered` example worked around it by reading the provenance back off the meter. `charge` now takes `&Energy`, the exact figure the interaction is sealed with, provenance included. Every implementor and caller in the workspace is updated, and `jcp_metered`'s authorizer maps the charged figure's own provenance onto `jcp_schema::Energy` instead of holding an `Rc` to the meter. `run_interaction` turned a missing sample into `Energy::estimator(0)`. Work that ran was sealed at 0 µJ and charged nothing, so on a host with no reading a budget never bound, which SPEC §6 says is exactly what the budget is for. Now, when either sample is `None`, the handler's own wall-clock time is charged at the meter's `estimator_milliwatts()` (default `DEFAULT_ESTIMATOR_MILLIWATTS` = 20 W, the Sandbox estimator's default), rounded up and never 0 (`estimate_uj`), graded `Estimator`. A denial, whose handler never ran, still records 0 µJ. The signed core and its profile tag are unchanged. SPEC: §5 states the default and carries the shared vendor-counter liveness MUST verbatim (HwShunt only while the counter is demonstrably live for the domain it claims; for IOReport the CPU rail advancing in >= 5 intervals, in >= 4 of every 5, over >= 300 ms while the probe burns CPU; a frozen or lumped counter never yields a hardware-graded figure; no claim of a domain the counter does not read). The "real RAPL/IOReport" wording at §5 and "the hardware Meter" at §9 now describe the reference meter as a hardware reading while a vendor counter is live and its labelled estimate otherwise. §6.2 specifies the no-reading estimate and forbids recording work that ran at 0 µJ; §6.3 becomes "charge the cost, with its provenance". Tests (joule-ui-interaction, new): `a_meter_is_an_estimator_unless_it_opts_in`, `work_that_ran_without_a_reading_is_estimated_never_zero`, `the_estimate_is_wall_time_at_the_declared_power_rounded_up`, `a_budget_binds_on_a_host_with_no_reading` (a 1 J budget stops ~20 ms interactions at 20 W within three, every debit `Estimator`), and `charge_is_handed_the_sealed_figure_and_its_provenance`. All 31 tests across joule-ui-interaction, -conformance (incl. the published-pack tests), -serve and -mcp pass; the workspace builds with all targets; the three examples run (`jcp_metered` here: five ~3 J `Estimator` interactions spend the 15 J grant and the real JCP runtime denies the sixth). `gen_vectors` regenerated into a scratch directory is byte-identical to `conformance/v1/` (all five files, MANIFEST included). Falsified, each restored and re-run green: - default back to `HwShunt` with the fixture override removed: the vectors regenerate identical (under the old default they rested on the default), and `a_meter_is_an_estimator_unless_it_opts_in`, `charge_is_handed_the_sealed_figure_and_its_provenance`, joule-ui-serve's `firing_an_action_meters_seals_and_returns_the_next_surface` and joule-ui-mcp's `an_mcp_app_action_is_metered_sealed_and_attributed` fail; - new default, override removed: `c2_interaction_is_reproduced_from_the_pack` and `c2_interaction_is_metered_and_allowed` fail, and the regenerated c2, c4 and MANIFEST differ (`"measured_uj": null, "provenance": "Estimator"`), so the override is what keeps the pack; - `charge` handed `Energy::measured(uj)` regardless of grade: `charge_is_handed_the_sealed_figure_and_its_provenance` and `a_budget_binds_on_a_host_with_no_reading` fail; - the no-reading arm back to `Energy::estimator(0)`: `work_that_ran_without_a_reading_is_estimated_never_zero` and `a_budget_binds_on_a_host_with_no_reading` fail. Clippy --no-deps on the changed crates adds nothing; the remaining warnings are in untouched code (joule-ui-core's tests, the conformance pack test). Not done here: joule-ui's energy figure has no coverage field, and both `SpanMeter` adapters grade a span whose only records are hardware GPU records (NVML without a CPU probe) `HwShunt`, which claims the whole interaction. §5's coverage clause forbids that; fixing it needs either a coverage field in the signed core (a new profile tag) or a downgrade rule, which is a decision, not a cleanup. Co-Authored-By: Claude Opus 5.5 <[email protected]>
123 lines
4.6 KiB
Rust
123 lines
4.6 KiB
Rust
//! The full joule-ui loop in one file: declare a typed surface with
|
|
//! `joule-ui-core`, validate it against a registry, then fire an action on it
|
|
//! through `joule-ui-interaction` so the invocation is **metered, budget-gated,
|
|
//! and sealed into a hash-chained receipt**. The thing Gradio/Streamlit/A2UI
|
|
//! cannot show: a UI whose every interaction is an auditable, energy-accounted
|
|
//! transaction.
|
|
//!
|
|
//! ```bash
|
|
//! cargo run -p joule-ui-interaction --example metered_surface
|
|
//! ```
|
|
|
|
use joule_ui_core::{
|
|
AllowedChildren, PropSchema, PropType, PropValue, Registry, Widget, WidgetSchema, validate,
|
|
};
|
|
use joule_ui_interaction::{
|
|
Authorizer, ContentId, Energy, InteractionLog, Meter, run_interaction, verify_chain,
|
|
};
|
|
use std::cell::Cell;
|
|
|
|
/// A wall-clock-free deterministic meter standing in for the Sandbox standard's
|
|
/// `PowerMeter`. The point of the example is the *protocol*. It declares no
|
|
/// hardware grade, so its figures are sealed as `Estimator`: a hardware
|
|
/// figure is the Sandbox edge's to supply, and only from a live counter.
|
|
struct DemoMeter(Cell<u64>);
|
|
impl Meter for DemoMeter {
|
|
fn sample_uj(&self) -> Option<u64> {
|
|
let v = self.0.get();
|
|
self.0.set(v + 4200); // each sample advances 4.2 mJ
|
|
Some(v)
|
|
}
|
|
}
|
|
|
|
/// A joule budget — the JCP edge in miniature.
|
|
struct Budget {
|
|
remaining: u64,
|
|
}
|
|
impl Authorizer for Budget {
|
|
fn authorize(&mut self, _action: &str) -> Result<u64, String> {
|
|
if self.remaining > 0 {
|
|
Ok(self.remaining)
|
|
} else {
|
|
Err("budget-exceeded".into())
|
|
}
|
|
}
|
|
fn charge(&mut self, _action: &str, cost: &Energy) -> u64 {
|
|
self.remaining = self.remaining.saturating_sub(cost.uj());
|
|
self.remaining
|
|
}
|
|
fn grant_id(&self) -> Option<String> {
|
|
Some("did:web:demo#grant-1".into())
|
|
}
|
|
}
|
|
|
|
fn main() {
|
|
// 1. Declare the surface: a Form holding a Slider whose change fires "apply".
|
|
let mut registry = Registry::new();
|
|
registry.add(
|
|
WidgetSchema::new("Form", "a vertical form").children(AllowedChildren::Any),
|
|
);
|
|
registry.add(
|
|
WidgetSchema::new("Slider", "a numeric slider")
|
|
.prop("min", PropSchema::required(PropType::Number))
|
|
.prop("max", PropSchema::required(PropType::Number))
|
|
.prop("on_change", PropSchema::required(PropType::Action))
|
|
.children(AllowedChildren::None),
|
|
);
|
|
registry.add(WidgetSchema::new("Image", "an image output").children(AllowedChildren::None));
|
|
|
|
let surface = Widget::leaf("Form").child(
|
|
Widget::leaf("Slider")
|
|
.prop("min", 1.0)
|
|
.prop("max", 10.0)
|
|
.prop(
|
|
"on_change",
|
|
PropValue::Action { id: "apply".into(), payload: None },
|
|
),
|
|
);
|
|
|
|
let errors = validate(&surface, ®istry);
|
|
assert!(errors.is_empty(), "surface validates: {errors:?}");
|
|
let surface_id = ContentId::of_bytes(serde_json::to_string(&surface).unwrap().as_bytes());
|
|
println!("surface declared + validated; id = {}", surface_id.0);
|
|
|
|
// 2. Fire three "apply" interactions (the user dragging the slider), each
|
|
// metered, budget-gated, and sealed into the chain.
|
|
let signing = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
|
|
let mut log = InteractionLog::new(signing);
|
|
let mut budget = Budget { remaining: 10_000 };
|
|
|
|
for intensity in [3u8, 6, 9] {
|
|
let meter = DemoMeter(Cell::new(0));
|
|
let inputs = format!(r#"{{"intensity":{intensity}}}"#);
|
|
let interaction = run_interaction(
|
|
&surface_id,
|
|
"apply",
|
|
"did:key:z6MkDemoUser",
|
|
inputs.as_bytes(),
|
|
&meter,
|
|
Some(&mut budget),
|
|
|| {
|
|
// The handler: produce the output widget (a processed Image).
|
|
let out = Widget::leaf("Image").prop("intensity", intensity as f64);
|
|
serde_json::to_vec(&out).unwrap()
|
|
},
|
|
);
|
|
println!(
|
|
" apply(intensity={intensity}): {} · {} µJ [{}] · budget {} µJ left",
|
|
interaction.verdict.tag(),
|
|
interaction.energy.uj(),
|
|
interaction.energy.provenance.label(),
|
|
interaction.budget_remaining_uj.unwrap_or(0),
|
|
);
|
|
log.record(interaction);
|
|
}
|
|
|
|
// 3. The session is a verifiable ledger.
|
|
let verified = verify_chain(log.entries()).expect("chain verifies");
|
|
println!(
|
|
"\nsealed interaction chain: {verified} links verified, {} µJ total",
|
|
log.total_uj()
|
|
);
|
|
println!("every interaction is signed, hash-chained, and energy-accounted — by construction.");
|
|
}
|