Closes §5 step 4, the last item in the blueprint's build sequence. This is the
gap the frontier survey filed as "nobody has shipped a privacy router": a system
that decides between on-device and remote execution and never tells the user
which side their request landed on.
## The design turns on the default
`Egress::Undeclared` is the default, and everything else follows from it. A tier
that has not declared where its work goes is recorded as undeclared — never as
"nothing left the device". Not knowing and knowing nothing happened are
different facts, and only the second is a privacy claim.
Undeclared no declaration; NOT a claim, and not publishable
None resolved on-device
AttestedEnclave left the device to a peer that attested itself
ThirdParty left the device unattested — named, counted, and
honestly marked unverifiable
`is_publishable()` refuses `Undeclared`, so a receipt cannot be built from a
resolution nobody measured. `Undeclared` and `None` both report zero bytes,
which is precisely why the class must be read rather than the byte count — a
test pins that.
## The runtime stamps it, not the tier
`Tier::egress()` is a defaulted trait method, matching the existing `coord()`
precedent so legacy tiers keep compiling. But the tier does not write the value
into its own `Answer`: the runtime reads the declaration and stamps it onto the
trace it returns. A tier therefore cannot forge the claim by construction, and
cannot silently omit it either.
Both dispatch paths needed the stamp on the *runtime's* trace rather than the
tier's, because both return `Answer { trace, ..answer }` — the spread discards
the tier's copy. That is exactly the sort of thing a test catches and a design
review does not; `a_local_cache_hit_is_recorded_as_local` failed on the first
attempt for this reason.
L0 is the one tier in the base cascade entitled to claim `None`: a hit is served
from this process's own memory.
## Placement
The record lives on `ExecutionTrace`, which already derives `Default` and has a
single literal construction site — so the change lands in 4 files and 201 lines
with no churn. Putting it on `Answer` would have required touching 12 struct
literals plus test files; an earlier attempt at that also tripped `cargo fmt`,
which reformatted 22 unrelated files because the crate was never rustfmt-clean.
Reverted, and the field moved to where it fits.
The dependency direction is preserved: the cascade gains its own egress concept
and takes no dependency on AR-1. Mapping `Egress` onto an Action Receipt's
egress binding is the envelope's job, as it should be.
108 tests green in jouleclaw-cascade.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
(cherry picked from commit 95f1c00238)
392 lines
13 KiB
Rust
392 lines
13 KiB
Rust
//! R1 tests — L0 cache and cascade walker.
|
||
//!
|
||
//! The load-bearing tests:
|
||
//! 1. L0 cache hit returns the right answer.
|
||
//! 2. L0 hit is orders of magnitude cheaper than L4 dispatch.
|
||
//! 3. Cascade falls through L0 miss to L4 and records the answer
|
||
//! in the cache; the second identical query hits L0.
|
||
|
||
use jouleclaw_cascade::*;
|
||
|
||
// ============================================================
|
||
// A mock L4 tier for testing
|
||
// ============================================================
|
||
|
||
/// A tier that pretends to be L4 — answers every query with a fixed
|
||
/// string at a fixed (expensive) cost. Used to demonstrate the joule
|
||
/// difference between L0 hits and L4 dispatch.
|
||
struct MockL4 {
|
||
cost_joules: f64,
|
||
confidence: f32,
|
||
answer_text: String,
|
||
call_count: u32,
|
||
}
|
||
|
||
impl MockL4 {
|
||
fn new(cost_joules: f64, answer_text: impl Into<String>) -> Self {
|
||
Self {
|
||
cost_joules,
|
||
confidence: 0.95,
|
||
answer_text: answer_text.into(),
|
||
call_count: 0,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Tier for MockL4 {
|
||
fn id(&self) -> TierId {
|
||
TierId::L4(L4ModelId(0))
|
||
}
|
||
|
||
fn estimate_cost(&self, _q: &Query) -> Option<TierEstimate> {
|
||
Some(TierEstimate {
|
||
joules: self.cost_joules,
|
||
latency: std::time::Duration::from_millis(100),
|
||
confidence_floor: self.confidence,
|
||
})
|
||
}
|
||
|
||
fn try_answer(
|
||
&mut self,
|
||
_q: &Query,
|
||
_budget: f64,
|
||
) -> Result<Answer, AnswerError> {
|
||
self.call_count += 1;
|
||
Ok(Answer {
|
||
output: AnswerOutput::Text(self.answer_text.clone()),
|
||
tier_used: self.id(),
|
||
joules_spent: self.cost_joules,
|
||
confidence: self.confidence,
|
||
trace: ExecutionTrace::default(),
|
||
verification: crate::verification::VerificationStatus::Resolved,
|
||
})
|
||
}
|
||
}
|
||
|
||
fn text_query(text: &str) -> Query {
|
||
Query {
|
||
input: QueryInput::Text(text.to_string()),
|
||
budget: JouleBudget::standard(),
|
||
quality: QualityFloor::chat(),
|
||
context: ContextRef::fresh(),
|
||
deadline: None,
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// L0Cache unit tests
|
||
// ============================================================
|
||
|
||
#[test]
|
||
fn l0_empty_cache_refuses_all() {
|
||
let mut cache = L0Cache::new();
|
||
let q = text_query("hello");
|
||
let a = cache.try_answer(&q, 1.0).unwrap();
|
||
assert!(matches!(a.output, AnswerOutput::Refused(_)));
|
||
assert_eq!(a.tier_used, TierId::L0);
|
||
assert_eq!(cache.stats().hits, 0);
|
||
assert_eq!(cache.stats().misses, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn l0_put_then_get_hits() {
|
||
let mut cache = L0Cache::new();
|
||
let q = text_query("what is 2+2?");
|
||
let answer = Answer {
|
||
output: AnswerOutput::Text("4".to_string()),
|
||
tier_used: TierId::L1(L1Primitive::Execute),
|
||
joules_spent: 1e-7,
|
||
confidence: 1.0,
|
||
trace: ExecutionTrace::default(),
|
||
verification: crate::verification::VerificationStatus::Resolved,
|
||
};
|
||
cache.put(&q, &answer);
|
||
assert_eq!(cache.len(), 1);
|
||
|
||
let a = cache.try_answer(&q, 1.0).unwrap();
|
||
assert_eq!(a.output, AnswerOutput::Text("4".to_string()));
|
||
assert_eq!(a.tier_used, TierId::L0); // tier_used is L0, not the originator
|
||
assert_eq!(a.confidence, 1.0);
|
||
assert_eq!(cache.stats().hits, 1);
|
||
assert_eq!(cache.stats().misses, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn l0_same_text_different_context_misses() {
|
||
let mut cache = L0Cache::new();
|
||
let mut q1 = text_query("hello");
|
||
cache.put(&q1, &Answer {
|
||
output: AnswerOutput::Text("world".to_string()),
|
||
tier_used: TierId::L4(L4ModelId(0)),
|
||
joules_spent: 1.0,
|
||
confidence: 0.9,
|
||
trace: ExecutionTrace::default(),
|
||
verification: crate::verification::VerificationStatus::Resolved,
|
||
});
|
||
|
||
// Change context fingerprint.
|
||
q1.context.history_fingerprint.0[0] = 1;
|
||
let a = cache.try_answer(&q1, 1.0).unwrap();
|
||
assert!(matches!(a.output, AnswerOutput::Refused(_)),
|
||
"different context fingerprint should miss");
|
||
}
|
||
|
||
#[test]
|
||
fn l0_hit_cost_is_independent_of_call_count() {
|
||
// The cost model says lookup is O(1) in calls, not amortized.
|
||
let mut cache = L0Cache::new();
|
||
let q = text_query("test");
|
||
cache.put(&q, &Answer {
|
||
output: AnswerOutput::Text("answer".to_string()),
|
||
tier_used: TierId::L4(L4ModelId(0)),
|
||
joules_spent: 1.0,
|
||
confidence: 0.9,
|
||
trace: ExecutionTrace::default(),
|
||
verification: crate::verification::VerificationStatus::Resolved,
|
||
});
|
||
let c1 = cache.try_answer(&q, 1.0).unwrap().joules_spent;
|
||
let c2 = cache.try_answer(&q, 1.0).unwrap().joules_spent;
|
||
let c100 = (0..100).map(|_| cache.try_answer(&q, 1.0).unwrap().joules_spent).last().unwrap();
|
||
assert_eq!(c1, c2);
|
||
assert_eq!(c1, c100);
|
||
}
|
||
|
||
#[test]
|
||
fn l0_cost_scales_with_input_length() {
|
||
let cache = L0Cache::new();
|
||
let short = text_query("hi");
|
||
let long = text_query(&"x".repeat(10000));
|
||
let short_est = cache.estimate_cost(&short).unwrap().joules;
|
||
let long_est = cache.estimate_cost(&long).unwrap().joules;
|
||
assert!(long_est > short_est,
|
||
"long input should cost more to hash: short={:.3e}, long={:.3e}",
|
||
short_est, long_est);
|
||
}
|
||
|
||
#[test]
|
||
fn l0_key_is_deterministic() {
|
||
let q = text_query("deterministic query");
|
||
let k1 = L0Cache::key_for(&q);
|
||
let k2 = L0Cache::key_for(&q);
|
||
let k3 = L0Cache::key_for(&q);
|
||
assert_eq!(k1, k2);
|
||
assert_eq!(k2, k3);
|
||
}
|
||
|
||
#[test]
|
||
fn l0_keys_differ_for_different_inputs() {
|
||
let q1 = text_query("a");
|
||
let q2 = text_query("b");
|
||
assert_ne!(L0Cache::key_for(&q1), L0Cache::key_for(&q2));
|
||
}
|
||
|
||
// ============================================================
|
||
// Cascade walker tests
|
||
// ============================================================
|
||
|
||
#[test]
|
||
fn runtime_falls_through_empty_cache_to_l4() {
|
||
let mut cascade = Cascade::new();
|
||
// Runtime owns the L0 cache; tests don't register it.
|
||
cascade.register(Box::new(MockL4::new(0.5, "Paris")));
|
||
let mut rt = Runtime::new(cascade);
|
||
|
||
let q = text_query("capital of France?");
|
||
let a = rt.answer(q).unwrap();
|
||
assert_eq!(a.output, AnswerOutput::Text("Paris".to_string()));
|
||
assert_eq!(a.tier_used, TierId::L4(L4ModelId(0)));
|
||
// Trace shows L0 refused (Inapplicable on miss), then L4 hit.
|
||
assert_eq!(a.trace.attempts.len(), 2);
|
||
assert_eq!(a.trace.attempts[0].tier, TierId::L0);
|
||
assert!(matches!(a.trace.attempts[0].outcome, TraceOutcome::Refused(_)));
|
||
assert_eq!(a.trace.attempts[1].tier, TierId::L4(L4ModelId(0)));
|
||
assert_eq!(a.trace.attempts[1].outcome, TraceOutcome::Hit);
|
||
}
|
||
|
||
/// THE proof: same query twice, second is L0 hit, ~10^6× cheaper.
|
||
#[test]
|
||
fn second_query_hits_cache_and_is_orders_of_magnitude_cheaper() {
|
||
let mock_l4_cost = 0.5; // 500 mJ for L4 (realistic for a 7B local model)
|
||
|
||
let mut cascade = Cascade::new();
|
||
// Runtime auto-creates L0 internally; we only register L4.
|
||
cascade.register(Box::new(MockL4::new(mock_l4_cost, "Paris")));
|
||
let mut rt = Runtime::new(cascade);
|
||
|
||
let q = text_query("capital of France?");
|
||
|
||
// First call: L0 miss → falls through to L4. The runtime
|
||
// auto-records the L4 answer to L0 for next time.
|
||
let a1 = rt.answer(q.clone()).unwrap();
|
||
assert_eq!(a1.tier_used, TierId::L4(L4ModelId(0)));
|
||
let l4_cost = a1.joules_spent;
|
||
assert!(l4_cost >= mock_l4_cost,
|
||
"first call should cost at least L4's price: {:.3e}", l4_cost);
|
||
|
||
// Second call: L0 hit.
|
||
let a2 = rt.answer(q).unwrap();
|
||
assert_eq!(a2.tier_used, TierId::L0,
|
||
"second identical query must hit L0; got {:?}", a2.tier_used);
|
||
assert_eq!(a2.output, AnswerOutput::Text("Paris".to_string()));
|
||
|
||
let l0_cost = a2.joules_spent;
|
||
|
||
// The actual claim: orders of magnitude cheaper. L4 ≈ 500 mJ, L0 ≈
|
||
// tens of nJ → ratio ~10^7. We require at least 10^4.
|
||
let ratio = l4_cost / l0_cost;
|
||
assert!(ratio > 1e4,
|
||
"L0 hit should be at least 10^4× cheaper than L4: \
|
||
L4 cost {:.3e} J, L0 cost {:.3e} J, ratio {:.1e}×",
|
||
l4_cost, l0_cost, ratio);
|
||
}
|
||
|
||
#[test]
|
||
fn runtime_with_only_l0_and_empty_cache_returns_budget_exhausted() {
|
||
// No external tiers, but the runtime has its own L0 (which will
|
||
// miss). Since L0 miss is treated as Inapplicable (not a refusal
|
||
// we count), the runtime falls through with no tiers attempted.
|
||
let cascade = Cascade::new();
|
||
let mut rt = Runtime::new(cascade);
|
||
|
||
let q = text_query("anything");
|
||
let result = rt.answer(q);
|
||
// With no external tiers, the runtime has nothing to dispatch to
|
||
// after L0 misses. We get BudgetExhausted (attempted_tiers empty).
|
||
assert!(matches!(result, Err(AnswerError::BudgetExhausted { .. })),
|
||
"L0 miss with no fallback should produce BudgetExhausted; got {:?}",
|
||
result);
|
||
}
|
||
|
||
#[test]
|
||
fn runtime_respects_budget_and_skips_unaffordable_tier() {
|
||
let mut cascade = Cascade::new();
|
||
cascade.register(Box::new(MockL4::new(10.0, "expensive answer")));
|
||
let mut rt = Runtime::new(cascade);
|
||
|
||
// Budget is too small for L4.
|
||
let q = Query {
|
||
input: QueryInput::Text("x".to_string()),
|
||
budget: JouleBudget { hard_limit: 1.0, soft_target: 0.1 },
|
||
quality: QualityFloor::chat(),
|
||
context: ContextRef::fresh(),
|
||
deadline: None,
|
||
};
|
||
|
||
let result = rt.answer(q);
|
||
assert!(matches!(result, Err(AnswerError::BudgetExhausted { .. })),
|
||
"budget too small for L4 should produce BudgetExhausted; got {:?}",
|
||
result);
|
||
}
|
||
|
||
#[test]
|
||
fn runtime_respects_quality_floor() {
|
||
let mut cascade = Cascade::new();
|
||
// L4 with confidence_floor = 0.5
|
||
let mut weak_l4 = MockL4::new(0.5, "uncertain answer");
|
||
weak_l4.confidence = 0.5;
|
||
cascade.register(Box::new(weak_l4));
|
||
let mut rt = Runtime::new(cascade);
|
||
|
||
// Query demanding 0.9 confidence — weak L4 should be skipped.
|
||
let q = Query {
|
||
input: QueryInput::Text("x".to_string()),
|
||
budget: JouleBudget::standard(),
|
||
quality: QualityFloor { min_confidence: 0.9, accept_partial: false },
|
||
context: ContextRef::fresh(),
|
||
deadline: None,
|
||
};
|
||
|
||
let result = rt.answer(q);
|
||
assert!(matches!(result, Err(AnswerError::BudgetExhausted { .. })),
|
||
"quality floor should skip the weak tier; got {:?}", result);
|
||
}
|
||
|
||
// ============================================================
|
||
// Demo
|
||
// ============================================================
|
||
|
||
#[test]
|
||
fn cascade_demo() {
|
||
println!("\n=== R1: L0 cache fires in the cascade ===\n");
|
||
|
||
let l4_cost = 0.5; // 500 mJ
|
||
let mut cascade = Cascade::new();
|
||
cascade.register(Box::new(MockL4::new(l4_cost, "Paris")));
|
||
let mut rt = Runtime::new(cascade);
|
||
|
||
let q = text_query("capital of France?");
|
||
|
||
println!("Query: \"capital of France?\"");
|
||
println!("Budget: 1 J hard / 100 mJ soft");
|
||
println!();
|
||
|
||
println!("--- First call ---");
|
||
let a1 = rt.answer(q.clone()).unwrap();
|
||
print_trace(&a1);
|
||
|
||
println!("\n--- Second call (identical query) ---");
|
||
let a2 = rt.answer(q).unwrap();
|
||
print_trace(&a2);
|
||
|
||
let ratio = a1.joules_spent / a2.joules_spent;
|
||
println!("\nL0 hit is {:.1e}× cheaper than the L4 dispatch.", ratio);
|
||
println!("Same answer, near-zero joule cost. This is the cascade.");
|
||
}
|
||
|
||
fn print_trace(a: &Answer) {
|
||
println!(" tier_used: {:?}", a.tier_used);
|
||
println!(" answer: {:?}", a.output);
|
||
println!(" joules: {:.3e} J", a.joules_spent);
|
||
println!(" confidence: {}", a.confidence);
|
||
println!(" attempts:");
|
||
for entry in &a.trace.attempts {
|
||
println!(" {:<20} {:?} ({:.3e} J)",
|
||
format!("{:?}", entry.tier), entry.outcome, entry.joules);
|
||
}
|
||
}
|
||
|
||
|
||
// ── egress contract (AR-1 §3) ────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn runtime_stamps_the_answering_tier_egress_onto_the_trace() {
|
||
use jouleclaw_cascade::types::Egress;
|
||
|
||
// MockL4 does not override `egress()`, so it is Undeclared — and the point
|
||
// is that this is NOT silently reported as "nothing left the device".
|
||
let mut cascade = Cascade::new();
|
||
cascade.register(Box::new(MockL4::new(0.5, "Paris")));
|
||
let mut rt = Runtime::new(cascade);
|
||
|
||
let a = rt.answer(text_query("capital of France?")).unwrap();
|
||
assert_eq!(a.tier_used, TierId::L4(L4ModelId(0)));
|
||
assert_eq!(
|
||
a.trace.egress,
|
||
Egress::Undeclared,
|
||
"a tier that never declared its egress must not be recorded as local"
|
||
);
|
||
assert!(
|
||
!a.trace.egress.is_publishable(),
|
||
"Undeclared carries no evidence, so it cannot become a privacy claim"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_local_cache_hit_is_recorded_as_local() {
|
||
use jouleclaw_cascade::types::Egress;
|
||
|
||
// L0 overrides `egress()` with None because a hit is served from this
|
||
// process's own memory. The runtime stamps it — the tier does not write it
|
||
// into its own Answer — so a tier cannot forge the claim by construction.
|
||
let mut cascade = Cascade::new();
|
||
cascade.register(Box::new(MockL4::new(0.5, "Paris")));
|
||
let mut rt = Runtime::new(cascade);
|
||
|
||
let q = text_query("capital of France?");
|
||
let _ = rt.answer(q.clone()).unwrap(); // miss → L4 populates the cache
|
||
let hit = rt.answer(q).unwrap(); // hit → served by L0
|
||
|
||
assert_eq!(hit.tier_used, TierId::L0);
|
||
assert_eq!(hit.trace.egress, Egress::None);
|
||
assert!(hit.trace.egress.is_publishable());
|
||
}
|