Receipts as a training corpus + prove-local: attested offline inference #31
11 changed files with 1509 additions and 315 deletions
769
eoc/eoc-rs/Cargo.lock
generated
769
eoc/eoc-rs/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
13
jouleclaw/jouleclaw-rs/Cargo.lock
generated
13
jouleclaw/jouleclaw-rs/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1147,6 +1147,7 @@ members = [
|
|||
# top of jouleclaw-otel's SpanData.
|
||||
"crates/jouleclaw-otel-authz",
|
||||
"crates/jouleclaw-jcp",
|
||||
"crates/jouleclaw-corpus",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
|
|
|||
20
jouleclaw/jouleclaw-rs/crates/jouleclaw-corpus/Cargo.toml
Normal file
20
jouleclaw/jouleclaw-rs/crates/jouleclaw-corpus/Cargo.toml
Normal 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 }
|
||||
|
|
@ -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}");
|
||||
}
|
||||
607
jouleclaw/jouleclaw-rs/crates/jouleclaw-corpus/src/lib.rs
Normal file
607
jouleclaw/jouleclaw-rs/crates/jouleclaw-corpus/src/lib.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
1
sandbox/sandbox-rs/Cargo.lock
generated
1
sandbox/sandbox-rs/Cargo.lock
generated
|
|
@ -1874,6 +1874,7 @@ dependencies = [
|
|||
name = "sandbox-proof"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"ed25519-dalek",
|
||||
"hex",
|
||||
"sandbox-attest",
|
||||
|
|
|
|||
|
|
@ -26,4 +26,5 @@ serde = { workspace = true }
|
|||
serde_json.workspace = true
|
||||
ureq.workspace = true
|
||||
hex.workspace = true
|
||||
blake3.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue