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}