open-standards/joule-ui/joule-ui-rs/crates/joule-ui-interaction/examples/metered_surface.rs
Transaction Science 7609bfeaef joule-ui: a meter is an estimator until it opts in, and a charge carries its grade
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]>
2026-09-30 20:10:14 -04:00

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, &registry);
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.");
}