use async_trait::async_trait;
pub const COMPLEXITY_HINT_LIMIT: f32 = 0.3;
pub fn clamp_complexity(value: f32) -> f32 {
debug_assert!(
value.is_finite() && value.abs() <= COMPLEXITY_HINT_LIMIT,
"ContextRouter complexity_hint must be in [-{lim}, +{lim}], got {value}",
lim = COMPLEXITY_HINT_LIMIT,
);
if !value.is_finite() {
0.0
} else {
value.clamp(-COMPLEXITY_HINT_LIMIT, COMPLEXITY_HINT_LIMIT)
}
}
#[derive(Debug, Clone)]
pub struct ContextRequest {
pub content: String,
pub channel: String,
pub chat_id: String,
pub metadata: std::collections::HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone)]
pub struct ContextDecision {
pub skills: Vec<String>,
pub tool_subset: Option<Vec<String>>,
pub complexity_hint: f32,
pub archetype: Option<String>,
pub fallback_used: bool,
}
impl ContextDecision {
pub fn new(
skills: Vec<String>,
tool_subset: Option<Vec<String>>,
complexity_hint: f32,
) -> Self {
Self {
skills,
tool_subset,
complexity_hint: clamp_complexity(complexity_hint),
archetype: None,
fallback_used: false,
}
}
}
impl Default for ContextDecision {
fn default() -> Self {
Self {
skills: Vec::new(),
tool_subset: None,
complexity_hint: 0.0,
archetype: None,
fallback_used: false,
}
}
}
#[async_trait]
pub trait ContextRouter: Send + Sync + 'static {
async fn route(&self, request: &ContextRequest) -> ContextDecision;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct NullRouter;
#[async_trait]
impl ContextRouter for NullRouter {
async fn route(&self, _request: &ContextRequest) -> ContextDecision {
ContextDecision::default()
}
}
pub mod llm_classifier;
pub use llm_classifier::{
CLASSIFIER_SYSTEM_PROMPT, Classifier, ClassifierOutput, DEFAULT_CLASSIFIER_MAX_TOKENS,
LlmClassifierRouter,
};
#[cfg(feature = "vector-memory")]
pub mod embedding;
#[cfg(feature = "vector-memory")]
pub use embedding::{
DEFAULT_CONFIDENCE_THRESHOLD, DEFAULT_TOP_K, EmbeddingRouter, EmbeddingRouterError,
FALLBACK_TRACING_TARGET, INDEX_BACKEND,
};
pub mod hybrid;
pub mod micro_lora;
pub mod rerank;
#[cfg(feature = "hybrid-rerank")]
pub mod sona_rerank;
pub use hybrid::HybridRouter;
pub use micro_lora::{
DEFAULT_RANK, FEATURE_DIM, MicroLoraPattern, MicroLoraRouter, feature_bag,
};
pub use rerank::{
IdentityReranker, RerankCandidate, SharedReranker, SkillReranker, apply_rerank, rank_priors,
};
#[cfg(feature = "hybrid-rerank")]
pub use sona_rerank::{DEFAULT_SONA_DIM, RERANK_TRACING_TARGET, SonaSkillReranker};
#[cfg(test)]
mod tests {
use super::*;
#[allow(dead_code)]
fn _coerce(_router: &dyn ContextRouter) {}
#[tokio::test]
async fn null_router_returns_empty_decision() {
let router = NullRouter;
let req = ContextRequest {
content: "hello".into(),
channel: "panel".into(),
chat_id: "c1".into(),
metadata: Default::default(),
};
let decision = router.route(&req).await;
assert!(decision.skills.is_empty());
assert!(decision.tool_subset.is_none());
assert_eq!(decision.complexity_hint, 0.0);
}
#[test]
fn default_decision_is_neutral() {
let d = ContextDecision::default();
assert!(d.skills.is_empty());
assert!(d.tool_subset.is_none());
assert_eq!(d.complexity_hint, 0.0);
}
#[test]
fn clamp_complexity_passes_through_in_range() {
assert_eq!(clamp_complexity(0.0), 0.0);
assert_eq!(clamp_complexity(0.1), 0.1);
assert_eq!(clamp_complexity(-0.25), -0.25);
assert_eq!(clamp_complexity(0.3), 0.3);
assert_eq!(clamp_complexity(-0.3), -0.3);
}
#[test]
fn clamp_complexity_saturates_out_of_range() {
let high = std::panic::catch_unwind(|| clamp_complexity(0.5));
let low = std::panic::catch_unwind(|| clamp_complexity(-1.0));
if cfg!(debug_assertions) {
assert!(high.is_err(), "0.5 should panic via debug_assert");
assert!(low.is_err(), "-1.0 should panic via debug_assert");
} else {
assert_eq!(high.unwrap(), COMPLEXITY_HINT_LIMIT);
assert_eq!(low.unwrap(), -COMPLEXITY_HINT_LIMIT);
}
}
#[test]
fn clamp_complexity_handles_non_finite() {
let nan = std::panic::catch_unwind(|| clamp_complexity(f32::NAN));
let inf = std::panic::catch_unwind(|| clamp_complexity(f32::INFINITY));
if cfg!(debug_assertions) {
assert!(nan.is_err());
assert!(inf.is_err());
} else {
assert_eq!(nan.unwrap(), 0.0);
assert_eq!(inf.unwrap(), 0.0);
}
}
#[test]
fn context_decision_new_clamps_hint() {
let d = ContextDecision::new(vec!["a".into()], Some(vec!["t".into()]), 0.2);
assert_eq!(d.skills, vec!["a"]);
assert_eq!(d.tool_subset.as_deref(), Some(&["t".to_string()][..]));
assert!((d.complexity_hint - 0.2).abs() < f32::EPSILON);
let edge = ContextDecision::new(vec![], None, COMPLEXITY_HINT_LIMIT);
assert_eq!(edge.complexity_hint, COMPLEXITY_HINT_LIMIT);
}
#[test]
fn context_decision_default_archetype_is_none() {
let d = ContextDecision::default();
assert!(d.archetype.is_none());
let n = ContextDecision::new(vec![], None, 0.0);
assert!(n.archetype.is_none());
}
}