use std::fmt;
use serde::{Deserialize, Serialize};
use crate::query_class::QueryClass;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PipelineState {
Nominal,
DegradedQuality,
CircuitOpen,
Probing,
}
impl fmt::Display for PipelineState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Nominal => write!(f, "nominal"),
Self::DegradedQuality => write!(f, "degraded_quality"),
Self::CircuitOpen => write!(f, "circuit_open"),
Self::Probing => write!(f, "probing"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PipelineAction {
Refine,
SkipRefinement,
OpenCircuit,
CloseCircuit,
ProbeQuality,
AdjustBlend {
quality_weight: u8, },
}
impl fmt::Display for PipelineAction {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Refine => write!(f, "refine"),
Self::SkipRefinement => write!(f, "skip_refinement"),
Self::OpenCircuit => write!(f, "open_circuit"),
Self::CloseCircuit => write!(f, "close_circuit"),
Self::ProbeQuality => write!(f, "probe_quality"),
Self::AdjustBlend { quality_weight } => {
write!(f, "adjust_blend({quality_weight}%)")
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct LossVector {
pub quality: f64,
pub latency: f64,
pub resource: f64,
}
impl LossVector {
pub const ZERO: Self = Self {
quality: 0.0,
latency: 0.0,
resource: 0.0,
};
#[must_use]
pub fn weighted_total(&self, w_quality: f64, w_latency: f64, w_resource: f64) -> f64 {
if !self.quality.is_finite()
|| !self.latency.is_finite()
|| !self.resource.is_finite()
|| !w_quality.is_finite()
|| !w_latency.is_finite()
|| !w_resource.is_finite()
{
return f64::MAX;
}
let quality = self.quality.max(0.0);
let latency = self.latency.max(0.0);
let resource = self.resource.max(0.0);
let w_quality = w_quality.max(0.0);
let w_latency = w_latency.max(0.0);
let w_resource = w_resource.max(0.0);
let total = quality.mul_add(w_quality, latency.mul_add(w_latency, resource * w_resource));
if total.is_finite() { total } else { f64::MAX }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct LossWeights {
pub quality: f64,
pub latency: f64,
pub resource: f64,
}
impl Default for LossWeights {
fn default() -> Self {
Self {
quality: 0.5,
latency: 0.3,
resource: 0.2,
}
}
}
impl LossWeights {
pub const LATENCY_FIRST: Self = Self {
quality: 0.2,
latency: 0.6,
resource: 0.2,
};
pub const QUALITY_FIRST: Self = Self {
quality: 0.7,
latency: 0.1,
resource: 0.2,
};
#[must_use]
pub fn apply(&self, loss: &LossVector) -> f64 {
loss.weighted_total(self.quality, self.latency, self.resource)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DecisionOutcome {
pub state: PipelineState,
pub action: PipelineAction,
pub expected_loss: LossVector,
pub reason: ReasonCode,
pub query_class: Option<QueryClass>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum CalibrationStatus {
Uncalibrated,
Calibrating {
observations: usize,
target: usize,
},
Calibrated {
ece: f64,
observations: usize,
},
Stale {
reason: CalibrationFallbackReason,
observations_since_train: usize,
},
}
impl CalibrationStatus {
#[must_use]
pub const fn is_usable(&self) -> bool {
matches!(self, Self::Calibrated { .. })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum CalibrationFallbackReason {
InsufficientData,
DistributionShift,
ErrorTooHigh,
ModelChanged,
ManualReset,
}
impl fmt::Display for CalibrationFallbackReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InsufficientData => write!(f, "insufficient_data"),
Self::DistributionShift => write!(f, "distribution_shift"),
Self::ErrorTooHigh => write!(f, "error_too_high"),
Self::ModelChanged => write!(f, "model_changed"),
Self::ManualReset => write!(f, "manual_reset"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct CalibrationThresholds {
pub min_observations: usize,
pub max_ece: f64,
pub drift_kl_threshold: f64,
pub recalibration_interval: usize,
}
impl Default for CalibrationThresholds {
fn default() -> Self {
Self {
min_observations: 100,
max_ece: 0.05,
drift_kl_threshold: 0.1,
recalibration_interval: 1000,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ReasonCode(pub String);
impl ReasonCode {
pub const DECISION_SKIP_FAST_ONLY: &str = "decision.skip.fast_only";
pub const DECISION_SKIP_CIRCUIT_OPEN: &str = "decision.skip.circuit_open";
pub const DECISION_SKIP_BUDGET_EXHAUSTED: &str = "decision.skip.budget_exhausted";
pub const DECISION_SKIP_HIGH_LOSS: &str = "decision.skip.high_loss";
pub const DECISION_SKIP_EMPTY_QUERY: &str = "decision.skip.empty_query";
pub const DECISION_REFINE_NOMINAL: &str = "decision.refine.nominal";
pub const DECISION_PROBE_SENT: &str = "decision.probe.sent";
pub const DECISION_PROBE_SUCCESS: &str = "decision.probe.success";
pub const DECISION_PROBE_FAILURE: &str = "decision.probe.failure";
pub const CIRCUIT_OPEN_FAILURES: &str = "circuit.open.consecutive_failures";
pub const CIRCUIT_OPEN_LATENCY: &str = "circuit.open.sustained_latency";
pub const CIRCUIT_CLOSE_RECOVERY: &str = "circuit.close.recovery";
pub const CALIBRATION_FALLBACK_DATA: &str = "calibration.fallback.insufficient_data";
pub const CALIBRATION_FALLBACK_DRIFT: &str = "calibration.fallback.distribution_shift";
pub const CALIBRATION_FALLBACK_ERROR: &str = "calibration.fallback.error_too_high";
pub const CALIBRATION_FALLBACK_MODEL: &str = "calibration.fallback.model_changed";
pub const CALIBRATION_TRAINED: &str = "calibration.lifecycle.trained";
pub const CALIBRATION_RESET: &str = "calibration.lifecycle.reset";
pub const FUSION_BLEND_ADJUSTED: &str = "fusion.blend.adjusted";
pub const FUSION_RRF_K_ADJUSTED: &str = "fusion.rrf_k.adjusted";
pub const FUSION_FALLBACK_DEFAULT: &str = "fusion.fallback.default";
pub const TESTING_REJECT: &str = "testing.gate.rejected";
pub const TESTING_CONTINUE: &str = "testing.gate.continue";
pub const TESTING_RESET: &str = "testing.gate.reset";
pub const CONFORMAL_VALID: &str = "conformal.coverage.valid";
pub const CONFORMAL_VIOLATION: &str = "conformal.coverage.violation";
pub const CONFORMAL_UPDATE: &str = "conformal.calibration.updated";
pub const FEEDBACK_BOOST_UPDATED: &str = "feedback.boost.updated";
pub const FEEDBACK_BOOST_DECAYED: &str = "feedback.boost.decayed";
#[must_use]
pub fn new(code: impl Into<String>) -> Self {
Self(code.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn is_valid(&self) -> bool {
let parts: Vec<&str> = self.0.split('.').collect();
if parts.len() != 3 {
return false;
}
parts.iter().all(|part| {
!part.is_empty()
&& part
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
})
}
}
impl fmt::Display for ReasonCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<&str> for ReasonCode {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Info,
Warn,
Error,
}
impl fmt::Display for Severity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Info => write!(f, "info"),
Self::Warn => write!(f, "warn"),
Self::Error => write!(f, "error"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceEventType {
Decision,
Alert,
Degradation,
Transition,
ReplayMarker,
}
impl fmt::Display for EvidenceEventType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Decision => write!(f, "decision"),
Self::Alert => write!(f, "alert"),
Self::Degradation => write!(f, "degradation"),
Self::Transition => write!(f, "transition"),
Self::ReplayMarker => write!(f, "replay_marker"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvidenceRecord {
pub event_type: EvidenceEventType,
pub reason_code: ReasonCode,
pub reason_human: String,
pub severity: Severity,
pub pipeline_state: PipelineState,
pub action: Option<PipelineAction>,
pub expected_loss: Option<LossVector>,
pub query_class: Option<QueryClass>,
pub source_component: String,
}
impl EvidenceRecord {
#[must_use]
pub fn new(
event_type: EvidenceEventType,
reason_code: impl Into<ReasonCode>,
reason_human: impl Into<String>,
severity: Severity,
pipeline_state: PipelineState,
source_component: impl Into<String>,
) -> Self {
Self {
event_type,
reason_code: reason_code.into(),
reason_human: reason_human.into(),
severity,
pipeline_state,
action: None,
expected_loss: None,
query_class: None,
source_component: source_component.into(),
}
}
#[must_use]
pub const fn with_action(mut self, action: PipelineAction) -> Self {
self.action = Some(action);
self
}
#[must_use]
pub const fn with_expected_loss(mut self, loss: LossVector) -> Self {
self.expected_loss = Some(loss);
self
}
#[must_use]
pub const fn with_query_class(mut self, qc: QueryClass) -> Self {
self.query_class = Some(qc);
self
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResourceBudget {
pub max_embed_calls: Option<usize>,
pub max_rerank_calls: Option<usize>,
pub max_phase2_ms: Option<u64>,
pub max_total_ms: Option<u64>,
}
impl ResourceBudget {
pub const UNLIMITED: Self = Self {
max_embed_calls: None,
max_rerank_calls: None,
max_phase2_ms: None,
max_total_ms: None,
};
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResourceUsage {
pub embed_calls: usize,
pub rerank_calls: usize,
pub phase2_ms: u64,
pub total_ms: u64,
}
impl ResourceUsage {
#[must_use]
pub const fn is_exhausted(&self, budget: &ResourceBudget) -> bool {
if let Some(max) = budget.max_embed_calls
&& self.embed_calls >= max
{
return true;
}
if let Some(max) = budget.max_rerank_calls
&& self.rerank_calls >= max
{
return true;
}
if let Some(max) = budget.max_phase2_ms
&& self.phase2_ms >= max
{
return true;
}
if let Some(max) = budget.max_total_ms
&& self.total_ms >= max
{
return true;
}
false
}
#[must_use]
pub fn exhausted_dimension(&self, budget: &ResourceBudget) -> Option<&'static str> {
if budget
.max_embed_calls
.is_some_and(|max| self.embed_calls >= max)
{
return Some("embed_calls");
}
if budget
.max_rerank_calls
.is_some_and(|max| self.rerank_calls >= max)
{
return Some("rerank_calls");
}
if budget
.max_phase2_ms
.is_some_and(|max| self.phase2_ms >= max)
{
return Some("phase2_ms");
}
if budget.max_total_ms.is_some_and(|max| self.total_ms >= max) {
return Some("total_ms");
}
None
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ExhaustionPolicy {
#[default]
Degrade,
CircuitBreak,
FailOpen,
}
impl fmt::Display for ExhaustionPolicy {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Degrade => write!(f, "degrade"),
Self::CircuitBreak => write!(f, "circuit_break"),
Self::FailOpen => write!(f, "fail_open"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DecisionContext {
pub state: PipelineState,
pub calibration: CalibrationStatus,
pub budget: ResourceBudget,
pub usage: ResourceUsage,
pub query_class: QueryClass,
pub loss_weights: LossWeights,
pub recent_quality_latency_ms: f64,
pub consecutive_failures: usize,
}
pub struct IntegrationCriteria;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pipeline_state_display() {
assert_eq!(PipelineState::Nominal.to_string(), "nominal");
assert_eq!(
PipelineState::DegradedQuality.to_string(),
"degraded_quality"
);
assert_eq!(PipelineState::CircuitOpen.to_string(), "circuit_open");
assert_eq!(PipelineState::Probing.to_string(), "probing");
}
#[test]
fn pipeline_state_serde_roundtrip() {
for state in [
PipelineState::Nominal,
PipelineState::DegradedQuality,
PipelineState::CircuitOpen,
PipelineState::Probing,
] {
let json = serde_json::to_string(&state).unwrap();
let decoded: PipelineState = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, state);
}
}
#[test]
fn pipeline_action_display() {
assert_eq!(PipelineAction::Refine.to_string(), "refine");
assert_eq!(
PipelineAction::SkipRefinement.to_string(),
"skip_refinement"
);
assert_eq!(PipelineAction::OpenCircuit.to_string(), "open_circuit");
assert_eq!(PipelineAction::CloseCircuit.to_string(), "close_circuit");
assert_eq!(PipelineAction::ProbeQuality.to_string(), "probe_quality");
assert_eq!(
PipelineAction::AdjustBlend { quality_weight: 70 }.to_string(),
"adjust_blend(70%)"
);
}
#[test]
fn pipeline_action_serde_roundtrip() {
let actions = [
PipelineAction::Refine,
PipelineAction::SkipRefinement,
PipelineAction::OpenCircuit,
PipelineAction::CloseCircuit,
PipelineAction::ProbeQuality,
PipelineAction::AdjustBlend { quality_weight: 80 },
];
for action in actions {
let json = serde_json::to_string(&action).unwrap();
let decoded: PipelineAction = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, action);
}
}
#[test]
fn loss_vector_zero() {
let zero = LossVector::ZERO;
assert!(zero.quality.abs() < f64::EPSILON);
assert!(zero.latency.abs() < f64::EPSILON);
assert!(zero.resource.abs() < f64::EPSILON);
}
#[test]
fn loss_vector_weighted_total() {
let loss = LossVector {
quality: 0.5,
latency: 0.3,
resource: 0.2,
};
let total = loss.weighted_total(1.0, 1.0, 1.0);
assert!((total - 1.0).abs() < 1e-10);
let q_only = loss.weighted_total(1.0, 0.0, 0.0);
assert!((q_only - 0.5).abs() < 1e-10);
}
#[test]
fn loss_weights_default() {
let w = LossWeights::default();
assert!((w.quality - 0.5).abs() < 1e-10);
assert!((w.latency - 0.3).abs() < 1e-10);
assert!((w.resource - 0.2).abs() < 1e-10);
}
#[test]
fn loss_weights_apply() {
let loss = LossVector {
quality: 1.0,
latency: 0.0,
resource: 0.0,
};
let w = LossWeights::QUALITY_FIRST;
let total = w.apply(&loss);
assert!((total - 0.7).abs() < 1e-10);
}
#[test]
fn loss_weights_serde_roundtrip() {
let w = LossWeights::LATENCY_FIRST;
let json = serde_json::to_string(&w).unwrap();
let decoded: LossWeights = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, w);
}
#[test]
fn calibration_status_usable() {
assert!(!CalibrationStatus::Uncalibrated.is_usable());
assert!(
!CalibrationStatus::Calibrating {
observations: 50,
target: 100
}
.is_usable()
);
assert!(
CalibrationStatus::Calibrated {
ece: 0.03,
observations: 200
}
.is_usable()
);
assert!(
!CalibrationStatus::Stale {
reason: CalibrationFallbackReason::DistributionShift,
observations_since_train: 5000,
}
.is_usable()
);
}
#[test]
fn calibration_status_serde_roundtrip() {
let statuses = [
CalibrationStatus::Uncalibrated,
CalibrationStatus::Calibrating {
observations: 42,
target: 100,
},
CalibrationStatus::Calibrated {
ece: 0.02,
observations: 500,
},
CalibrationStatus::Stale {
reason: CalibrationFallbackReason::ErrorTooHigh,
observations_since_train: 3000,
},
];
for status in &statuses {
let json = serde_json::to_string(status).unwrap();
let decoded: CalibrationStatus = serde_json::from_str(&json).unwrap();
assert_eq!(&decoded, status);
}
}
#[test]
fn calibration_fallback_reason_display() {
assert_eq!(
CalibrationFallbackReason::InsufficientData.to_string(),
"insufficient_data"
);
assert_eq!(
CalibrationFallbackReason::DistributionShift.to_string(),
"distribution_shift"
);
assert_eq!(
CalibrationFallbackReason::ErrorTooHigh.to_string(),
"error_too_high"
);
assert_eq!(
CalibrationFallbackReason::ModelChanged.to_string(),
"model_changed"
);
assert_eq!(
CalibrationFallbackReason::ManualReset.to_string(),
"manual_reset"
);
}
#[test]
fn calibration_thresholds_default() {
let t = CalibrationThresholds::default();
assert_eq!(t.min_observations, 100);
assert!((t.max_ece - 0.05).abs() < 1e-10);
assert!((t.drift_kl_threshold - 0.1).abs() < 1e-10);
assert_eq!(t.recalibration_interval, 1000);
}
#[test]
fn calibration_thresholds_serde_roundtrip() {
let t = CalibrationThresholds {
min_observations: 50,
max_ece: 0.1,
drift_kl_threshold: 0.2,
recalibration_interval: 500,
};
let json = serde_json::to_string(&t).unwrap();
let decoded: CalibrationThresholds = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, t);
}
#[test]
fn reason_code_validation() {
assert!(ReasonCode::new("decision.skip.fast_only").is_valid());
assert!(ReasonCode::new("calibration.fallback.insufficient_data").is_valid());
assert!(ReasonCode::new("circuit.open.consecutive_failures").is_valid());
assert!(!ReasonCode::new("decision.skip").is_valid());
assert!(!ReasonCode::new("a.b.c.d").is_valid());
assert!(!ReasonCode::new("Decision.skip.fast_only").is_valid());
assert!(!ReasonCode::new("decision..fast_only").is_valid());
}
#[test]
fn all_reason_code_constants_are_valid() {
let codes = [
ReasonCode::DECISION_SKIP_FAST_ONLY,
ReasonCode::DECISION_SKIP_CIRCUIT_OPEN,
ReasonCode::DECISION_SKIP_BUDGET_EXHAUSTED,
ReasonCode::DECISION_SKIP_HIGH_LOSS,
ReasonCode::DECISION_SKIP_EMPTY_QUERY,
ReasonCode::DECISION_REFINE_NOMINAL,
ReasonCode::DECISION_PROBE_SENT,
ReasonCode::DECISION_PROBE_SUCCESS,
ReasonCode::DECISION_PROBE_FAILURE,
ReasonCode::CIRCUIT_OPEN_FAILURES,
ReasonCode::CIRCUIT_OPEN_LATENCY,
ReasonCode::CIRCUIT_CLOSE_RECOVERY,
ReasonCode::CALIBRATION_FALLBACK_DATA,
ReasonCode::CALIBRATION_FALLBACK_DRIFT,
ReasonCode::CALIBRATION_FALLBACK_ERROR,
ReasonCode::CALIBRATION_FALLBACK_MODEL,
ReasonCode::CALIBRATION_TRAINED,
ReasonCode::CALIBRATION_RESET,
ReasonCode::FUSION_BLEND_ADJUSTED,
ReasonCode::FUSION_RRF_K_ADJUSTED,
ReasonCode::FUSION_FALLBACK_DEFAULT,
ReasonCode::TESTING_REJECT,
ReasonCode::TESTING_CONTINUE,
ReasonCode::TESTING_RESET,
ReasonCode::CONFORMAL_VALID,
ReasonCode::CONFORMAL_VIOLATION,
ReasonCode::CONFORMAL_UPDATE,
ReasonCode::FEEDBACK_BOOST_UPDATED,
ReasonCode::FEEDBACK_BOOST_DECAYED,
];
for code_str in codes {
let code = ReasonCode::new(code_str);
assert!(code.is_valid(), "invalid reason code: {code_str}");
}
}
#[test]
fn reason_code_display() {
let code = ReasonCode::new("decision.skip.fast_only");
assert_eq!(code.to_string(), "decision.skip.fast_only");
}
#[test]
fn reason_code_from_str() {
let code: ReasonCode = "circuit.open.latency".into();
assert_eq!(code.as_str(), "circuit.open.latency");
}
#[test]
fn reason_code_serde_roundtrip() {
let code = ReasonCode::new("fusion.blend.adjusted");
let json = serde_json::to_string(&code).unwrap();
let decoded: ReasonCode = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, code);
}
#[test]
fn severity_display() {
assert_eq!(Severity::Info.to_string(), "info");
assert_eq!(Severity::Warn.to_string(), "warn");
assert_eq!(Severity::Error.to_string(), "error");
}
#[test]
fn severity_serde_roundtrip() {
for sev in [Severity::Info, Severity::Warn, Severity::Error] {
let json = serde_json::to_string(&sev).unwrap();
let decoded: Severity = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, sev);
}
}
#[test]
fn evidence_event_type_display() {
assert_eq!(EvidenceEventType::Decision.to_string(), "decision");
assert_eq!(EvidenceEventType::Alert.to_string(), "alert");
assert_eq!(EvidenceEventType::Degradation.to_string(), "degradation");
assert_eq!(EvidenceEventType::Transition.to_string(), "transition");
assert_eq!(EvidenceEventType::ReplayMarker.to_string(), "replay_marker");
}
#[test]
fn evidence_event_type_serde_roundtrip() {
for evt in [
EvidenceEventType::Decision,
EvidenceEventType::Alert,
EvidenceEventType::Degradation,
EvidenceEventType::Transition,
EvidenceEventType::ReplayMarker,
] {
let json = serde_json::to_string(&evt).unwrap();
let decoded: EvidenceEventType = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, evt);
}
}
#[test]
fn evidence_record_builder() {
let record = EvidenceRecord::new(
EvidenceEventType::Decision,
ReasonCode::DECISION_REFINE_NOMINAL,
"Proceeding with quality refinement",
Severity::Info,
PipelineState::Nominal,
"two_tier_searcher",
)
.with_action(PipelineAction::Refine)
.with_expected_loss(LossVector {
quality: 0.0,
latency: 0.3,
resource: 0.2,
})
.with_query_class(QueryClass::NaturalLanguage);
assert_eq!(record.event_type, EvidenceEventType::Decision);
assert_eq!(record.reason_code.as_str(), "decision.refine.nominal");
assert_eq!(record.severity, Severity::Info);
assert_eq!(record.pipeline_state, PipelineState::Nominal);
assert_eq!(record.action, Some(PipelineAction::Refine));
assert!(record.expected_loss.is_some());
assert_eq!(record.query_class, Some(QueryClass::NaturalLanguage));
assert_eq!(record.source_component, "two_tier_searcher");
}
#[test]
fn evidence_record_serde_roundtrip() {
let record = EvidenceRecord::new(
EvidenceEventType::Transition,
ReasonCode::CIRCUIT_OPEN_FAILURES,
"Quality tier failed 5 consecutive times",
Severity::Warn,
PipelineState::CircuitOpen,
"circuit_breaker",
)
.with_action(PipelineAction::OpenCircuit);
let json = serde_json::to_string(&record).unwrap();
let decoded: EvidenceRecord = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.event_type, EvidenceEventType::Transition);
assert_eq!(
decoded.reason_code.as_str(),
"circuit.open.consecutive_failures"
);
assert_eq!(decoded.severity, Severity::Warn);
}
#[test]
fn resource_budget_unlimited() {
let b = ResourceBudget::UNLIMITED;
assert!(b.max_embed_calls.is_none());
assert!(b.max_rerank_calls.is_none());
assert!(b.max_phase2_ms.is_none());
assert!(b.max_total_ms.is_none());
}
#[test]
fn resource_budget_default_is_unlimited() {
assert_eq!(ResourceBudget::default(), ResourceBudget::UNLIMITED);
}
#[test]
fn resource_budget_serde_roundtrip() {
let b = ResourceBudget {
max_embed_calls: Some(4),
max_rerank_calls: Some(1),
max_phase2_ms: Some(300),
max_total_ms: Some(500),
};
let json = serde_json::to_string(&b).unwrap();
let decoded: ResourceBudget = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, b);
}
#[test]
fn resource_usage_not_exhausted_with_unlimited() {
let usage = ResourceUsage {
embed_calls: 100,
rerank_calls: 50,
phase2_ms: 999,
total_ms: 9999,
};
assert!(!usage.is_exhausted(&ResourceBudget::UNLIMITED));
}
#[test]
fn resource_usage_exhausted_embed_calls() {
let usage = ResourceUsage {
embed_calls: 5,
..Default::default()
};
let budget = ResourceBudget {
max_embed_calls: Some(5),
..Default::default()
};
assert!(usage.is_exhausted(&budget));
assert_eq!(usage.exhausted_dimension(&budget), Some("embed_calls"));
}
#[test]
fn resource_usage_exhausted_rerank_calls() {
let usage = ResourceUsage {
rerank_calls: 2,
..Default::default()
};
let budget = ResourceBudget {
max_rerank_calls: Some(1),
..Default::default()
};
assert!(usage.is_exhausted(&budget));
assert_eq!(usage.exhausted_dimension(&budget), Some("rerank_calls"));
}
#[test]
fn resource_usage_exhausted_phase2_ms() {
let usage = ResourceUsage {
phase2_ms: 350,
..Default::default()
};
let budget = ResourceBudget {
max_phase2_ms: Some(300),
..Default::default()
};
assert!(usage.is_exhausted(&budget));
assert_eq!(usage.exhausted_dimension(&budget), Some("phase2_ms"));
}
#[test]
fn resource_usage_exhausted_total_ms() {
let usage = ResourceUsage {
total_ms: 600,
..Default::default()
};
let budget = ResourceBudget {
max_total_ms: Some(500),
..Default::default()
};
assert!(usage.is_exhausted(&budget));
assert_eq!(usage.exhausted_dimension(&budget), Some("total_ms"));
}
#[test]
fn resource_usage_not_exhausted_below_caps() {
let usage = ResourceUsage {
embed_calls: 3,
rerank_calls: 0,
phase2_ms: 200,
total_ms: 400,
};
let budget = ResourceBudget {
max_embed_calls: Some(5),
max_rerank_calls: Some(1),
max_phase2_ms: Some(300),
max_total_ms: Some(500),
};
assert!(!usage.is_exhausted(&budget));
assert!(usage.exhausted_dimension(&budget).is_none());
}
#[test]
fn exhaustion_policy_default() {
assert_eq!(ExhaustionPolicy::default(), ExhaustionPolicy::Degrade);
}
#[test]
fn exhaustion_policy_display() {
assert_eq!(ExhaustionPolicy::Degrade.to_string(), "degrade");
assert_eq!(ExhaustionPolicy::CircuitBreak.to_string(), "circuit_break");
assert_eq!(ExhaustionPolicy::FailOpen.to_string(), "fail_open");
}
#[test]
fn exhaustion_policy_serde_roundtrip() {
for policy in [
ExhaustionPolicy::Degrade,
ExhaustionPolicy::CircuitBreak,
ExhaustionPolicy::FailOpen,
] {
let json = serde_json::to_string(&policy).unwrap();
let decoded: ExhaustionPolicy = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, policy);
}
}
#[test]
fn decision_outcome_serde_roundtrip() {
let outcome = DecisionOutcome {
state: PipelineState::Nominal,
action: PipelineAction::Refine,
expected_loss: LossVector {
quality: 0.0,
latency: 0.4,
resource: 0.2,
},
reason: ReasonCode::new(ReasonCode::DECISION_REFINE_NOMINAL),
query_class: Some(QueryClass::NaturalLanguage),
};
let json = serde_json::to_string(&outcome).unwrap();
let decoded: DecisionOutcome = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.state, PipelineState::Nominal);
assert_eq!(decoded.action, PipelineAction::Refine);
assert_eq!(decoded.query_class, Some(QueryClass::NaturalLanguage));
}
#[test]
fn decision_context_construction() {
let ctx = DecisionContext {
state: PipelineState::DegradedQuality,
calibration: CalibrationStatus::Calibrated {
ece: 0.03,
observations: 500,
},
budget: ResourceBudget {
max_total_ms: Some(500),
..Default::default()
},
usage: ResourceUsage::default(),
query_class: QueryClass::ShortKeyword,
loss_weights: LossWeights::default(),
recent_quality_latency_ms: 250.0,
consecutive_failures: 0,
};
assert_eq!(ctx.state, PipelineState::DegradedQuality);
assert!(ctx.calibration.is_usable());
assert!(!ctx.usage.is_exhausted(&ctx.budget));
}
#[test]
fn decision_context_serde_roundtrip() {
let ctx = DecisionContext {
state: PipelineState::Nominal,
calibration: CalibrationStatus::Uncalibrated,
budget: ResourceBudget::UNLIMITED,
usage: ResourceUsage::default(),
query_class: QueryClass::NaturalLanguage,
loss_weights: LossWeights::QUALITY_FIRST,
recent_quality_latency_ms: 128.0,
consecutive_failures: 0,
};
let json = serde_json::to_string(&ctx).unwrap();
let decoded: DecisionContext = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.state, PipelineState::Nominal);
assert_eq!(decoded.query_class, QueryClass::NaturalLanguage);
}
#[test]
fn loss_vector_serde_roundtrip() {
let loss = LossVector {
quality: 0.42,
latency: 0.31,
resource: 0.19,
};
let json = serde_json::to_string(&loss).unwrap();
let decoded: LossVector = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, loss);
}
#[test]
fn loss_weights_presets_exact_values() {
let lf = LossWeights::LATENCY_FIRST;
assert!((lf.quality - 0.2).abs() < 1e-10);
assert!((lf.latency - 0.6).abs() < 1e-10);
assert!((lf.resource - 0.2).abs() < 1e-10);
let qf = LossWeights::QUALITY_FIRST;
assert!((qf.quality - 0.7).abs() < 1e-10);
assert!((qf.latency - 0.1).abs() < 1e-10);
assert!((qf.resource - 0.2).abs() < 1e-10);
}
#[test]
fn resource_usage_default_is_zero() {
let usage = ResourceUsage::default();
assert_eq!(usage.embed_calls, 0);
assert_eq!(usage.rerank_calls, 0);
assert_eq!(usage.phase2_ms, 0);
assert_eq!(usage.total_ms, 0);
}
#[test]
fn resource_usage_serde_roundtrip() {
let usage = ResourceUsage {
embed_calls: 3,
rerank_calls: 1,
phase2_ms: 150,
total_ms: 320,
};
let json = serde_json::to_string(&usage).unwrap();
let decoded: ResourceUsage = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, usage);
}
#[test]
fn evidence_record_without_optional_fields() {
let record = EvidenceRecord::new(
EvidenceEventType::Alert,
ReasonCode::CIRCUIT_OPEN_LATENCY,
"High latency detected",
Severity::Warn,
PipelineState::DegradedQuality,
"circuit_breaker",
);
assert!(record.action.is_none());
assert!(record.expected_loss.is_none());
assert!(record.query_class.is_none());
let json = serde_json::to_string(&record).unwrap();
let decoded: EvidenceRecord = serde_json::from_str(&json).unwrap();
assert!(decoded.action.is_none());
assert!(decoded.expected_loss.is_none());
assert!(decoded.query_class.is_none());
}
#[test]
fn reason_code_with_numbers_and_underscores() {
assert!(ReasonCode::new("ns1.sub2.detail3").is_valid());
assert!(ReasonCode::new("long_ns.sub_part.detail_code").is_valid());
assert!(!ReasonCode::new("ns.sub.detail-code").is_valid());
assert!(!ReasonCode::new("ns.sub.detail code").is_valid());
}
#[test]
fn calibration_fallback_reason_serde_roundtrip() {
let reasons = [
CalibrationFallbackReason::InsufficientData,
CalibrationFallbackReason::DistributionShift,
CalibrationFallbackReason::ErrorTooHigh,
CalibrationFallbackReason::ModelChanged,
CalibrationFallbackReason::ManualReset,
];
for reason in reasons {
let json = serde_json::to_string(&reason).unwrap();
let decoded: CalibrationFallbackReason = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, reason);
}
}
#[test]
fn exhausted_dimension_returns_first_by_check_order() {
let usage = ResourceUsage {
embed_calls: 10,
rerank_calls: 5,
phase2_ms: 400,
total_ms: 600,
};
let budget = ResourceBudget {
max_embed_calls: Some(5),
max_rerank_calls: Some(3),
max_phase2_ms: Some(200),
max_total_ms: Some(500),
};
assert_eq!(usage.exhausted_dimension(&budget), Some("embed_calls"));
}
#[test]
fn pipeline_state_hash_distinct() {
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(PipelineState::Nominal);
set.insert(PipelineState::DegradedQuality);
set.insert(PipelineState::CircuitOpen);
set.insert(PipelineState::Probing);
assert_eq!(set.len(), 4);
}
#[test]
fn loss_vector_weighted_total_zero_weights() {
let loss = LossVector {
quality: 1.0,
latency: 1.0,
resource: 1.0,
};
let total = loss.weighted_total(0.0, 0.0, 0.0);
assert!(total.abs() < 1e-10);
}
#[test]
fn loss_vector_weighted_total_non_finite_inputs_return_worst_case() {
let loss = LossVector {
quality: f64::NAN,
latency: 0.2,
resource: 0.3,
};
assert_eq!(
loss.weighted_total(0.5, 0.3, 0.2).to_bits(),
f64::MAX.to_bits()
);
assert_eq!(
LossVector::ZERO
.weighted_total(f64::NAN, 0.3, 0.2)
.to_bits(),
f64::MAX.to_bits()
);
}
#[test]
fn loss_vector_weighted_total_negative_values_are_clamped() {
let loss = LossVector {
quality: -1.0,
latency: 0.4,
resource: -0.2,
};
let total = loss.weighted_total(-0.5, 1.0, 0.5);
assert!((total - 0.4).abs() < 1e-10);
}
}