open-standards/jouleclaw/jouleclaw-rs/crates/jouleclaw-cascade/tests/r1_cascade.rs
Transaction Science fce6e6eb98 cascade: the egress contract — per-resolution, and never stronger than evidence
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)
2026-08-28 08:44:08 -04:00

392 lines
13 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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());
}