Receipts as a training corpus + prove-local: attested offline inference #31

Merged
dcharlot merged 3 commits from receipts-corpus-connector into main 2026-07-20 15:01:21 -04:00
11 changed files with 1509 additions and 315 deletions

769
eoc/eoc-rs/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -17,7 +17,7 @@ serde_json = "1"
async-trait.workspace = true
thiserror.workspace = true
rand = { version = "0.10", default-features = false, features = ["std", "std_rng"] }
rand_distr = { version = "0.4", default-features = false }
rand_distr = { version = "0.6", default-features = false }
nalgebra = { version = "0.33", default-features = false, features = ["std", "serde-serialize"] }
tracing = "0.1"

View file

@ -3389,6 +3389,19 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "jouleclaw-corpus"
version = "0.1.0"
dependencies = [
"jouleclaw-energy",
"jouleclaw-intent",
"jouleclaw-prov",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.18",
]
[[package]]
name = "jouleclaw-critic"
version = "0.1.0"

View file

@ -1147,6 +1147,7 @@ members = [
# top of jouleclaw-otel's SpanData.
"crates/jouleclaw-otel-authz",
"crates/jouleclaw-jcp",
"crates/jouleclaw-corpus",
]
[workspace.package]

View file

@ -0,0 +1,20 @@
[package]
name = "jouleclaw-corpus"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
description = "Receipts → training corpus. Projects sealed JouleClaw receipts into the labelled (embedding, stage, success, cost_microjoules) rows an EOC router trainer consumes, turning the audit ledger into a supervision signal. Labels are derived from observed cascade closure — never invented; receipts whose closing tier has no EOC analogue are skipped and counted, not guessed."
[dependencies]
jouleclaw-prov = { path = "../jouleclaw-prov" }
jouleclaw-energy = { path = "../jouleclaw-energy" }
jouleclaw-intent = { path = "../jouleclaw-intent" }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }

View file

@ -0,0 +1,70 @@
//! Emit a training corpus from a batch of sealed receipts.
//!
//! ```text
//! cargo run -p jouleclaw-corpus --example emit_corpus -- /tmp/corpus.jsonl
//! ```
//!
//! The output file is directly loadable by an EOC router trainer's JSONL
//! loader. This example stands in a synthetic ledger for the receipts a
//! real deployment would read off disk.
use jouleclaw_corpus::{CorpusBuilder, write_jsonl};
use jouleclaw_energy::Provenance;
use jouleclaw_intent::{IntentVerdict, QueryIntent};
use jouleclaw_prov::{CascadeTier, Receipt};
fn receipt(tier: CascadeTier, uj: u64, intent: QueryIntent, confidence: f64) -> Receipt {
Receipt {
context: None,
jc_receipt: "1".into(),
id: format!("rcpt-{}-{uj}", tier.wire_tag()),
closed_at: "2026-07-20T00:00:00Z".into(),
input_hash: "a".repeat(64),
tier,
joules_uj: uj,
energy_provenance: Provenance::HwShunt,
tools_touched: Vec::new(),
claims: Vec::new(),
eoc_stage: None,
citations: Vec::new(),
tool_receipts: Vec::new(),
token_attributions: Vec::new(),
model_id: None,
intent: Some(IntentVerdict::new(intent, confidence, "heuristic:v1")),
steward: None,
}
}
fn main() {
let out = std::env::args()
.nth(1)
.unwrap_or_else(|| "corpus.jsonl".to_string());
// A ledger with the shape a real cascade produces: comprehension work
// closing cheap, generation work escalating to a model, and one lawful
// primitive that has no EOC analogue and must be skipped.
let ledger = vec![
receipt(CascadeTier::L0Cache, 12, QueryIntent::Comprehension, 0.91),
receipt(CascadeTier::L0Cache, 9, QueryIntent::Comprehension, 0.88),
receipt(CascadeTier::L2Embed, 4_100, QueryIntent::Comprehension, 0.72),
receipt(CascadeTier::L2Embed, 3_800, QueryIntent::Review, 0.65),
receipt(CascadeTier::L3Model, 2_400_000, QueryIntent::Generation, 0.94),
receipt(CascadeTier::L3Model, 1_900_000, QueryIntent::Modification, 0.80),
receipt(CascadeTier::L4Wire, 61_000_000, QueryIntent::Generation, 0.97),
receipt(CascadeTier::L1Lawful, 40, QueryIntent::Process, 0.99),
];
let (rows, stats) = CorpusBuilder::default()
.with_monotone_negatives(true)
.build(&ledger);
write_jsonl(&out, &rows).expect("write corpus");
println!("receipts seen {}", stats.receipts_seen);
println!("receipts used {}", stats.receipts_used);
println!("skipped unmapped {}", stats.skipped_unmapped);
println!("rows positive {}", stats.rows_positive);
println!("rows negative {}", stats.rows_negative);
println!("rows total {}", stats.rows());
println!("wrote {out}");
}

View file

