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.
233 lines
9.2 KiB
Rust
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());
|
|
}
|