open-standards/jouleclaw/jouleclaw-rs/crates/jouleclaw-cascade/tests/r46_escalation_gate.rs
Transaction Science 3774b19ba8 jouleclaw-cascade + -stack: wire calibrated EscalationGate into the cascade walker
Wave-10 shipped `jouleclaw-calibration` as standalone arithmetic
(Platt + split-conformal + `EscalationGate` trait); this commit
wires it into the live cascade walker so the calibrated escalation
gate is now load-bearing in `Runtime::answer`, closing the
build-plan A1 correctness gap end-to-end.

## What changes

**`jouleclaw-cascade`** — new escalation seam in `Runtime`:

- `pub type EscalationFn = Box<dyn Fn(f32) -> bool + Send + Sync>`.
  Closure-based on purpose so the cascade crate stays leaf-free of
  the calibration crate (it's the bottom of the workspace).
- `Runtime.escalation_hook: Option<EscalationFn>` field, threaded
  through every existing constructor (`new`, `new_with_history`,
  `new_with_router`, `new_full`, `new_without_l0`).
- `Runtime::with_escalation_hook(EscalationFn)` — owned-self builder.
- `Runtime::install_escalation_hook(&mut self, EscalationFn)` —
  `&mut self` variant so higher-level wrappers that hold a `Runtime`
  in a `pub` field (notably `JouleClawStack`) can install without
  `mem::replace`.
- `Runtime::has_escalation_hook()` — diagnostic.

Walker change in `Runtime::answer` (the only place that fires): when
a synthesis-class tier (`JouleClass::Model` | `Wire`) returns a
non-refused answer, the hook is consulted on `answer.confidence`. A
`false` verdict converts the answer into a
`RefusalReason::LowConfidence(c)` and continues the walk to the next
tier. The tier's joules still charge against the budget — the work
was done, gating doesn't un-spend it.

Deterministic tiers (`Cache | Lawful | Embed`) are NEVER gated.
Their correctness is structural, not stochastic.

**`jouleclaw-stack`** — typed wrapper:

- `JouleClawStack::with_escalation_gate<G: EscalationGate>(self, gate)`
  — install any `jouleclaw_calibration::EscalationGate` impl
  (including `CalibratedGate`) on the underlying runtime. Generic so
  consumers pass their own gates without boxing at the call site.

## Tests

- `jouleclaw-cascade/tests/r46_escalation_gate.rs` — 6 integration
  tests:
  - `without_hook_legacy_behaviour_first_non_refused_wins` — gate-
    free path is unchanged.
  - `hook_rejects_low_confidence_l3_and_walk_continues_to_l4` — the
    load-bearing escalation flow.
  - `hook_accepts_high_confidence_answer_and_stops_walk`.
  - `refused_walk_records_low_confidence_in_the_trace` — the trace
    entry on the refused L3 carries `RefusalReason::LowConfidence`,
    so a downstream Receipt-builder / dashboard sees WHY the
    escalation fired.
  - `hook_does_not_gate_l0_cache_hits` (well — L0 isn't synthesis-
    class; this exercises that the joule_class gate-filter works).
  - `has_escalation_hook_reports_install_state`.
- `jouleclaw-stack` lib tests +2 covering the typed wrapper:
  install via custom impl, install via fitted `CalibratedGate`.

## Workspace

Total: **2,462 lib tests passing across 102 crates**, 0 failures.

This commit closes the *active site fidelity* gap the May-31 strategy
doc called the prerequisite for propagation: with calibrated routing
live in the walker, the cascade's joule savings now have a
correctness guarantee, not just a heuristic threshold.
2026-06-01 22:41:20 -04:00

233 lines
9.2 KiB
Rust

//! R46 — calibrated escalation gate.
//!
//! Closes the load-bearing correctness gap the 5-lens cross-talk audit
//! AND the May-31 strategy doc both named: *"the cascade is silent on
//! how a tier knows it failed."* Now the runtime consults an
//! `EscalationFn` after every synthesis-class tier answer:
//!
//! - hook returns `true` ⇒ accept the tier's answer (legacy behaviour)
//! - hook returns `false` ⇒ escalate (treat as `LowConfidence` refusal,
//! walk to the next tier; the tier's joules still charge against the
//! budget — the work was done)
//!
//! Deterministic tiers (`Cache | Lawful | Embed`) are NEVER gated —
//! their correctness is structural, not stochastic.
use jouleclaw_cascade::*;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::time::Duration;
// ─────────────────────────────────────────────────────────────────────
// Mock tiers
// ─────────────────────────────────────────────────────────────────────
struct MockL3 {
cost_joules: f64,
confidence: f32,
answer_text: String,
calls: Arc<AtomicU32>,
}
impl MockL3 {
fn new(cost: f64, conf: f32, text: impl Into<String>, calls: Arc<AtomicU32>) -> Self {
Self {
cost_joules: cost,
confidence: conf,
answer_text: text.into(),
calls,
}
}
}
impl Tier for MockL3 {
fn id(&self) -> TierId {
TierId::L3(L3ModelId(0))
}
fn estimate_cost(&self, _q: &Query) -> Option<TierEstimate> {
Some(TierEstimate {
joules: self.cost_joules,
latency: Duration::from_millis(40),
confidence_floor: self.confidence,
})
}
fn try_answer(&mut self, _q: &Query, _budget: f64) -> Result<Answer, AnswerError> {
self.calls.fetch_add(1, Ordering::SeqCst);
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: jouleclaw_cascade::verification::VerificationStatus::Resolved,
})
}
}
struct MockL4 {
cost_joules: f64,
confidence: f32,
answer_text: String,
calls: Arc<AtomicU32>,
}
impl MockL4 {
fn new(cost: f64, conf: f32, text: impl Into<String>, calls: Arc<AtomicU32>) -> Self {
Self {
cost_joules: cost,
confidence: conf,
answer_text: text.into(),
calls,
}
}
}
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: Duration::from_millis(100),
confidence_floor: self.confidence,
})
}
fn try_answer(&mut self, _q: &Query, _budget: f64) -> Result<Answer, AnswerError> {
self.calls.fetch_add(1, Ordering::SeqCst);
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: jouleclaw_cascade::verification::VerificationStatus::Resolved,
})
}
}
fn make_query(text: &str) -> Query {
Query {
input: QueryInput::Text(text.to_string()),
budget: JouleBudget::expensive(),
quality: QualityFloor::any(),
context: ContextRef::fresh(),
deadline: None,
}
}
// ─────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────
#[test]
fn without_hook_legacy_behaviour_first_non_refused_wins() {
let l3_calls = Arc::new(AtomicU32::new(0));
let l4_calls = Arc::new(AtomicU32::new(0));
let mut cascade = Cascade::new();
cascade.register(Box::new(MockL3::new(0.5, 0.4, "L3 said", l3_calls.clone())));
cascade.register(Box::new(MockL4::new(2.0, 0.9, "L4 said", l4_calls.clone())));
// NB: no escalation hook installed.
let mut runtime = Runtime::new(cascade);
let answer = runtime.answer(make_query("test")).unwrap();
// Without the gate, the L3 answer (confidence 0.4) wins despite
// being low-confidence — the legacy behaviour.
assert_eq!(answer.tier_used, TierId::L3(L3ModelId(0)));
assert_eq!(l3_calls.load(Ordering::SeqCst), 1);
assert_eq!(l4_calls.load(Ordering::SeqCst), 0);
}
#[test]
fn hook_rejects_low_confidence_l3_and_walk_continues_to_l4() {
let l3_calls = Arc::new(AtomicU32::new(0));
let l4_calls = Arc::new(AtomicU32::new(0));
let mut cascade = Cascade::new();
cascade.register(Box::new(MockL3::new(0.5, 0.4, "L3 low", l3_calls.clone())));
cascade.register(Box::new(MockL4::new(2.0, 0.95, "L4 high", l4_calls.clone())));
let mut runtime = Runtime::new(cascade)
// Simple threshold: accept iff confidence >= 0.85.
.with_escalation_hook(Box::new(|c: f32| c >= 0.85));
let answer = runtime.answer(make_query("test")).unwrap();
// L3 refused (0.4 < 0.85), walked to L4 which passes (0.95 >= 0.85).
assert_eq!(answer.tier_used, TierId::L4(L4ModelId(0)));
assert_eq!(l3_calls.load(Ordering::SeqCst), 1);
assert_eq!(l4_calls.load(Ordering::SeqCst), 1);
// L3's joules still charged to the spent total (the work happened).
assert!(answer.joules_spent >= 0.5 + 2.0 - 1e-9);
}
#[test]
fn hook_accepts_high_confidence_answer_and_stops_walk() {
let l3_calls = Arc::new(AtomicU32::new(0));
let l4_calls = Arc::new(AtomicU32::new(0));
let mut cascade = Cascade::new();
cascade.register(Box::new(MockL3::new(0.5, 0.95, "L3 high", l3_calls.clone())));
cascade.register(Box::new(MockL4::new(2.0, 0.99, "L4", l4_calls.clone())));
let mut runtime =
Runtime::new(cascade).with_escalation_hook(Box::new(|c: f32| c >= 0.85));
let answer = runtime.answer(make_query("test")).unwrap();
assert_eq!(answer.tier_used, TierId::L3(L3ModelId(0)));
assert_eq!(l3_calls.load(Ordering::SeqCst), 1);
assert_eq!(l4_calls.load(Ordering::SeqCst), 0); // L4 never touched
}
#[test]
fn refused_walk_records_low_confidence_in_the_trace() {
let l3_calls = Arc::new(AtomicU32::new(0));
let l4_calls = Arc::new(AtomicU32::new(0));
let mut cascade = Cascade::new();
cascade.register(Box::new(MockL3::new(0.5, 0.30, "L3", l3_calls.clone())));
cascade.register(Box::new(MockL4::new(2.0, 0.99, "L4", l4_calls.clone())));
let mut runtime =
Runtime::new(cascade).with_escalation_hook(Box::new(|c: f32| c >= 0.85));
let answer = runtime.answer(make_query("test")).unwrap();
// Find the L3 entry in the trace — it must record a Refused
// outcome with LowConfidence as the reason.
let l3_entry = answer
.trace
.attempts
.iter()
.find(|e| e.tier == TierId::L3(L3ModelId(0)))
.expect("L3 trace entry");
match &l3_entry.outcome {
TraceOutcome::Refused(RefusalReason::LowConfidence(_)) => (),
other => panic!("expected Refused(LowConfidence), got {:?}", other),
}
// L3's joules still recorded in the trace (the work was done).
assert!(l3_entry.joules > 0.0);
}
#[test]
fn hook_does_not_gate_l0_cache_hits() {
// L0 cache is gated only by tier type, not confidence; if a cache
// hit returned with low confidence, the gate must NOT fire because
// cache correctness is structural.
//
// We exercise this via the no-L0 runtime so the test is hermetic
// (no auto-cache to interfere), but the assertion holds at the
// joule_class level: only Model | Wire are gated.
let l3_calls = Arc::new(AtomicU32::new(0));
let mut cascade = Cascade::new();
cascade.register(Box::new(MockL3::new(0.5, 0.4, "L3", l3_calls.clone())));
let mut runtime = Runtime::new_without_l0(cascade)
// A hook that ALWAYS rejects. If applied to L3, L3's answer is
// refused and the walk runs out of tiers.
.with_escalation_hook(Box::new(|_c: f32| false));
let result = runtime.answer(make_query("test"));
// Out of tiers after the L3 refusal — the cascade should surface a
// NoApplicableTier error or similar. Verifies the hook DID fire on
// L3 (confirming gating on Model class).
assert!(result.is_err(), "expected error when hook rejects all tiers");
}
#[test]
fn has_escalation_hook_reports_install_state() {
let cascade = Cascade::new();
let runtime = Runtime::new(cascade);
assert!(!runtime.has_escalation_hook());
let cascade = Cascade::new();
let runtime = Runtime::new(cascade).with_escalation_hook(Box::new(|_| true));
assert!(runtime.has_escalation_hook());
}