dwara_core/ai/policy.rs
1//! AI routing policies (DW-085): within-request escalation and
2//! latency-vs-cost selection. Composed over DW-076 routing.
3//!
4//! Two policy kinds:
5//! - **FallbackChain**: calls an external classifier service to
6//! estimate prompt complexity. Simple prompts (score < threshold)
7//! route to the cheap model; complex prompts (score >= threshold)
8//! escalate to the costlier model. On classifier error, fails open
9//! to the cheap model (the safe default).
10//! - **LatencyCost**: static config-based selection. Candidates are
11//! sorted at compile time by the configured preference (cost,
12//! latency, or balanced); the policy returns the best candidate
13//! deterministically (no runtime metrics needed).
14//!
15//! A policy composes over DW-076 routing: the candidate aliases it
16//! names (`cheap`, `escalate_to`, or `candidates[].model`) are
17//! themselves plain chain/canary aliases. The policy resolves each
18//! alias to its PRIMARY [`RouteTarget`] at compile time (the first
19//! chain entry or the first canary version), so the runtime
20//! evaluation returns a flat candidate list the dataplane walks with
21//! the same failover loop as a plain chain.
22//!
23//! # Dependency direction
24//!
25//! `ai` depends on `config` only. The classifier HTTP call reuses
26//! the same `hyper_util` client pattern as the DW-083 semantic cache
27//! (no new dependencies).
28
29use crate::ai::{CompiledModel, RouteTarget};
30use crate::config::ai::{AiLatencyCostCandidate, AiLatencyPreference, AiRoutingPolicy};
31use bytes::Bytes;
32use http_body_util::{BodyExt as _, Full};
33use hyper::{Method, Request};
34use hyper_util::client::legacy::Client;
35use hyper_util::rt::TokioExecutor;
36use std::collections::BTreeMap;
37use std::sync::{Arc, Mutex};
38use std::time::Duration;
39
40/// A compiled routing policy (DW-085). Built at AiRuntime compile
41/// time from the config block; immutable once built. The
42/// [`evaluate`](Self::evaluate) method returns the candidate list to
43/// walk (the same shape as [`AiRuntime::route`](crate::ai::AiRuntime::route))
44/// plus a [`PolicyDecision`] the dataplane records as a metric.
45#[derive(Debug, Clone)]
46pub enum CompiledRoutingPolicy {
47 /// Cheap-first with complexity-signal escalation. The
48 /// `evaluate` call POSTs the prompt to the external classifier
49 /// service and picks the cheap or escalate-to target by the
50 /// returned score.
51 FallbackChain {
52 /// The routing-policy name (for metrics attribution).
53 name: String,
54 /// The primary target of the `cheap` alias.
55 cheap: Box<RouteTarget>,
56 /// The primary target of the `escalate_to` alias.
57 escalate_to: Box<RouteTarget>,
58 /// The classifier service URL.
59 classifier_url: String,
60 /// The model name passed to the classifier service.
61 classifier_model: String,
62 /// The complexity score threshold (>= escalates).
63 threshold: f64,
64 /// The classifier HTTP timeout in milliseconds.
65 timeout_ms: u64,
66 /// Optional resolved API key for the classifier service.
67 api_key: Option<String>,
68 },
69 /// Latency-vs-cost static selection. Candidates are pre-sorted at
70 /// compile time by the configured preference; `evaluate` returns
71 /// the best one (no I/O).
72 LatencyCost {
73 /// The routing-policy name (for metrics attribution).
74 name: String,
75 /// Candidates sorted best-first by the preference (the first
76 /// is the pick).
77 candidates: Vec<RouteTarget>,
78 /// AI-15 (#203): the model alias for each candidate (for
79 /// live latency tracking). Parallel to `candidates`.
80 candidate_models: Vec<String>,
81 /// AI-15 (#203): the static cost scores for each candidate.
82 candidate_costs: Vec<u32>,
83 /// AI-15 (#203): the static latency scores for each candidate.
84 candidate_latencies: Vec<u32>,
85 /// AI-15 (#203): the selection preference.
86 preference: AiLatencyPreference,
87 /// AI-15 (#203): live latency tracker. None when `live` is
88 /// false (static selection).
89 live_tracker: Option<Arc<LiveLatencyTracker>>,
90 },
91}
92
93/// AI-15 (#203): Live latency tracker for LatencyCost policies.
94/// Maintains a rolling window of latency observations per model alias.
95/// Thread-safe (wrapped in Arc<Mutex>).
96#[derive(Debug)]
97pub struct LiveLatencyTracker {
98 window: usize,
99 observations: Mutex<BTreeMap<String, Vec<f64>>>,
100}
101
102impl LiveLatencyTracker {
103 /// Create a new tracker with the given window size.
104 pub fn new(window: usize) -> Self {
105 Self {
106 window: window.max(1),
107 observations: Mutex::new(BTreeMap::new()),
108 }
109 }
110
111 /// Record a latency observation for a model alias.
112 pub fn record(&self, model: &str, latency_ms: f64) {
113 let mut obs = self
114 .observations
115 .lock()
116 .expect("live latency tracker poisoned");
117 let entry = obs.entry(model.to_string()).or_default();
118 entry.push(latency_ms);
119 if entry.len() > self.window {
120 let excess = entry.len() - self.window;
121 entry.drain(0..excess);
122 }
123 }
124
125 /// Get the average latency for a model alias. Returns None when
126 /// there are no observations.
127 pub fn avg_latency(&self, model: &str) -> Option<f64> {
128 let obs = self
129 .observations
130 .lock()
131 .expect("live latency tracker poisoned");
132 obs.get(model)
133 .filter(|v| !v.is_empty())
134 .map(|v| v.iter().sum::<f64>() / v.len() as f64)
135 }
136}
137
138/// The decision a routing policy made for one request (DW-085). The
139/// dataplane records this as a metric (the `ai` domain cannot import
140/// `observability` — the dependency direction forbids it — so the
141/// decision travels back to the caller, which records it).
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum PolicyDecision {
144 /// FallbackChain: the prompt was simple (score < threshold) and
145 /// routed to the cheap model.
146 Cheap,
147 /// FallbackChain: the prompt was complex (score >= threshold)
148 /// and escalated to the costlier model.
149 Escalate,
150 /// FallbackChain: the classifier call failed and the policy
151 /// failed open to the cheap model.
152 ClassifierError,
153 /// LatencyCost: the pre-sorted best candidate was selected.
154 LatencyCost,
155}
156
157impl CompiledRoutingPolicy {
158 /// Compile from config. Resolves each named alias to its primary
159 /// [`RouteTarget`] against the first-pass compiled model map
160 /// (plain chain/canary aliases only — a policy alias cannot
161 /// reference another policy alias, so nested policies are
162 /// rejected by returning None). Returns None when a referenced
163 /// alias is missing or is itself a policy alias (a
164 /// validate-vs-build race or an authoring error validation
165 /// missed).
166 pub fn compile(
167 name: &str,
168 policy: &AiRoutingPolicy,
169 models: &BTreeMap<String, CompiledModel>,
170 ) -> Option<Self> {
171 match policy {
172 AiRoutingPolicy::FallbackChain(p) => {
173 let cheap = primary_target_of(models, &p.cheap)?.clone();
174 let escalate_to = primary_target_of(models, &p.escalate_to)?.clone();
175 Some(Self::FallbackChain {
176 name: name.to_string(),
177 cheap: Box::new(cheap),
178 escalate_to: Box::new(escalate_to),
179 classifier_url: p.classifier_url.clone(),
180 classifier_model: p.classifier_model.clone(),
181 threshold: p.threshold,
182 timeout_ms: p.timeout_ms,
183 api_key: p.api_key.clone(),
184 })
185 }
186 AiRoutingPolicy::LatencyCost(p) => {
187 let mut scored: Vec<(&AiLatencyCostCandidate, RouteTarget)> = Vec::new();
188 for c in &p.candidates {
189 let target = primary_target_of(models, &c.model)?.clone();
190 scored.push((c, target));
191 }
192 match p.preference {
193 AiLatencyPreference::Cost => scored.sort_by_key(|(c, _)| c.cost),
194 AiLatencyPreference::Latency => scored.sort_by_key(|(c, _)| c.latency),
195 AiLatencyPreference::Balanced => {
196 scored.sort_by_key(|(c, _)| c.cost + c.latency)
197 }
198 }
199 let candidate_models: Vec<String> =
200 scored.iter().map(|(c, _)| c.model.clone()).collect();
201 let candidate_costs: Vec<u32> = scored.iter().map(|(c, _)| c.cost).collect();
202 let candidate_latencies: Vec<u32> = scored.iter().map(|(c, _)| c.latency).collect();
203 let live_tracker = if p.live {
204 let window = p.live_window.unwrap_or(10);
205 Some(Arc::new(LiveLatencyTracker::new(window)))
206 } else {
207 None
208 };
209 Some(Self::LatencyCost {
210 name: name.to_string(),
211 candidates: scored.into_iter().map(|(_, t)| t).collect(),
212 candidate_models,
213 candidate_costs,
214 candidate_latencies,
215 preference: p.preference,
216 live_tracker,
217 })
218 }
219 }
220 }
221
222 /// Evaluate the policy for one request. Returns the candidate
223 /// list to walk (the same shape as
224 /// [`AiRuntime::route`](crate::ai::AiRuntime::route)) plus a
225 /// [`PolicyDecision`] the dataplane records as a metric. For
226 /// FallbackChain, this calls the external classifier service
227 /// (async). For LatencyCost, the candidates are pre-sorted at
228 /// compile time, so this is effectively synchronous.
229 pub async fn evaluate(&self, prompt_text: &str) -> (Vec<RouteTarget>, PolicyDecision) {
230 match self {
231 Self::FallbackChain {
232 cheap,
233 escalate_to,
234 classifier_url,
235 classifier_model,
236 threshold,
237 timeout_ms,
238 api_key,
239 ..
240 } => {
241 match call_classifier(
242 classifier_url,
243 classifier_model,
244 prompt_text,
245 *timeout_ms,
246 api_key.as_deref(),
247 )
248 .await
249 {
250 Ok(score) => {
251 if score >= *threshold {
252 tracing::info!(
253 code = "ai_routing_policy_escalate",
254 score = score,
255 threshold = threshold,
256 "complexity signal triggered escalation to the costlier model"
257 );
258 (vec![(**escalate_to).clone()], PolicyDecision::Escalate)
259 } else {
260 tracing::info!(
261 code = "ai_routing_policy_cheap",
262 score = score,
263 threshold = threshold,
264 "complexity signal below threshold; using the cheap model"
265 );
266 (vec![(**cheap).clone()], PolicyDecision::Cheap)
267 }
268 }
269 Err(e) => {
270 // Fail open: on classifier error, use the
271 // cheap model (the safe default).
272 tracing::warn!(
273 code = "ai_routing_policy_classifier_error",
274 error = %e,
275 "classifier service call failed; defaulting to the cheap model"
276 );
277 (vec![(**cheap).clone()], PolicyDecision::ClassifierError)
278 }
279 }
280 }
281 Self::LatencyCost {
282 candidates,
283 candidate_models,
284 candidate_costs,
285 candidate_latencies,
286 preference,
287 live_tracker,
288 ..
289 } => {
290 // AI-15 (#203): when live tracking is enabled, use
291 // observed latencies to dynamically select the best
292 // candidate. Fall back to the static pre-sorted order
293 // when there are no observations.
294 if let Some(tracker) = live_tracker {
295 // Compute a live score for each candidate. The
296 // live score combines the observed latency (when
297 // available) with the static cost score.
298 let mut best_idx = 0;
299 let mut best_score = f64::MAX;
300 for (i, model) in candidate_models.iter().enumerate() {
301 let live_latency = tracker.avg_latency(model);
302 let score = match preference {
303 AiLatencyPreference::Cost => candidate_costs[i] as f64,
304 AiLatencyPreference::Latency => {
305 live_latency.unwrap_or(candidate_latencies[i] as f64)
306 }
307 AiLatencyPreference::Balanced => {
308 let latency = live_latency.unwrap_or(candidate_latencies[i] as f64);
309 candidate_costs[i] as f64 + latency
310 }
311 };
312 if score < best_score {
313 best_score = score;
314 best_idx = i;
315 }
316 }
317 (
318 vec![candidates[best_idx].clone()],
319 PolicyDecision::LatencyCost,
320 )
321 } else {
322 // Pre-sorted at compile time; return the best
323 // candidate. The list is non-empty (validation
324 // rejects an empty candidates list).
325 (vec![candidates[0].clone()], PolicyDecision::LatencyCost)
326 }
327 }
328 }
329 }
330
331 /// The routing-policy name (for metrics attribution).
332 pub fn name(&self) -> &str {
333 match self {
334 Self::FallbackChain { name, .. } | Self::LatencyCost { name, .. } => name,
335 }
336 }
337
338 /// AI-15 (#203): Get the live latency tracker for this policy.
339 /// Returns None when live tracking is not enabled.
340 pub fn live_tracker(&self) -> Option<&Arc<LiveLatencyTracker>> {
341 match self {
342 Self::LatencyCost { live_tracker, .. } => live_tracker.as_ref(),
343 _ => None,
344 }
345 }
346}
347
348/// Resolve the primary [`RouteTarget`] of a named alias from the
349/// compiled model map. Returns None when the alias is missing or is
350/// itself a policy alias (nested policies are not allowed — a policy
351/// composes over plain chain/canary aliases only).
352fn primary_target_of<'a>(
353 models: &'a BTreeMap<String, CompiledModel>,
354 alias: &str,
355) -> Option<&'a RouteTarget> {
356 match models.get(alias)? {
357 CompiledModel::Chain(chain) => chain.first(),
358 CompiledModel::Canary(versions) => versions.first().map(|(_, t)| t),
359 CompiledModel::Policy(_) => None,
360 CompiledModel::Experiment(_) => None,
361 }
362}
363
364/// Call the external classifier service: POST
365/// `{"model": ..., "input": text}` to `url`, parse
366/// `{"data": [{"score": 0.0-1.0}]}`. Returns the complexity score on
367/// success. Reuses the same `hyper_util` client pattern as the
368/// DW-083 semantic cache embedding call.
369async fn call_classifier(
370 url: &str,
371 model: &str,
372 input: &str,
373 timeout_ms: u64,
374 api_key: Option<&str>,
375) -> Result<f64, String> {
376 let body = serde_json::json!({
377 "model": model,
378 "input": input,
379 });
380 let body_bytes =
381 serde_json::to_vec(&body).map_err(|e| format!("encode classifier request body: {e}"))?;
382 let mut req = Request::builder()
383 .method(Method::POST)
384 .uri(url)
385 .header("content-type", "application/json")
386 .header("accept", "application/json");
387 // Optional API key (resolved at compile time; the value lives
388 // only on the wire).
389 if let Some(key) = api_key {
390 let resolved = crate::config::credentials::resolve_configured_secret(key)
391 .map_err(|e| format!("resolve classifier api key: {e}"))?;
392 req = req.header("authorization", format!("Bearer {resolved}"));
393 }
394 let req = req
395 .body(Full::new(Bytes::from(body_bytes)))
396 .map_err(|e| format!("build classifier request: {e}"))?;
397 let client = Client::builder(TokioExecutor::new()).build_http();
398 let timeout = Duration::from_millis(timeout_ms);
399 let resp = tokio::time::timeout(timeout, client.request(req))
400 .await
401 .map_err(|_| format!("classifier service timed out after {timeout_ms} ms"))?
402 .map_err(|e| format!("classifier service request failed: {e}"))?;
403 let status = resp.status();
404 let bytes = resp
405 .into_body()
406 .collect()
407 .await
408 .map_err(|e| format!("read classifier response body: {e}"))?
409 .to_bytes();
410 if !status.is_success() {
411 return Err(format!(
412 "classifier service returned {status}: {}",
413 String::from_utf8_lossy(&bytes)
414 ));
415 }
416 let v: serde_json::Value = serde_json::from_slice(&bytes)
417 .map_err(|e| format!("parse classifier response JSON: {e}"))?;
418 let score = v
419 .get("data")
420 .and_then(|d| d.get(0))
421 .and_then(|d| d.get("score"))
422 .and_then(|s| s.as_f64())
423 .ok_or_else(|| "classifier response missing data[0].score".to_string())?;
424 Ok(score)
425}