mesh: a grant is decided by JCP's runtime, so expiry, caveats, the model and spend bind #294
10 changed files with 497 additions and 43 deletions
1
joulehook/joulehook-rs/Cargo.lock
generated
1
joulehook/joulehook-rs/Cargo.lock
generated
|
|
@ -537,6 +537,7 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"ed25519-dalek",
|
||||
"jcp-grant",
|
||||
"jcp-runtime",
|
||||
"jcp-schema",
|
||||
"mesh-engine",
|
||||
"mesh-meter",
|
||||
|
|
|
|||
|
|
@ -20,17 +20,20 @@ inflate: the joule.
|
|||
## The shape
|
||||
|
||||
```
|
||||
ServeRequest ──▶ authorise (JCP grant) ──▶ route (cheapest-sufficient)
|
||||
│
|
||||
▼
|
||||
meter ◀── serve (engine) ──▶ tokens
|
||||
│
|
||||
▼
|
||||
ServeRequest ──▶ route (cheapest-sufficient) ──▶ authorise (JCP grant)
|
||||
│
|
||||
▼
|
||||
meter + debit ◀── serve (engine) ──▶ tokens
|
||||
│
|
||||
▼
|
||||
sealed JCR-1 receipt: µJ / accepted token
|
||||
```
|
||||
|
||||
Four roles — **caller**, **router**, **engine**, **steward** — and four phases
|
||||
per serve: authorise, route, meter, seal. See [`SPEC.md`](SPEC.md) §2.
|
||||
per serve: route, authorise, meter, seal. The grant is decided by JCP's own
|
||||
runtime before any work runs, so its expiry, caveats and remaining budget bind
|
||||
here as at any JCP enforcement point, and the metered joules are debited
|
||||
against it afterwards. See [`SPEC.md`](SPEC.md) §2.
|
||||
|
||||
## The reference workspace
|
||||
|
||||
|
|
@ -44,7 +47,7 @@ per serve: authorise, route, meter, seal. See [`SPEC.md`](SPEC.md) §2.
|
|||
| `mesh-engine-gguf` | a **real** backend (behind `gguf`): wraps the family's pure-Rust GGUF/Metal inference substrate so the mesh routes and meters actual model tokens |
|
||||
| `mesh-meter` | the meter window; an always-available estimator + an optional bridge to the family hardware meter (RAPL / NVML / Apple) behind `hw-meter`, and `ProcessMeter`: this process's own CPU and GPU energy for an engine that runs in process, measured when a span covers the whole request |
|
||||
| `mesh-receipt` | the `mesh/1` receipt, JCR-1 sealing (det-CBOR + COSE_Sign1), and a hash-chained log |
|
||||
| `mesh-core` | the orchestration that ties authorise → route → meter → seal together |
|
||||
| `mesh-core` | the orchestration that ties route → authorise → meter → seal together |
|
||||
| `mesh-conformance` | the deterministic reference scenario, its checks, and the `gen-vectors` bin |
|
||||
|
||||
Mesh **rides on** [JCP](../jcp/) (a serve request is authorised by a JCP grant;
|
||||
|
|
|
|||
48
mesh/SPEC.md
48
mesh/SPEC.md
|
|
@ -46,16 +46,31 @@ A Mesh deployment has four roles:
|
|||
|
||||
One serve proceeds in four phases, in order:
|
||||
|
||||
1. **Authorise.** If the request rides on a JCP `SignedGrant`, the steward MUST
|
||||
verify the issuer signature, confirm the capability permits the action
|
||||
`infer` on the request's content id, and confirm the grant's joule budget is
|
||||
≥ the request's joule ceiling. A failure at any step aborts the serve before
|
||||
any work is done.
|
||||
2. **Route.** The router selects the cheapest admissible backend (§4). If none is
|
||||
admissible, the serve fails with the router's recorded reason.
|
||||
1. **Route.** The router selects the cheapest admissible backend (§4). If none is
|
||||
admissible, the serve fails with the router's recorded reason. When the
|
||||
request rides on a JCP `SignedGrant` carrying a `ModelIn` caveat (JCP §4.9),
|
||||
a backend whose model the grant does not permit is inadmissible, and so is a
|
||||
backend that names no model.
|
||||
2. **Authorise.** If the request rides on a grant, the steward is a JCP
|
||||
enforcement point and MUST decide the grant in JCP's two-phase metered path
|
||||
(JCP §7, §4.9) before any work is done. The frame it decides over names the
|
||||
action `infer` on the request's content id, the request's `prompt_hash` as
|
||||
payload, and, as `model`, the content address of the weights the chosen
|
||||
backend runs, when the backend names one. Every check of that decision
|
||||
applies: signature, expiry, revocation, and every cost-independent caveat.
|
||||
The steward MUST also refuse the serve when what the grant has left (its
|
||||
budget less what earlier serves on it were debited) is below the request's
|
||||
joule ceiling. A failure aborts the serve before any work is done.
|
||||
3. **Meter.** The steward opens an energy window, calls the engine, and closes
|
||||
the window. Energy MUST be metered **around** the engine; an engine MUST NOT
|
||||
self-report energy.
|
||||
self-report energy. With a grant, the steward then debits the metered joules
|
||||
against it (JCP's second phase), where a cost-dependent caveat such as
|
||||
`MaxCostUj` binds. The work has run, so the joules are debited even when the
|
||||
debit breaches a caveat, and the breach is recorded on the receipt
|
||||
(`grant_breach`, §6.1). An engine that names its weights before serving MUST
|
||||
report the same weights after; the steward MUST NOT seal a serve whose
|
||||
engine reports others, since they are not the weights the grant was decided
|
||||
over.
|
||||
4. **Seal.** The steward builds the receipt (§6), stamps it into its hash-chained
|
||||
log, and seals it as a JCR-1 envelope.
|
||||
|
||||
|
|
@ -333,6 +348,7 @@ and nulls; booleans are encoded as `0|1` uints and absent options dropped.
|
|||
| `uj_per_decision` | **the wedge metric** for a decision — `energy.uj() / questions_answered` |
|
||||
| `uj_per_option_scored` | the finer decision figure — `energy.uj() / options_scored` |
|
||||
| `energy_provenance` | `HwShunt | ModelBased | Estimator` |
|
||||
| `grant_breach` | the JCP denial label when debiting the metered joules breached the grant, e.g. `caveat-unsatisfied` for a per-call cost caveat (OPTIONAL — §2; absent means the debit was within the grant, or the serve rode on none) |
|
||||
| `sequence`, `prev` | hash-chain position and prior envelope's content id |
|
||||
| `nonce`, `steward` | per-receipt nonce and the sealing identity (COSE `kid`) |
|
||||
|
||||
|
|
@ -356,8 +372,11 @@ any envelope breaks verification of that receipt and the chain link from the nex
|
|||
|
||||
A conforming implementation MUST:
|
||||
|
||||
1. Reject a serve whose grant fails verification, does not permit `infer` on the
|
||||
request, or carries a budget below the request's joule ceiling (§2.1).
|
||||
1. Decide a serve's grant as a JCP enforcement point before any work (§2): reject
|
||||
one that fails verification, has expired or been revoked, does not permit
|
||||
`infer` on the request, carries a cost-independent caveat that does not hold,
|
||||
or has less left than the request's joule ceiling; debit the metered joules
|
||||
against it after the work, recording a breach on the receipt.
|
||||
2. Choose the cheapest admissible backend and record every candidate weighed
|
||||
(§4).
|
||||
3. Meter energy around the engine and tag provenance honestly, enforcing the
|
||||
|
|
@ -383,9 +402,16 @@ Mesh composes proven primitives: Ed25519 signatures, BLAKE3 content addressing,
|
|||
RFC 9052 COSE_Sign1, and the JCP capability model. It introduces no new
|
||||
cryptography. Two properties are correctness, not style:
|
||||
|
||||
- **Authorisation happens outside the model.** The grant check (§2.1) is enforced
|
||||
- **Authorisation happens outside the model.** The grant check (§2) is enforced
|
||||
by the steward before any inference runs; a prompt cannot grant itself budget
|
||||
or capability.
|
||||
- **The caller is not authenticated.** Mesh honours a grant as a bearer token
|
||||
of its subject: whoever presents it is decided as the subject. A grant that
|
||||
must not be usable by anyone who obtains it needs a binding Mesh can check
|
||||
without an identity, such as `PayloadIs` or a short `NotAfter`.
|
||||
- **A grant's spend is per steward.** The debit ledger is the steward's own, in
|
||||
memory. A grant presented to several stewards, or to one that restarts, is
|
||||
spent separately at each (JCP §4.5, §4.11).
|
||||
- **Honesty is enforced at seal time.** A receipt cannot claim a measurement it
|
||||
does not have (§3.3). An operator without a hardware meter produces honest
|
||||
`Estimator` receipts.
|
||||
|
|
|
|||
10
mesh/mesh-rs/Cargo.lock
generated
10
mesh/mesh-rs/Cargo.lock
generated
|
|
@ -432,6 +432,15 @@ dependencies = [
|
|||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jcp-runtime"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"jcp-grant",
|
||||
"jcp-schema",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jcp-schema"
|
||||
version = "0.1.0"
|
||||
|
|
@ -560,6 +569,7 @@ dependencies = [
|
|||
"ed25519-dalek",
|
||||
"hex",
|
||||
"jcp-grant",
|
||||
"jcp-runtime",
|
||||
"jcp-schema",
|
||||
"jouleclaw-backend-apple",
|
||||
"mesh-engine",
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ mesh-core = { path = "crates/mesh-core" }
|
|||
# Family standards Mesh rides on (cross-workspace path deps).
|
||||
jcp-schema = { path = "../../jcp/jcp-rs/crates/jcp-schema" }
|
||||
jcp-grant = { path = "../../jcp/jcp-rs/crates/jcp-grant" }
|
||||
jcp-runtime = { path = "../../jcp/jcp-rs/crates/jcp-runtime" }
|
||||
# Optional: the family's honest energy meter (RAPL / NVML / Apple), behind `hw-meter`.
|
||||
jouleclaw-energy = { path = "../../jouleclaw/jouleclaw-rs/crates/jouleclaw-energy" }
|
||||
jouleclaw-backend-apple = { path = "../../jouleclaw/jouleclaw-rs/crates/jouleclaw-backend-apple" }
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ mesh-engine = { workspace = true }
|
|||
mesh-router = { workspace = true }
|
||||
jcp-schema = { workspace = true }
|
||||
jcp-grant = { workspace = true }
|
||||
jcp-runtime = { workspace = true }
|
||||
ed25519-dalek = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
|
|
|
|||
|
|
@ -2,12 +2,17 @@
|
|||
//!
|
||||
//! [`Mesh`] ties the four moving parts together for one metered inference:
|
||||
//!
|
||||
//! 1. **Authorise** — if the request rides on a JCP [`SignedGrant`], verify the
|
||||
//! issuer signature, confirm the capability permits `infer` on this exact
|
||||
//! request, and confirm the grant's joule budget covers the request ceiling.
|
||||
//! 2. **Route** — pick the cheapest-sufficient backend (`mesh-router`).
|
||||
//! 1. **Route** — pick the cheapest-sufficient backend (`mesh-router`). With a
|
||||
//! JCP [`SignedGrant`], a backend whose model the grant does not permit is
|
||||
//! inadmissible.
|
||||
//! 2. **Authorise** — decide the grant in JCP's two-phase metered path
|
||||
//! (`jcp-runtime`), over a frame naming `infer` on this exact request and
|
||||
//! the chosen backend's model: signature, expiry, revocation, every
|
||||
//! cost-independent caveat, and whether what the grant has left covers the
|
||||
//! request ceiling. All of it before any work runs.
|
||||
//! 3. **Meter** — open an energy window around the engine's `serve` call
|
||||
//! (`mesh-meter`), so the energy figure is measured, not self-reported.
|
||||
//! (`mesh-meter`), so the energy figure is measured, not self-reported, and
|
||||
//! debit the grant with it, where a per-call cost caveat binds.
|
||||
//! 4. **Seal** — build the [`MeshReceipt`], append it to the steward's
|
||||
//! hash-chained log, and return the sealed JCR-1 envelope.
|
||||
//!
|
||||
|
|
@ -27,7 +32,8 @@ compile_error!("a hw-meter-* feature must enable mesh-core's `hw-meter` feature"
|
|||
use std::collections::HashMap;
|
||||
|
||||
use ed25519_dalek::SigningKey;
|
||||
use jcp_schema::{ContentId, SignedGrant};
|
||||
use jcp_runtime::Runtime;
|
||||
use jcp_schema::{Caveat, ContentId, DenyReason, Frame, FrameKind, SignedGrant, Trust, Verdict};
|
||||
use mesh_engine::ServingEngine;
|
||||
use mesh_meter::{Meter, Session};
|
||||
use mesh_receipt::{MeshReceipt, ReceiptError, ReceiptLog};
|
||||
|
|
@ -49,9 +55,20 @@ pub enum MeshError {
|
|||
/// The grant does not authorise inference on this request.
|
||||
#[error("grant does not permit `{INFER_ACTION}` on this request")]
|
||||
Unauthorized,
|
||||
/// The grant's budget is below the request's joule ceiling.
|
||||
#[error("grant budget {budget_uj} µJ below request ceiling {ceiling_uj} µJ")]
|
||||
/// What the grant has left at this steward is below the request's joule
|
||||
/// ceiling. `budget_uj` is the remainder: the grant's budget less what
|
||||
/// earlier serves riding on it were debited.
|
||||
#[error("grant has {budget_uj} µJ left, below request ceiling {ceiling_uj} µJ")]
|
||||
BudgetTooLow { budget_uj: u64, ceiling_uj: u64 },
|
||||
/// The JCP decision refused the grant for a reason the variants above do
|
||||
/// not name: it expired, was revoked, carries a caveat that does not hold,
|
||||
/// or carries a flow ceiling that Mesh computes no provenance for.
|
||||
#[error("grant denied: {}", .0.label())]
|
||||
GrantDenied(DenyReason),
|
||||
/// The engine reported running other weights than the ones it named
|
||||
/// before serving, which are the ones the grant was decided over.
|
||||
#[error("backend `{backend}` named model {named} before serving but reported {reported}")]
|
||||
ModelMismatch { backend: String, named: String, reported: String },
|
||||
/// The chosen backend has no registered engine (internal invariant).
|
||||
#[error("internal: no engine registered for backend `{0}`")]
|
||||
NoEngine(String),
|
||||
|
|
@ -93,6 +110,14 @@ pub struct Mesh<'m> {
|
|||
engines: HashMap<String, Box<dyn ServingEngine>>,
|
||||
meter: &'m dyn Meter,
|
||||
log: ReceiptLog,
|
||||
/// The steward's JCP enforcement point. Every grant a serve rides on is
|
||||
/// decided here, in JCP's two-phase metered path: authorised before any
|
||||
/// work, then debited with the metered joules. Expiry, revocation, every
|
||||
/// caveat and the grant's cumulative spend therefore bind at Mesh as they
|
||||
/// do at any other JCP enforcement point.
|
||||
grants: Runtime,
|
||||
/// Unix seconds, for expiry and time caveats.
|
||||
clock: fn() -> u64,
|
||||
}
|
||||
|
||||
impl<'m> Mesh<'m> {
|
||||
|
|
@ -104,9 +129,23 @@ impl<'m> Mesh<'m> {
|
|||
engines: HashMap::new(),
|
||||
meter,
|
||||
log: ReceiptLog::new(steward_sk, steward),
|
||||
grants: Runtime::new(),
|
||||
clock: system_clock,
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide grants against `clock` (unix seconds) instead of the system
|
||||
/// clock.
|
||||
pub fn with_clock(mut self, clock: fn() -> u64) -> Self {
|
||||
self.clock = clock;
|
||||
self
|
||||
}
|
||||
|
||||
/// The microjoules debited so far against a grant at this steward.
|
||||
pub fn spent(&self, grant_id: &ContentId) -> u64 {
|
||||
self.grants.spent(grant_id)
|
||||
}
|
||||
|
||||
/// Register a backend: its routing spec and the engine that serves it.
|
||||
pub fn register(&mut self, spec: BackendSpec, engine: Box<dyn ServingEngine>) -> &mut Self {
|
||||
self.engines.insert(spec.id.0.clone(), engine);
|
||||
|
|
@ -129,23 +168,43 @@ impl<'m> Mesh<'m> {
|
|||
self.log.head()
|
||||
}
|
||||
|
||||
/// Authorise a request against an optional JCP grant. Returns the grant id
|
||||
/// to record on the receipt (or `None` for an ungranted request).
|
||||
/// Authorise a request against an optional JCP grant, over the frame that
|
||||
/// names the model the chosen backend runs. Returns the grant id to record
|
||||
/// on the receipt (or `None` for an ungranted request). Debits nothing.
|
||||
fn authorize(
|
||||
&self,
|
||||
&mut self,
|
||||
req: &ServeRequest,
|
||||
grant: Option<&SignedGrant>,
|
||||
model: Option<ContentId>,
|
||||
) -> Result<Option<ContentId>, MeshError> {
|
||||
let Some(sg) = grant else {
|
||||
return Ok(None);
|
||||
};
|
||||
jcp_grant::verify(sg).map_err(|e| MeshError::GrantInvalid(e.to_string()))?;
|
||||
if !sg.grant.capability.permits(&req.id(), INFER_ACTION) {
|
||||
return Err(MeshError::Unauthorized);
|
||||
// Mesh does not authenticate who presents a grant (SPEC §8): it honours
|
||||
// one as a bearer token of its subject, so the subject is the caller.
|
||||
let d = self.grants.authorize(
|
||||
&infer_frame(sg, req, model),
|
||||
sg,
|
||||
sg.grant.subject,
|
||||
(self.clock)(),
|
||||
);
|
||||
match d.verdict {
|
||||
Verdict::Allow => {}
|
||||
Verdict::Deny(DenyReason::BadSignature) => {
|
||||
let why = jcp_grant::verify(sg).err().map(|e| e.to_string());
|
||||
return Err(MeshError::GrantInvalid(why.unwrap_or_else(|| "bad-signature".into())));
|
||||
}
|
||||
Verdict::Deny(DenyReason::WrongResource | DenyReason::ActionNotGranted) => {
|
||||
return Err(MeshError::Unauthorized);
|
||||
}
|
||||
Verdict::Deny(DenyReason::BudgetExceeded) => {
|
||||
return Err(MeshError::BudgetTooLow { budget_uj: 0, ceiling_uj: req.joule_ceiling_uj });
|
||||
}
|
||||
Verdict::Deny(r) => return Err(MeshError::GrantDenied(r)),
|
||||
}
|
||||
if sg.grant.budget_uj < req.joule_ceiling_uj {
|
||||
if d.budget_remaining_uj < req.joule_ceiling_uj {
|
||||
return Err(MeshError::BudgetTooLow {
|
||||
budget_uj: sg.grant.budget_uj,
|
||||
budget_uj: d.budget_remaining_uj,
|
||||
ceiling_uj: req.joule_ceiling_uj,
|
||||
});
|
||||
}
|
||||
|
|
@ -164,15 +223,35 @@ impl<'m> Mesh<'m> {
|
|||
prompt: &[u32],
|
||||
grant: Option<&SignedGrant>,
|
||||
) -> Result<ServeResponse, MeshError> {
|
||||
// 1. Authorise.
|
||||
let grant_id = self.authorize(req, grant)?;
|
||||
|
||||
// 2. Route.
|
||||
let route = self.router.route(req);
|
||||
// 1. Route. With a grant, a backend whose model the grant does not
|
||||
// permit is inadmissible, so the route is the cheapest backend the
|
||||
// grant allows rather than one the decision would then refuse. This
|
||||
// only steers: the decision in step 2 is what authorises.
|
||||
let route = match grant {
|
||||
None => self.router.route(req),
|
||||
Some(sg) => {
|
||||
let engines = &self.engines;
|
||||
self.router.route_where(req, |b| {
|
||||
let model = engines.get(&b.0).and_then(|e| e.weights_digest());
|
||||
if model_permitted(sg, &infer_frame(sg, req, model)) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(match model {
|
||||
Some(m) => format!("model {} not permitted by the grant", m.to_hex()),
|
||||
None => "names no model, and the grant permits only named models".into(),
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
};
|
||||
let backend = route
|
||||
.chosen
|
||||
.clone()
|
||||
.ok_or_else(|| MeshError::NoRoute(route.justification.clone()))?;
|
||||
let model = self.engines.get(&backend.0).and_then(|e| e.weights_digest());
|
||||
|
||||
// 2. Authorise, before any work runs.
|
||||
let grant_id = self.authorize(req, grant, model)?;
|
||||
let chosen_fidelity = route
|
||||
.chosen_fidelity
|
||||
.expect("a chosen route carries its fidelity");
|
||||
|
|
@ -190,7 +269,31 @@ impl<'m> Mesh<'m> {
|
|||
let served = engine.serve(req, prompt);
|
||||
let energy = session.stop();
|
||||
|
||||
// 3a. Decide whether the floor was met, from what came back rather than
|
||||
// 3a. Debit the grant with what was metered: JCP's phase two, where a
|
||||
// per-call cost caveat binds. The work has run and the joules are
|
||||
// real, so they are debited whatever comes next, including a
|
||||
// refusal to seal below; a breach is recorded on the receipt.
|
||||
let grant_breach = match grant {
|
||||
Some(sg) => match self.grants.charge(&sg.grant, jcp_energy(&energy)).verdict {
|
||||
Verdict::Allow => None,
|
||||
Verdict::Deny(r) => Some(r.label().to_string()),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
// 3b. The engine must have run the weights it named before serving:
|
||||
// those are the ones the grant was decided over.
|
||||
if let (Some(named), Some(reported)) = (model, served.outcome.weights_digest) {
|
||||
if named != reported {
|
||||
return Err(MeshError::ModelMismatch {
|
||||
backend: backend.0.clone(),
|
||||
named: named.to_hex(),
|
||||
reported: reported.to_hex(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3c. Decide whether the floor was met, from what came back rather than
|
||||
// from the route table. The old code computed this at step 2, before
|
||||
// the engine ran, so a backend that degraded at serve time still
|
||||
// sealed `fidelity_met: 1`.
|
||||
|
|
@ -246,6 +349,7 @@ impl<'m> Mesh<'m> {
|
|||
fidelity_delivered: delivered,
|
||||
fidelity_met,
|
||||
energy,
|
||||
grant_breach,
|
||||
sequence: 0, // stamped by the log
|
||||
prev: None, // stamped by the log
|
||||
nonce,
|
||||
|
|
@ -263,6 +367,54 @@ impl<'m> Mesh<'m> {
|
|||
}
|
||||
}
|
||||
|
||||
/// The JCP frame a serve exercises its grant with: the action `infer` on the
|
||||
/// request's content id, the prompt as payload, and the model the backend
|
||||
/// runs, if it named one.
|
||||
fn infer_frame(sg: &SignedGrant, req: &ServeRequest, model: Option<ContentId>) -> Frame {
|
||||
Frame {
|
||||
kind: FrameKind::ToolCall,
|
||||
trust: Trust::Control,
|
||||
grant_id: sg.grant.id(),
|
||||
action: INFER_ACTION.into(),
|
||||
resource: req.id(),
|
||||
payload_hash: req.prompt_hash,
|
||||
model,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the grant's model caveats admit `frame`'s model. `ModelIn` is the
|
||||
/// only caveat that depends on which backend serves, so it is the only one
|
||||
/// that steers the route.
|
||||
fn model_permitted(sg: &SignedGrant, frame: &Frame) -> bool {
|
||||
sg.grant
|
||||
.caveats
|
||||
.iter()
|
||||
.filter(|c| matches!(c, Caveat::ModelIn(_)))
|
||||
.all(|c| c.holds(frame, sg.grant.subject, 0, 0))
|
||||
}
|
||||
|
||||
/// The metered span as JCP's energy type, for the debit. Only the debited
|
||||
/// figure is read from it; Mesh's receipt carries the span itself.
|
||||
fn jcp_energy(e: &Energy) -> jcp_schema::Energy {
|
||||
let provenance = match e.provenance {
|
||||
mesh_schema::Provenance::HwShunt => jcp_schema::Provenance::HwShunt,
|
||||
mesh_schema::Provenance::ModelBased => jcp_schema::Provenance::ModelBased,
|
||||
mesh_schema::Provenance::Estimator => jcp_schema::Provenance::Estimator,
|
||||
};
|
||||
jcp_schema::Energy {
|
||||
measured_uj: e.measured_uj,
|
||||
estimated_uj: e.estimated_uj,
|
||||
provenance,
|
||||
coverage: jcp_schema::Coverage::Whole,
|
||||
}
|
||||
}
|
||||
|
||||
fn system_clock() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs())
|
||||
}
|
||||
|
||||
/// Derive a deterministic per-receipt nonce from the request id (first 8 bytes).
|
||||
fn nonce_from(id: &ContentId) -> [u8; 8] {
|
||||
let b = id.0;
|
||||
|
|
@ -308,6 +460,221 @@ mod tests {
|
|||
m
|
||||
}
|
||||
|
||||
/// A grant over exactly `req`, held by a fixed subject.
|
||||
fn grant_over(req: &ServeRequest, budget_uj: u64, expiry: u64, caveats: Vec<Caveat>) -> SignedGrant {
|
||||
let issuer = SigningKey::from_bytes(&[42u8; 32]);
|
||||
let holder = SigningKey::from_bytes(&[43u8; 32]);
|
||||
let g = jcp_schema::Grant {
|
||||
issuer: jcp_schema::Did::from_verifying_key(&issuer.verifying_key()),
|
||||
subject: jcp_schema::Did::from_verifying_key(&holder.verifying_key()),
|
||||
capability: jcp_schema::Capability::new(req.id(), [INFER_ACTION]),
|
||||
budget_uj,
|
||||
budget_funds: None,
|
||||
caveats,
|
||||
expiry,
|
||||
parent: None,
|
||||
delegate_depth: None,
|
||||
nonce: [5u8; 16],
|
||||
};
|
||||
jcp_grant::issue(&issuer, g).expect("issue grant")
|
||||
}
|
||||
|
||||
fn at_1000() -> u64 {
|
||||
1_000
|
||||
}
|
||||
|
||||
/// Expiry and every cost-independent caveat bind before any work runs.
|
||||
/// Mesh used to check only the signature, the capability and the budget,
|
||||
/// so each of these grants was served.
|
||||
#[test]
|
||||
fn a_grant_is_decided_whole_before_any_work() {
|
||||
let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 1_000);
|
||||
let mut mesh = mesh_with(&meter).with_clock(at_1000);
|
||||
let req = request(8, Fidelity::F1, 10_000_000);
|
||||
|
||||
let refused = [
|
||||
(grant_over(&req, 20_000_000, 999, vec![]), DenyReason::Expired),
|
||||
(grant_over(&req, 20_000_000, 0, vec![Caveat::NotAfter(999)]), DenyReason::CaveatUnsatisfied),
|
||||
(grant_over(&req, 20_000_000, 0, vec![Caveat::NotBefore(1_001)]), DenyReason::CaveatUnsatisfied),
|
||||
(
|
||||
grant_over(&req, 20_000_000, 0, vec![Caveat::PayloadIs(ContentId::of_bytes(b"another prompt"))]),
|
||||
DenyReason::CaveatUnsatisfied,
|
||||
),
|
||||
];
|
||||
for (sg, reason) in &refused {
|
||||
match mesh.serve(&req, &[], Some(sg)) {
|
||||
Err(MeshError::GrantDenied(r)) => assert_eq!(r, *reason),
|
||||
other => panic!("expected {reason:?}, got {other:?}"),
|
||||
}
|
||||
assert_eq!(mesh.spent(&sg.grant.id()), 0);
|
||||
}
|
||||
assert_eq!(mesh.head(), None, "nothing ran, so nothing was sealed");
|
||||
|
||||
let fine = grant_over(&req, 20_000_000, 1_001, vec![Caveat::NotAfter(1_000), Caveat::PayloadIs(req.prompt_hash)]);
|
||||
let resp = mesh.serve(&req, &[], Some(&fine)).expect("serve");
|
||||
assert_eq!(resp.receipt.grant_id, Some(fine.grant.id()));
|
||||
assert_eq!(mesh.spent(&fine.grant.id()), 1_000);
|
||||
}
|
||||
|
||||
/// The refusals Mesh made before keep their errors.
|
||||
#[test]
|
||||
fn signature_capability_and_budget_refusals_keep_their_errors() {
|
||||
let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 1_000);
|
||||
let mut mesh = mesh_with(&meter);
|
||||
let req = request(8, Fidelity::F1, 10_000_000);
|
||||
|
||||
let mut forged = grant_over(&req, 20_000_000, 0, vec![]);
|
||||
forged.grant.budget_uj = 1_000_000_000;
|
||||
assert!(matches!(mesh.serve(&req, &[], Some(&forged)), Err(MeshError::GrantInvalid(_))));
|
||||
let elsewhere = grant_over(&request(9, Fidelity::F1, 10_000_000), 20_000_000, 0, vec![]);
|
||||
assert!(matches!(mesh.serve(&req, &[], Some(&elsewhere)), Err(MeshError::Unauthorized)));
|
||||
let starved = grant_over(&req, 1, 0, vec![]);
|
||||
assert!(matches!(
|
||||
mesh.serve(&req, &[], Some(&starved)),
|
||||
Err(MeshError::BudgetTooLow { budget_uj: 1, ceiling_uj: 10_000_000 })
|
||||
));
|
||||
assert_eq!(mesh.head(), None);
|
||||
}
|
||||
|
||||
/// A grant's budget is what it may ever spend, so serves riding on it spend
|
||||
/// it down. Before, each serve checked the whole budget against its own
|
||||
/// ceiling and debited nothing, so one grant paid for any number of them.
|
||||
#[test]
|
||||
fn a_grant_is_spent_down_across_serves() {
|
||||
let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 3_000_000);
|
||||
let mut mesh = mesh_with(&meter);
|
||||
let req = request(8, Fidelity::F1, 4_000_000);
|
||||
let sg = grant_over(&req, 10_000_000, 0, vec![]);
|
||||
|
||||
for n in 1..=3 {
|
||||
mesh.serve(&req, &[], Some(&sg)).expect("within what the grant has left");
|
||||
assert_eq!(mesh.spent(&sg.grant.id()), 3_000_000 * n);
|
||||
}
|
||||
// 1 J left, below the 4 J ceiling: refused before any work.
|
||||
assert!(matches!(
|
||||
mesh.serve(&req, &[], Some(&sg)),
|
||||
Err(MeshError::BudgetTooLow { budget_uj: 1_000_000, ceiling_uj: 4_000_000 })
|
||||
));
|
||||
assert_eq!(mesh.spent(&sg.grant.id()), 9_000_000);
|
||||
}
|
||||
|
||||
/// `ModelIn` makes a backend whose model the grant does not permit
|
||||
/// inadmissible, so the route is the cheapest permitted one, and the
|
||||
/// candidates record why the others were refused.
|
||||
#[test]
|
||||
fn model_in_routes_to_the_cheapest_backend_the_grant_permits() {
|
||||
let (a, b) = (ContentId::of_bytes(b"ssm-1b weights"), ContentId::of_bytes(b"frontier weights"));
|
||||
let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 1_000);
|
||||
let (sk, steward) = steward_key();
|
||||
let mut mesh = Mesh::new(&meter, sk, steward);
|
||||
mesh.register(
|
||||
BackendSpec::new("anon", "unnamed", Fidelity::F1, 10_000),
|
||||
Box::new(ReferenceEngine::new("anon", "unnamed", Fidelity::F1, 10_000, 64)),
|
||||
);
|
||||
mesh.register(
|
||||
BackendSpec::new("tiny", "ssm-1b", Fidelity::F1, 50_000),
|
||||
Box::new(ReferenceEngine::new("tiny", "ssm-1b", Fidelity::F1, 50_000, 64).reporting_model_identity(a, "fp16")),
|
||||
);
|
||||
mesh.register(
|
||||
BackendSpec::new("frontier", "wire-rpc", Fidelity::F4, 5_000_000),
|
||||
Box::new(
|
||||
ReferenceEngine::new("frontier", "wire-rpc", Fidelity::F4, 5_000_000, 64).reporting_model_identity(b, "fp16"),
|
||||
),
|
||||
);
|
||||
let req = request(8, Fidelity::F1, 100_000_000);
|
||||
|
||||
let open = grant_over(&req, 1_000_000_000, 0, vec![]);
|
||||
assert_eq!(mesh.serve(&req, &[], Some(&open)).unwrap().receipt.backend, BackendId::new("anon"));
|
||||
|
||||
let only_b = grant_over(&req, 1_000_000_000, 0, vec![Caveat::ModelIn(vec![b])]);
|
||||
let resp = mesh.serve(&req, &[], Some(&only_b)).expect("serve");
|
||||
assert_eq!(resp.receipt.backend, BackendId::new("frontier"));
|
||||
assert_eq!(resp.receipt.weights_digest, Some(b));
|
||||
let reason = |id: &str| {
|
||||
let c = resp.route.candidates.iter().find(|c| c.backend.0 == id).unwrap();
|
||||
assert!(!c.admissible);
|
||||
c.reason.clone().unwrap()
|
||||
};
|
||||
assert!(reason("tiny").contains("not permitted"), "{}", reason("tiny"));
|
||||
assert!(reason("anon").contains("names no model"), "{}", reason("anon"));
|
||||
|
||||
let head = mesh.head();
|
||||
let absent = grant_over(&req, 1_000_000_000, 0, vec![Caveat::ModelIn(vec![ContentId::of_bytes(b"absent")])]);
|
||||
assert!(matches!(mesh.serve(&req, &[], Some(&absent)), Err(MeshError::NoRoute(_))));
|
||||
assert_eq!(mesh.head(), head);
|
||||
assert_eq!(mesh.spent(&absent.grant.id()), 0);
|
||||
}
|
||||
|
||||
/// Names one model before serving and reports another after.
|
||||
struct SwappingEngine {
|
||||
inner: ReferenceEngine,
|
||||
named: ContentId,
|
||||
}
|
||||
|
||||
impl ServingEngine for SwappingEngine {
|
||||
fn backend_id(&self) -> BackendId {
|
||||
self.inner.backend_id()
|
||||
}
|
||||
fn recipe_id(&self) -> String {
|
||||
self.inner.recipe_id()
|
||||
}
|
||||
fn fidelity(&self) -> Fidelity {
|
||||
self.inner.fidelity()
|
||||
}
|
||||
fn est_uj_per_token(&self) -> u64 {
|
||||
self.inner.est_uj_per_token()
|
||||
}
|
||||
fn weights_digest(&self) -> Option<ContentId> {
|
||||
Some(self.named)
|
||||
}
|
||||
fn serve(&mut self, req: &ServeRequest, prompt: &[u32]) -> mesh_engine::Served {
|
||||
self.inner.serve(req, prompt)
|
||||
}
|
||||
}
|
||||
|
||||
/// An engine that runs other weights than the ones the grant was decided
|
||||
/// over is refused a receipt. The joules it spent are still debited.
|
||||
#[test]
|
||||
fn an_engine_that_swaps_its_model_is_refused_a_receipt() {
|
||||
let (named, ran) = (ContentId::of_bytes(b"approved weights"), ContentId::of_bytes(b"other weights"));
|
||||
let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 2_000);
|
||||
let (sk, steward) = steward_key();
|
||||
let mut mesh = Mesh::new(&meter, sk, steward);
|
||||
mesh.register(
|
||||
BackendSpec::new("swap", "approved", Fidelity::F1, 50_000),
|
||||
Box::new(SwappingEngine {
|
||||
inner: ReferenceEngine::new("swap", "approved", Fidelity::F1, 50_000, 64).reporting_model_identity(ran, "fp16"),
|
||||
named,
|
||||
}),
|
||||
);
|
||||
let req = request(8, Fidelity::F1, 10_000_000);
|
||||
let sg = grant_over(&req, 20_000_000, 0, vec![Caveat::ModelIn(vec![named])]);
|
||||
assert!(matches!(mesh.serve(&req, &[], Some(&sg)), Err(MeshError::ModelMismatch { .. })));
|
||||
assert_eq!(mesh.head(), None);
|
||||
assert_eq!(mesh.spent(&sg.grant.id()), 2_000);
|
||||
}
|
||||
|
||||
/// A per-call cost caveat binds at the debit: the work has run, so the
|
||||
/// serve is debited and sealed, and the receipt carries the breach.
|
||||
#[test]
|
||||
fn a_per_call_cost_breach_is_debited_and_sealed_on_the_receipt() {
|
||||
let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 3_000_000);
|
||||
let mut mesh = mesh_with(&meter);
|
||||
let req = request(8, Fidelity::F1, 10_000_000);
|
||||
|
||||
let capped = grant_over(&req, 20_000_000, 0, vec![Caveat::MaxCostUj(1_000_000)]);
|
||||
let resp = mesh.serve(&req, &[], Some(&capped)).expect("the work ran, so the serve seals");
|
||||
assert_eq!(resp.receipt.grant_breach.as_deref(), Some("caveat-unsatisfied"));
|
||||
assert_eq!(mesh.spent(&capped.grant.id()), 3_000_000);
|
||||
assert_eq!(resp.receipt.to_jcr1_wire(mesh.steward())["grant_breach"], "caveat-unsatisfied");
|
||||
assert!(resp.receipt.verify_envelope(&resp.envelope, mesh.steward(), &mesh.verifying_key()).is_ok());
|
||||
|
||||
let roomy = grant_over(&req, 20_000_000, 0, vec![Caveat::MaxCostUj(5_000_000)]);
|
||||
let ok = mesh.serve(&req, &[], Some(&roomy)).expect("serve");
|
||||
assert_eq!(ok.receipt.grant_breach, None);
|
||||
assert!(ok.receipt.to_jcr1_wire(mesh.steward()).get("grant_breach").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measured_end_to_end_seals_and_verifies() {
|
||||
// A HwShunt stepping meter yields a deterministic 3_200_000 µJ span,
|
||||
|
|
|
|||
|
|
@ -39,6 +39,19 @@ pub trait ServingEngine {
|
|||
fn fidelity(&self) -> Fidelity;
|
||||
/// Modeled microjoules per token (the router's a-priori cost estimate).
|
||||
fn est_uj_per_token(&self) -> u64;
|
||||
|
||||
/// The content address of the weights this engine runs, when it knows
|
||||
/// them before serving. Mesh names this model in the JCP frame a grant is
|
||||
/// decided over, so a grant's `ModelIn` caveat can admit or refuse the
|
||||
/// backend before any work runs (SPEC §2). `None`, the default, names no
|
||||
/// model, and a grant that permits only named models refuses it.
|
||||
///
|
||||
/// An engine that reports one here MUST report the same digest in
|
||||
/// [`ServeOutcome::weights_digest`]: Mesh refuses to seal a serve whose
|
||||
/// engine ran other weights than the ones the grant was decided over.
|
||||
fn weights_digest(&self) -> Option<ContentId> {
|
||||
None
|
||||
}
|
||||
/// Serve the request. `prompt` is the input token payload — passed
|
||||
/// alongside the request, never sealed into the receipt (the receipt records
|
||||
/// only the content-addressed `prompt_hash`, §3.1). Implementations MUST do
|
||||
|
|
@ -189,6 +202,10 @@ impl ServingEngine for ReferenceEngine {
|
|||
self.uj_per_token
|
||||
}
|
||||
|
||||
fn weights_digest(&self) -> Option<ContentId> {
|
||||
self.weights_digest
|
||||
}
|
||||
|
||||
fn serve(&mut self, req: &ServeRequest, _prompt: &[u32]) -> Served {
|
||||
// The reference engine is synthetic: its output is a deterministic
|
||||
// function of the request's content id, independent of the prompt
|
||||
|
|
|
|||
|
|
@ -110,6 +110,14 @@ pub struct MeshReceipt {
|
|||
pub fidelity_met: bool,
|
||||
/// The honest, provenance-tagged energy span.
|
||||
pub energy: Energy,
|
||||
/// The JCP denial label when debiting the metered joules against the grant
|
||||
/// breached it (SPEC §2): a per-call cost caveat the measured cost
|
||||
/// exceeded. The work had already run, so the joules were debited anyway;
|
||||
/// this field is what keeps the breach visible instead of absorbed.
|
||||
/// `None`, skipped on the wire, means the debit was within the grant, or
|
||||
/// the serve rode on no grant.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub grant_breach: Option<String>,
|
||||
/// Monotonic sequence in the steward's receipt log.
|
||||
pub sequence: u64,
|
||||
/// Content id of the previous envelope (hash chain); `None` at genesis.
|
||||
|
|
@ -211,6 +219,11 @@ impl MeshReceipt {
|
|||
"energy_provenance".into(),
|
||||
json!(self.energy.provenance.jcr1_label()),
|
||||
);
|
||||
// Emitted only on a breach, so every receipt whose debit was within its
|
||||
// grant seals to the bytes it sealed to before the field existed.
|
||||
if let Some(b) = &self.grant_breach {
|
||||
obj.insert("grant_breach".into(), json!(b));
|
||||
}
|
||||
obj.insert("sequence".into(), json!(self.sequence));
|
||||
if let Some(p) = &self.prev {
|
||||
obj.insert("prev".into(), json!(p.to_hex()));
|
||||
|
|
@ -345,6 +358,7 @@ mod tests {
|
|||
fidelity_delivered: Some(Fidelity::F2),
|
||||
fidelity_met: true,
|
||||
energy,
|
||||
grant_breach: None,
|
||||
sequence: 0,
|
||||
prev: None,
|
||||
nonce: [1, 2, 3, 4, 5, 6, 7, 8],
|
||||
|
|
|
|||
|
|
@ -404,10 +404,24 @@ impl Router {
|
|||
/// Route a request: weigh every backend, choose the cheapest admissible one,
|
||||
/// and return an auditable decision.
|
||||
pub fn route(&self, req: &ServeRequest) -> RouteDecision {
|
||||
self.route_where(req, |_| Ok(()))
|
||||
}
|
||||
|
||||
/// [`route`](Self::route), with one more admissibility test: `permit` may
|
||||
/// refuse a backend, giving the reason the decision records. Mesh uses it
|
||||
/// to admit only backends whose model a JCP grant permits, so the route
|
||||
/// is the cheapest backend the grant allows, not the cheapest overall.
|
||||
pub fn route_where(
|
||||
&self,
|
||||
req: &ServeRequest,
|
||||
permit: impl Fn(&BackendId) -> Result<(), String>,
|
||||
) -> RouteDecision {
|
||||
let mut candidates = Vec::with_capacity(self.backends.len());
|
||||
for b in &self.backends {
|
||||
let est = b.estimate(req);
|
||||
let (admissible, reason) = if b.fidelity < req.fidelity_floor {
|
||||
let (admissible, reason) = if let Err(why) = permit(&b.id) {
|
||||
(false, Some(why))
|
||||
} else if b.fidelity < req.fidelity_floor {
|
||||
(
|
||||
false,
|
||||
Some(format!(
|
||||
|
|
@ -468,7 +482,7 @@ impl Router {
|
|||
}
|
||||
None => {
|
||||
let justification = format!(
|
||||
"no admissible backend: all {} candidate(s) below fidelity floor {} or over ceiling {} µJ",
|
||||
"no admissible backend: all {} candidate(s) refused, below fidelity floor {} or over ceiling {} µJ",
|
||||
candidates.len(),
|
||||
req.fidelity_floor.label(),
|
||||
req.joule_ceiling_uj
|
||||
|
|
|
|||
Loading…
Reference in a new issue