@ -0,0 +1,607 @@
//! # jouleclaw-corpus
//!
//! **Receipts → training corpus.** The connector that turns the audit
//! ledger into a supervision signal.
//!
//! Every sealed [`Receipt`] already records what a cascade walk actually
//! did: which tier closed the query, how many microjoules it cost, and at
//! what counter honesty. That is an *observed execution outcome* — the same
//! class of signal a code model gets from "the test passed on head and
//! failed on base", except it arrives signed and energy-metered rather than
//! reconstructed after the fact.
//!
//! Until now those receipts were terminal: written, verified, aggregated
//! into cost reports, and never read again. This crate projects them into
//! the labelled rows an EOC router trainer consumes, so the ledger compounds
//! instead of merely accumulating.
//!
//! ## What is derived, and what is not
//!
//! - **`cost_microjoules`** — read directly from [`Receipt::joules_uj`]. Not
//! modelled, not rescaled.
//! - **`stage`** — taken from [`Receipt::eoc_stage`] when the runtime
//! reported one (authoritative), else mapped from [`CascadeTier`] but
//! **only where the mapping is unambiguous**. Receipts that cannot be
//! classified are skipped and counted in [`CorpusStats::skipped_unmapped`],
//! never guessed into a bucket.
//! - **`success`** — a sealed receipt attests the walk *closed* at
//! `tier`; that stage is labelled a success. Note honestly: the receipt
//! carries no failure discriminator, so if a runtime seals receipts for
//! failed walks they are indistinguishable here. Treat `success: true` as
//! "this tier closed the walk", which is what it is.
//! - **`embedding`** — receipts carry no vector. An [`Embedder`] supplies
//! one. The bundled [`StructuralEmbedder`] is deterministic and offline;
//! see its docs for the sharp limitation.
//!
//! ## Wire compatibility
//!
//! Rows serialise to the exact JSON an EOC router trainer's JSONL loader
//! expects. One trap this crate exists to absorb: the receipt's
//! `eoc_stage` label is **lower-case** (`"neural"`), while the trainer's
//! `Stage` enum deserialises **capitalised** (`"Neural"`) and rejects
//! anything else. Feeding the receipt field straight through fails at
//! ingest. [`CorpusStage::from_eoc_label`] absorbs the difference and
//! `stage_wire_form_is_pinned` locks the emitted spelling.
//!
//! The coupling here is the wire format, deliberately — the EOC standard
//! takes no dependency on JouleClaw, and JouleClaw takes none on EOC.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
use std::io::Write;
use std::path::Path;
use jouleclaw_energy::Provenance;
use jouleclaw_intent::QueryIntent;
use jouleclaw_prov::{CascadeTier, Receipt};
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Errors raised while building or writing a corpus.
#[derive(Debug, Error)]
pub enum Error {
/// Writing the JSONL sink failed.
#[error("corpus write failed: {0}")]
Write(String),
/// A row could not be serialised.
#[error("corpus encode failed: {0}")]
Encode(String),
}
/// Result alias for this crate.
pub type Result<T> = std::result::Result<T, Error>;
// ─────────────────────────────────────────────────────────────────────
// Stage
// ─────────────────────────────────────────────────────────────────────
/// The EOC cascade stage a row is labelled with.
///
/// Variant spelling is **load-bearing**: these serialise to `"Cache"`,
/// `"Kv"`, `"Graph"`, `"Neural"` to match the consuming trainer's `Stage`
/// enum exactly. Do not add `#[serde(rename_all = ...)]` here — it would
/// silently break every downstream ingest.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum CorpusStage {
/// Content-addressed cache hit.
Cache,
/// Key-value lookup, including embedding-similarity match.
Kv,
/// Graph / triple-store retrieval.
Graph,
/// Neural inference.
Neural,
}
impl CorpusStage {
/// Relative cost rank, cheapest first. Used to order negatives.
fn rank(self) -> u8 {
match self {
Self::Cache => 0,
Self::Kv => 1,
Self::Graph => 2,
Self::Neural => 3,
}
}
/// Parse the label a runtime writes into [`Receipt::eoc_stage`].
///
/// Receipts carry the lower-case form (`"neural"`); this accepts that,
/// and is case-insensitive so a capitalised label round-trips too.
pub fn from_eoc_label(label: &str) -> Option<Self> {
match label.trim().to_ascii_lowercase().as_str() {
"cache" => Some(Self::Cache),
"kv" => Some(Self::Kv),
"graph" => Some(Self::Graph),
"neural" => Some(Self::Neural),
_ => None,
}
}
/// Map a JouleClaw cascade tier onto an EOC stage.
///
/// Returns `None` where no honest correspondence exists rather than
/// forcing one:
///
/// - `L0Cache` → `Cache`
/// - `L2Embed` → `Kv` (the Kv stage is defined to include
/// embedding-similarity match)
/// - `L3Model`, `L4Wire` → `Neural`
/// - `L1Lawful` → **`None`.** A deterministic lawful primitive is not a
/// cache, a lookup, a graph walk, or an inference. There is no EOC
/// stage for it.
///
/// Note that nothing maps *to* `Graph`: JouleClaw has no graph tier.
/// `Graph` rows only ever arise from an explicit `eoc_stage` label.
pub fn from_tier(tier: CascadeTier) -> Option<Self> {
match tier {
CascadeTier::L0Cache => Some(Self::Cache),
CascadeTier::L1Lawful => None,
CascadeTier::L2Embed => Some(Self::Kv),
CascadeTier::L3Model | CascadeTier::L4Wire => Some(Self::Neural),
}
}
}
// ─────────────────────────────────────────────────────────────────────
// Row
// ─────────────────────────────────────────────────────────────────────
/// One labelled training row, in the trainer's on-disk shape.
///
/// Field names and order match the consumer's `Example` struct. See
/// [`CorpusStage`] on why the stage spelling must not change.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CorpusRow {
/// Feature vector. Dimension must be consistent across the corpus.
pub embedding: Vec<f32>,
/// Stage this row labels.
pub stage: CorpusStage,
/// Whether `stage` closed the walk.
pub success: bool,
/// Observed cost in microjoules, verbatim from the receipt.
pub cost_microjoules: u64,
}
// ─────────────────────────────────────────────────────────────────────
// Embedding
// ─────────────────────────────────────────────────────────────────────
/// Supplies the feature vector for a receipt.
///
/// Receipts carry no vector, so this is the one genuinely external input.
/// Implement it over a real text embedder when the query text is available
/// alongside the receipt; use [`StructuralEmbedder`] when it is not.
pub trait Embedder {
/// Feature vector for `receipt`. Must return [`Embedder::dim`] elements.
fn embed(&self, receipt: &Receipt) -> Vec<f32>;
/// Dimension of every vector this embedder returns.
fn dim(&self) -> usize;
}
/// Number of dimensions [`StructuralEmbedder`] emits.
pub const STRUCTURAL_DIM: usize = 15;
/// A deterministic, offline embedder over receipt *structure*.
///
/// ## The limitation, stated plainly
///
/// This does **not** encode query semantics. Receipts hold a BLAKE3 of the
/// input, not the input, and a hash has no semantic locality — using its
/// bits as features would manufacture noise and dress it as signal, so this
/// embedder does not touch `input_hash`. What it captures is the *shape* of
/// a request: its intent class, how much retrieval grounded it, and how
/// trustworthy the energy counter was. That is enough to learn "requests
/// shaped like this tend to be closed by that stage at this cost", and not
/// enough to distinguish two differently-worded questions of the same shape.
/// For semantic routing, supply a real text embedder.
///
/// ## No label leakage
///
/// `tier`/`eoc_stage` and `joules_uj` are deliberately **excluded**. They
/// are the label side of every row — a router that could read the closing
/// tier or its cost out of the feature vector would score perfectly while
/// learning nothing. Layout:
///
/// | dims | feature |
/// |--------|----------------------------------------------------------|
/// | 0–5 | intent one-hot (Comprehension…Other), all-zero if absent |
/// | 6 | intent confidence, `0.0` when no verdict ran |
/// | 7–9 | energy provenance one-hot (HwShunt/ModelBased/Estimator) |
/// | 10–13 | `ln(1+n)` over tools, claims, citations, attributions |
/// | 14 | 1.0 when a model id is present |
#[derive(Debug, Clone, Copy, Default)]
pub struct StructuralEmbedder;
impl Embedder for StructuralEmbedder {
fn dim(&self) -> usize {
STRUCTURAL_DIM
}
fn embed(&self, receipt: &Receipt) -> Vec<f32> {
let mut v = vec![0.0_f32; STRUCTURAL_DIM];
if let Some(verdict) = &receipt.intent {
let idx = match verdict.intent {
QueryIntent::Comprehension => 0,
QueryIntent::Generation => 1,
QueryIntent::Modification => 2,
QueryIntent::Process => 3,
QueryIntent::Review => 4,
QueryIntent::Other => 5,
// `QueryIntent` is `#[non_exhaustive]`. A variant added
// upstream lands in the unclassified slot rather than
// shifting the one-hot layout underneath a trained router.
_ => 5,
};
v[idx] = 1.0;
v[6] = verdict.confidence as f32;
}
let prov_idx = match receipt.energy_provenance {
Provenance::HwShunt => 7,
Provenance::ModelBased => 8,
Provenance::Estimator => 9,
};
v[prov_idx] = 1.0;
v[10] = ln1p(receipt.tools_touched.len());
v[11] = ln1p(receipt.claims.len());
v[12] = ln1p(receipt.citations.len());
v[13] = ln1p(receipt.token_attributions.len());
v[14] = if receipt.model_id.is_some() { 1.0 } else { 0.0 };
v
}
}
fn ln1p(n: usize) -> f32 {
(n as f32).ln_1p()
}
// ─────────────────────────────────────────────────────────────────────
// Builder
// ─────────────────────────────────────────────────────────────────────
/// What a corpus build actually consumed and produced.
///
/// Reported rather than swallowed: a silent skip reads as "covered
/// everything" when it did not.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CorpusStats {
/// Receipts read.
pub receipts_seen: usize,
/// Receipts that produced at least one row.
pub receipts_used: usize,
/// Receipts skipped because their closing tier has no EOC analogue
/// (in practice: `L1Lawful`) and no `eoc_stage` label was present.
pub skipped_unmapped: usize,
/// Positive rows emitted (one per used receipt).
pub rows_positive: usize,
/// Negative rows emitted under the monotone-escalation assumption.
pub rows_negative: usize,
}
impl CorpusStats {
/// Total rows emitted.
pub fn rows(&self) -> usize {
self.rows_positive + self.rows_negative
}
}
/// Projects receipts into corpus rows.
pub struct CorpusBuilder<E: Embedder> {
embedder: E,
monotone_negatives: bool,
}
impl Default for CorpusBuilder<StructuralEmbedder> {
fn default() -> Self {
Self::new(StructuralEmbedder)
}
}
impl<E: Embedder> CorpusBuilder<E> {
/// Build with `embedder`, emitting positives only.
pub fn new(embedder: E) -> Self {
Self {
embedder,
monotone_negatives: false,
}
}
/// Also emit a negative row for each cheaper stage that a monotone
/// cascade must have tried and escalated past.
///
/// **This rests on an assumption — read before enabling.** A receipt
/// records the tier that *closed* the walk, not the tiers walked. In a
/// strict cheapest-first cascade, closing at `Neural` means `Cache` and
/// `Kv` were attempted and did not satisfy the query, which is a real
/// negative label — and the pairing of "this rung failed, that rung
/// closed it" is the whole supervision signal. A router that skips
/// rungs breaks that inference, and these rows become noise.
///
/// `Graph` is never emitted as a negative: no JouleClaw tier maps to it,
/// so its absence from a walk is not evidence it was tried and failed.
pub fn with_monotone_negatives(mut self, yes: bool) -> Self {
self.monotone_negatives = yes;
self
}
/// Resolve the stage a receipt closed at, preferring the runtime's own
/// label over the tier mapping.
fn stage_of(receipt: &Receipt) -> Option<CorpusStage> {
receipt
.eoc_stage
.as_deref()
.and_then(CorpusStage::from_eoc_label)
.or_else(|| CorpusStage::from_tier(receipt.tier))
}
/// Project one receipt. Returns an empty vec when it cannot be mapped.
pub fn rows_from_receipt(&self, receipt: &Receipt) -> Vec<CorpusRow> {
let Some(stage) = Self::stage_of(receipt) else {
return Vec::new();
};
let embedding = self.embedder.embed(receipt);
let mut rows = vec![CorpusRow {
embedding: embedding.clone(),
stage,
success: true,
cost_microjoules: receipt.joules_uj,
}];
if self.monotone_negatives {
for cheaper in [CorpusStage::Cache, CorpusStage::Kv] {
if cheaper.rank() < stage.rank() {
rows.push(CorpusRow {
embedding: embedding.clone(),
stage: cheaper,
success: false,
cost_microjoules: receipt.joules_uj,
});
}
}
}
rows
}
/// Project a batch of receipts, reporting what was used and skipped.
pub fn build(&self, receipts: &[Receipt]) -> (Vec<CorpusRow>, CorpusStats) {
let mut out = Vec::new();
let mut stats = CorpusStats {
receipts_seen: receipts.len(),
..Default::default()
};
for r in receipts {
let rows = self.rows_from_receipt(r);
if rows.is_empty() {
stats.skipped_unmapped += 1;
continue;
}
stats.receipts_used += 1;
for row in &rows {
if row.success {
stats.rows_positive += 1;
} else {
stats.rows_negative += 1;
}
}
out.extend(rows);
}
(out, stats)
}
}
// ─────────────────────────────────────────────────────────────────────
// Sinks
// ─────────────────────────────────────────────────────────────────────
/// Encode rows as JSONL — one row per line, trainer-ready.
pub fn to_jsonl(rows: &[CorpusRow]) -> Result<String> {
let mut s = String::new();
for row in rows {
let line = serde_json::to_string(row).map_err(|e| Error::Encode(e.to_string()))?;
s.push_str(&line);
s.push('\n');
}
Ok(s)
}
/// Write rows to a JSONL file the trainer's loader can read directly.
pub fn write_jsonl(path: impl AsRef<Path>, rows: &[CorpusRow]) -> Result<()> {
let body = to_jsonl(rows)?;
let mut f = std::fs::File::create(path).map_err(|e| Error::Write(e.to_string()))?;
f.write_all(body.as_bytes())
.map_err(|e| Error::Write(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use jouleclaw_intent::IntentVerdict;
/// Struct-literal construction, as the conformance-vector generators do:
/// the builder *accounts* joules from tool/claim costs rather than taking
/// an asserted total, and these tests need an exact `joules_uj`.
fn receipt(tier: CascadeTier, uj: u64) -> Receipt {
Receipt {
context: None,
jc_receipt: "1".into(),
id: "rcpt-test".into(),
closed_at: "2026-07-20T00:00:00Z".into(),
input_hash: "a".repeat(64),
tier,
joules_uj: uj,
energy_provenance: Provenance::HwShunt,
tools_touched: Vec::new(),
claims: Vec::new(),
eoc_stage: None,
citations: Vec::new(),
tool_receipts: Vec::new(),
token_attributions: Vec::new(),
model_id: None,
intent: None,
steward: None,
}
}
#[test]
fn stage_wire_form_is_pinned() {
// The consuming trainer rejects any other spelling. If this test
// fails, downstream ingest is already broken.
for (stage, expected) in [
(CorpusStage::Cache, "\"Cache\""),
(CorpusStage::Kv, "\"Kv\""),
(CorpusStage::Graph, "\"Graph\""),
(CorpusStage::Neural, "\"Neural\""),
] {
assert_eq!(serde_json::to_string(&stage).unwrap(), expected);
}
}
#[test]
fn row_matches_trainer_field_shape() {
let row = CorpusRow {
embedding: vec![0.5, 0.25],
stage: CorpusStage::Neural,
success: true,
cost_microjoules: 1234,
};
assert_eq!(
serde_json::to_string(&row).unwrap(),
r#"{"embedding":[0.5,0.25],"stage":"Neural","success":true,"cost_microjoules":1234}"#
);
}
#[test]
fn absorbs_lowercase_receipt_label() {
// The exact trap this crate exists to absorb.
assert_eq!(
CorpusStage::from_eoc_label("neural"),
Some(CorpusStage::Neural)
);
assert_eq!(CorpusStage::from_eoc_label("kv"), Some(CorpusStage::Kv));
assert_eq!(CorpusStage::from_eoc_label("Neural"), Some(CorpusStage::Neural));
assert_eq!(CorpusStage::from_eoc_label("nonsense"), None);
}
#[test]
fn lawful_tier_is_skipped_not_guessed() {
assert_eq!(CorpusStage::from_tier(CascadeTier::L1Lawful), None);
let (rows, stats) = CorpusBuilder::default().build(&[receipt(CascadeTier::L1Lawful, 10)]);
assert!(rows.is_empty());
assert_eq!(stats.skipped_unmapped, 1);
assert_eq!(stats.receipts_used, 0);
}
#[test]
fn eoc_label_wins_over_tier_mapping() {
let mut r = receipt(CascadeTier::L3Model, 900);
r.eoc_stage = Some("graph".into());
let rows = CorpusBuilder::default().rows_from_receipt(&r);
// Tier alone would have said Neural; the runtime's own label wins.
assert_eq!(rows[0].stage, CorpusStage::Graph);
}
#[test]
fn cost_is_verbatim() {
let rows = CorpusBuilder::default().rows_from_receipt(&receipt(CascadeTier::L0Cache, 7));
assert_eq!(rows[0].cost_microjoules, 7);
assert!(rows[0].success);
assert_eq!(rows[0].stage, CorpusStage::Cache);
}
#[test]
fn monotone_negatives_pair_failure_with_closure() {
let rows = CorpusBuilder::default()
.with_monotone_negatives(true)
.rows_from_receipt(&receipt(CascadeTier::L3Model, 5_000));
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].stage, CorpusStage::Neural);
assert!(rows[0].success);
// Cheaper rungs a monotone cascade must have escalated past.
assert!(rows[1..].iter().all(|r| !r.success));
let neg: Vec<_> = rows[1..].iter().map(|r| r.stage).collect();
assert_eq!(neg, vec![CorpusStage::Cache, CorpusStage::Kv]);
}
#[test]
fn graph_is_never_a_synthesised_negative() {
// Nothing maps to Graph, so its absence is not evidence of failure.
let rows = CorpusBuilder::default()
.with_monotone_negatives(true)
.rows_from_receipt(&receipt(CascadeTier::L4Wire, 80_000));
assert!(!rows.iter().any(|r| r.stage == CorpusStage::Graph));
}
#[test]
fn cheapest_tier_emits_no_negatives() {
let rows = CorpusBuilder::default()
.with_monotone_negatives(true)
.rows_from_receipt(&receipt(CascadeTier::L0Cache, 1));
assert_eq!(rows.len(), 1);
}
#[test]
fn embedding_never_leaks_the_label() {
// Two receipts identical but for tier and cost must embed the same;
// otherwise the router can read its own answer out of the features.
let cheap = receipt(CascadeTier::L0Cache, 1);
let dear = receipt(CascadeTier::L4Wire, 900_000);
let e = StructuralEmbedder;
assert_eq!(e.embed(&cheap), e.embed(&dear));
}
#[test]
fn embedding_dim_is_stable_and_declared() {
let e = StructuralEmbedder;
let mut r = receipt(CascadeTier::L3Model, 10);
assert_eq!(e.embed(&r).len(), e.dim());
r.intent = Some(IntentVerdict::new(QueryIntent::Review, 0.75, "heuristic:v1"));
let v = e.embed(&r);
assert_eq!(v.len(), STRUCTURAL_DIM);
assert_eq!(v[4], 1.0, "Review one-hot");
assert!((v[6] - 0.75).abs() < 1e-6, "confidence carried");
}
#[test]
fn stats_account_for_every_receipt() {
let batch = vec![
receipt(CascadeTier::L0Cache, 1),
receipt(CascadeTier::L1Lawful, 2), // unmappable
receipt(CascadeTier::L3Model, 3),
];
let (rows, stats) = CorpusBuilder::default().build(&batch);
assert_eq!(stats.receipts_seen, 3);
assert_eq!(stats.receipts_used, 2);
assert_eq!(stats.skipped_unmapped, 1);
assert_eq!(stats.receipts_used + stats.skipped_unmapped, stats.receipts_seen);
assert_eq!(rows.len(), stats.rows());
}
#[test]
fn jsonl_round_trips() {
let (rows, _) = CorpusBuilder::default().build(&[receipt(CascadeTier::L3Model, 42)]);
let text = to_jsonl(&rows).unwrap();
assert_eq!(text.lines().count(), 1);
let back: CorpusRow = serde_json::from_str(text.lines().next().unwrap()).unwrap();
assert_eq!(back, rows[0]);
}
#[test]
fn writes_a_file_the_loader_can_read() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("corpus.jsonl");
let (rows, _) = CorpusBuilder::default()
.with_monotone_negatives(true)
.build(&[receipt(CascadeTier::L3Model, 42)]);
write_jsonl(&path, &rows).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert_eq!(text.lines().count(), 3);
for line in text.lines() {
serde_json::from_str::<CorpusRow>(line).unwrap();
}
}
}

