//! 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, } impl MockL3 { fn new(cost: f64, conf: f32, text: impl Into, calls: Arc) -> 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 { 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 { 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, } impl MockL4 { fn new(cost: f64, conf: f32, text: impl Into, calls: Arc) -> 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 { 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 { 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()); }