View file

@ -1874,6 +1874,7 @@ dependencies = [
name = "sandbox-proof"
version = "0.1.0"
dependencies = [
"blake3",
"ed25519-dalek",
"hex",
"sandbox-attest",

View file

@ -26,4 +26,5 @@ serde = { workspace = true }
serde_json.workspace = true
ureq.workspace = true
hex.workspace = true
blake3.workspace = true
thiserror.workspace = true

View file

@ -981,6 +981,245 @@ pub fn prove_confidential(
pub mod matrix;
// ─────────────────────────────────────────────────────────────────────
// Local (air-gapped) inference
// ─────────────────────────────────────────────────────────────────────
/// Inputs for a **local** inference proof: an engine to run, and the weights
/// it runs against. Both live on this machine; nothing is fetched.
#[derive(Clone, Debug)]
pub struct LocalInferenceInputs {
/// The local inference engine to execute.
pub engine: String,
/// Arguments passed to the engine.
pub args: Vec<String>,
/// Path to the model weights on local disk. Hashed into the proof, so the
/// cell attests inference against *these bytes* and not merely "a model".
pub weights: String,
}
/// Stream a BLAKE3 over a file without loading it into memory — weights are
/// routinely gigabytes.
fn hash_file(path: &std::path::Path) -> Result<String, ProofError> {
use std::io::Read;
let mut f = std::fs::File::open(path)
.map_err(|e| ProofError::Conformance(format!("weights unreadable: {e}")))?;
let mut hasher = blake3::Hasher::new();
let mut buf = vec![0u8; 1 << 20];
loop {
let n = f
.read(&mut buf)
.map_err(|e| ProofError::Conformance(format!("weights read failed: {e}")))?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(hasher.finalize().to_hex().to_string())
}
/// Prove **local, offline inference**: run a local engine against local weights
/// with no network grant, meter it on this host's own energy counters, and seal
/// a cell.
///
/// This is the deliberate inverse of [`prove_inference`]. There, the silicon
/// belongs to somebody else, so the energy is an honest token `Estimator` and
/// the call goes over the wire. Here the silicon is the prover's own, which is
/// the one case where the joules can actually be *measured* — so the device
/// records come straight off [`PowerMeter`] with whatever provenance the
/// hardware honestly supports (`HwShunt` on Apple Silicon IOReport and Linux
/// RAPL/NVML; `Estimator` where no counter exists). The meter can degrade; it
/// does not invent a reading, and this function does not re-tag one.
///
/// Three properties the cell attests, each checked rather than asserted:
///
/// 1. **The weights are local.** They are opened from disk and streamed into a
/// BLAKE3 that is bound into the proof. A path that does not resolve is an
/// error, not a warning.
/// 2. **No egress was granted.** The run's [`NetworkGrant`] is the default —
/// empty outbound allowlist, inbound false — and that emptiness is asserted
/// before the engine is spawned. Deny-by-default is the standard's posture;
/// this makes it a checked precondition of the proof.
/// 3. **The energy is this host's.** Measured over the engine's execution span
/// by the best counter the platform offers.
///
/// The engine is supplied by the operator and driven as a subprocess, exactly
/// as [`prove_accelerator`] drives a GPU workload. That keeps the sandbox
/// standard free of any dependency on a particular inference runtime — this
/// proves *local inference*, not one vendor's loader.
pub fn prove_local(
inputs: &LocalInferenceInputs,
cloud: &str,
at_unix_us: u64,
proof_key: &SigningKey,
) -> Result<SignedProofCell, ProofError> {
use ed25519_dalek::SigningKey as Sk;
use sandbox_attest::{DeviceIdentity, Outcome};
use sandbox_capability::{ComputeGrant, Grants};
use sandbox_meter::PowerMeter;
use sandbox_runtime::{grants_content_id, unit_content_id, Host, RunReport};
let report = sandbox_conformance::run();
let conformance: Vec<ProofClass> = report
.results
.iter()
.map(|r| ProofClass {
class: r.class.to_string(),
passed: r.passed,
})
.collect();
if !report.ok() {
return Err(ProofError::Conformance("in-code pack did not pass".into()));
}
// (1) Weights are local, and the proof is bound to their exact bytes.
let weights_path = std::path::Path::new(&inputs.weights);
if !weights_path.is_file() {
return Err(ProofError::Conformance(format!(
"weights not present locally: {}",
inputs.weights
)));
}
let weights_bytes = std::fs::metadata(weights_path)
.map(|m| m.len())
.unwrap_or(0);
let weights_hash = hash_file(weights_path)?;
// (2) Deny-by-default egress, asserted rather than assumed.
let grants = Grants {
compute: ComputeGrant {
devices: vec![DeviceClass::Cpu, DeviceClass::Gpu, DeviceClass::Npu],
energy_budget_uj: u64::MAX,
cpu_time_ms: 600_000,
memory_bytes: 64u64 << 30,
},
..Grants::default()
};
if !grants.network.outbound.is_empty() || grants.network.inbound {
return Err(ProofError::Conformance(
"local proof requires a zero-egress network grant".into(),
));
}
// (3) Meter this host's own counters across the engine's execution.
let mut meter = PowerMeter::detect();
let sources = meter.sources().join(",");
let is_hw = meter.is_hardware();
let (status, measurement) = meter.measure(|| {
std::process::Command::new(&inputs.engine)
.args(&inputs.args)
.status()
});
let status = status.map_err(|e| {
ProofError::Run(
sandbox_runtime::ExecError::Unavailable(format!("spawn {}: {e}", inputs.engine)).into(),
)
})?;
if !status.success() {
return Err(ProofError::Run(
sandbox_runtime::ExecError::Unavailable(format!(
"engine exited with {status}; refusing to seal a proof for a run that failed"
))
.into(),
));
}
// Device records pass through verbatim — the meter's provenance is the
// proof's provenance. Re-tagging here is exactly the lie the ladder exists
// to prevent.
// One identity per metered class — SPEC §7.2 requires every device that
// reports energy to be named. The meter decides which classes it can see
// (on Apple Silicon that is CPU, GPU and ANE together), so the identity
// list is derived from the measurement rather than assumed.
let device_identity: Vec<DeviceIdentity> = measurement
.devices
.iter()
.map(|d| DeviceIdentity {
class: d.class,
model: std::env::consts::ARCH.to_string(),
driver: sources.clone(),
})
.collect();
let run = RunReport {
device_energy: measurement.devices.clone(),
device_identity,
outcome: Outcome::Completed,
journal: None,
storage: None,
};
let host = Host::new(
"sandbox-proof",
"steward.proof",
Sk::from_bytes(&[51u8; 32]),
Sk::from_bytes(&[52u8; 32]),
);
let (receipt_vk, supervisor_vk) = host.verifying_keys();
// The image identity *is* the weights: two runs over different weights are
// different units, and the content id says so.
let image_id = ContentId::of_bytes(
format!("sandbox-local-inference:{weights_hash}").as_bytes(),
);
let grants_id = grants_content_id(&grants);
let unit_id = unit_content_id(&image_id, &grants_id);
let sealed = host.seal_run(
unit_id,
image_id.clone(),
&grants,
Tier::S0,
run,
1,
[0; 8],
at_unix_us,
at_unix_us,
)?;
sealed
.receipt
.verify_sealed(&sealed.receipt_envelope, &receipt_vk, Some("steward.proof"))
.map_err(|e| ProofError::Crypto(format!("receipt: {e}")))?;
sealed
.attestation
.verify(&sealed.attestation_sig, &supervisor_vk)
.map_err(|e| ProofError::Crypto(format!("attestation: {e}")))?;
eprintln!(
" local: {} ({:.2} GiB, b3:{}…) — {} over {:.1}s, avg {:.1} W [{}]",
weights_path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default(),
weights_bytes as f64 / (1u64 << 30) as f64,
&weights_hash[..12],
if is_hw { "measured" } else { "ESTIMATED" },
measurement.wall.as_secs_f64(),
measurement.avg_watts(),
sources,
);
let hardware: Vec<DeviceClass> = sealed.receipt.devices.iter().map(|d| d.class).collect();
let attestation_id = sealed
.attestation
.content_id()
.map_err(|e| ProofError::Crypto(format!("attestation id: {e}")))?;
let cell = ProofCell {
profile: PROFILE.to_string(),
substrate: Substrate::BareMetal,
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
cloud: cloud.to_string(),
hardware,
tier: Tier::S0,
conformance,
attestation_id,
receipt_id: sealed.receipt.content_id(),
total_estimated_uj: sealed.receipt.total.estimated_uj,
available_substrates: available_substrates(),
host_id: "sandbox-proof".to_string(),
at_unix_us,
};
sign(cell, proof_key)
}
#[cfg(test)]
mod tests {
use super::*;
@ -1094,4 +1333,76 @@ mod tests {
12 * J_IN_UJ + 5 * J_OUT_UJ
);
}
// ── local (air-gapped) inference proofs ──────────────────────────
fn local_inputs(engine: &str, weights: &str) -> LocalInferenceInputs {
LocalInferenceInputs {
engine: engine.to_string(),
args: vec![],
weights: weights.to_string(),
}
}
#[test]
fn local_proof_refuses_absent_weights() {
// An air-gap proof whose weights are not actually on this disk is
// exactly the claim that must not be sealable.
let sk = SigningKey::from_bytes(&[9u8; 32]);
let r = prove_local(
&local_inputs("/bin/true", "/nonexistent/model.gguf"),
"local",
0,
&sk,
);
match r {
Err(ProofError::Conformance(m)) => assert!(m.contains("not present locally"), "{m}"),
other => panic!("expected a conformance refusal, got {other:?}"),
}
}
#[test]
fn local_proof_refuses_a_failed_engine() {
// Sealing a proof for a run that exited non-zero would attest to work
// that did not happen.
let dir = std::env::temp_dir().join("sandbox-proof-local-test");
std::fs::create_dir_all(&dir).unwrap();
let w = dir.join("weights.bin");
std::fs::write(&w, b"not a real model, but really on disk").unwrap();
let sk = SigningKey::from_bytes(&[9u8; 32]);
let r = prove_local(
&local_inputs("/bin/false", w.to_str().unwrap()),
"local",
0,
&sk,
);
assert!(
matches!(r, Err(ProofError::Run(_))),
"expected a run refusal, got {r:?}"
);
}
#[test]
fn local_proof_binds_the_exact_weight_bytes() {
// Two different models must not produce the same unit identity.
let dir = std::env::temp_dir().join("sandbox-proof-local-hash");
std::fs::create_dir_all(&dir).unwrap();
let a = dir.join("a.bin");
let b = dir.join("b.bin");
std::fs::write(&a, b"weights-a").unwrap();
std::fs::write(&b, b"weights-b").unwrap();
let ha = hash_file(&a).unwrap();
let hb = hash_file(&b).unwrap();
assert_ne!(ha, hb);
assert_eq!(ha, hash_file(&a).unwrap(), "hash must be stable");
assert_eq!(ha.len(), 64, "blake3 hex");
}
#[test]
fn local_proof_grant_is_zero_egress() {
// The posture the proof asserts before spawning anything.
let g = sandbox_capability::Grants::default();
assert!(g.network.outbound.is_empty());
assert!(!g.network.inbound);
}
}

View file

@ -21,6 +21,7 @@ fn main() {
match cmd {
"prove" => prove(&path),
"prove-s5" => prove_s5(&path),
"prove-local" => prove_local(&path),
"prove-gpu" => prove_gpu(&path),
"prove-lpu" => prove_lpu(&path),
"prove-s6" => prove_s6(&path),
@ -131,6 +132,48 @@ fn prove_s5(path: &str) {
println!("\n appended to {path}");
}
/// Prove local, offline inference: run a local engine against local weights
/// with no network grant, metered on this host's own energy counters.
fn prove_local(path: &str) {
let env = |k: &str, d: &str| std::env::var(k).unwrap_or_else(|_| d.to_string());
let weights = env("SANDBOX_LOCAL_WEIGHTS", "");
if weights.is_empty() {
eprintln!("set SANDBOX_LOCAL_WEIGHTS=/path/to/model.gguf");
std::process::exit(2);
}
let engine = env("SANDBOX_LOCAL_ENGINE", "");
if engine.is_empty() {
eprintln!("set SANDBOX_LOCAL_ENGINE=/path/to/inference-binary");
std::process::exit(2);
}
let args: Vec<String> = std::env::var("SANDBOX_LOCAL_ARGS")
.ok()
.map(|s| s.split_whitespace().map(String::from).collect())
.unwrap_or_else(|| vec![weights.clone()]);
let inputs = sandbox_proof::LocalInferenceInputs { engine, args, weights };
let cloud = env("SANDBOX_CLOUD", "local");
let cell = match sandbox_proof::prove_local(&inputs, &cloud, now_us(), &proof_key()) {
Ok(c) => c,
Err(e) => {
eprintln!("local proof failed: {e}");
std::process::exit(1);
}
};
if let Err(e) = Matrix::append(path, &cell) {
eprintln!("could not append to {path}: {e}");
std::process::exit(1);
}
let c = &cell.cell;
println!("Sandbox proof — local inference (offline, zero egress)\n");
println!(" substrate: {}", c.substrate.label());
println!(" os / arch: {} / {}", c.os, c.arch);
println!(" tier: {}", c.tier.wire_tag());
println!(" attestation: {}", c.attestation_id);
println!(" receipt: {} ({} uj)", c.receipt_id, c.total_estimated_uj);
println!(" signature: {} (verified \u{2713})", &cell.signature_hex[..16]);
println!("\n appended to {path}");
}
/// Prove the accelerator (GPU) fabric: run a GPU workload, meter it via
/// nvidia-smi, seal a cell with a separate Gpu energy record + GPU identity.
fn prove_gpu(path: &str) {