1use std::sync::Arc;
28use std::time::Duration;
29
30use zeph_llm::any::AnyProvider;
31use zeph_llm::provider::LlmProvider as _;
32
33use crate::graph::GraphStore;
34
35#[derive(Debug, Clone)]
39pub struct QualityGateConfig {
40 pub enabled: bool,
42 pub threshold: f32,
44 pub recent_window: usize,
46 pub contradiction_grace_seconds: u64,
49 pub information_value_weight: f32,
51 pub reference_completeness_weight: f32,
53 pub contradiction_weight: f32,
55 pub rejection_rate_alarm_ratio: f32,
58 pub llm_timeout_ms: u64,
60 pub llm_weight: f32,
62 pub reference_check_lang_en: bool,
65}
66
67impl Default for QualityGateConfig {
68 fn default() -> Self {
69 Self {
70 enabled: false,
71 threshold: 0.55,
72 recent_window: 32,
73 contradiction_grace_seconds: 300,
74 information_value_weight: 0.4,
75 reference_completeness_weight: 0.3,
76 contradiction_weight: 0.3,
77 rejection_rate_alarm_ratio: 0.35,
78 llm_timeout_ms: 500,
79 llm_weight: 0.5,
80 reference_check_lang_en: true,
81 }
82 }
83}
84
85#[derive(Debug, Clone)]
89pub struct QualityScore {
90 pub information_value: f32,
92 pub reference_completeness: f32,
94 pub contradiction_risk: f32,
98 pub combined: f32,
100 pub final_score: f32,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
106#[serde(rename_all = "snake_case")]
107#[non_exhaustive]
108pub enum QualityRejectionReason {
109 Redundant,
111 IncompleteReference,
113 Contradiction,
115 LlmLowConfidence,
117}
118
119impl QualityRejectionReason {
120 #[must_use]
122 pub fn label(self) -> &'static str {
123 match self {
124 Self::Redundant => "redundant",
125 Self::IncompleteReference => "incomplete_reference",
126 Self::Contradiction => "contradiction",
127 Self::LlmLowConfidence => "llm_low_confidence",
128 }
129 }
130}
131
132struct RollingRateTracker {
134 window: std::collections::VecDeque<bool>,
135 capacity: usize,
136 reject_count: usize,
137}
138
139impl RollingRateTracker {
140 fn new(capacity: usize) -> Self {
141 Self {
142 window: std::collections::VecDeque::with_capacity(capacity + 1),
143 capacity,
144 reject_count: 0,
145 }
146 }
147
148 fn push(&mut self, rejected: bool) {
149 if self.window.len() >= self.capacity
150 && let Some(evicted) = self.window.pop_front()
151 && evicted
152 {
153 self.reject_count = self.reject_count.saturating_sub(1);
154 }
155 self.window.push_back(rejected);
156 if rejected {
157 self.reject_count += 1;
158 }
159 }
160
161 #[allow(clippy::cast_precision_loss)]
162 fn rate(&self) -> f32 {
163 if self.window.is_empty() {
164 return 0.0;
165 }
166 self.reject_count as f32 / self.window.len() as f32
167 }
168}
169
170pub struct QualityGate {
182 config: Arc<QualityGateConfig>,
183 llm_provider: Option<Arc<AnyProvider>>,
185 graph_store: Option<Arc<GraphStore>>,
186 rejection_counts: std::sync::Mutex<std::collections::HashMap<QualityRejectionReason, u64>>,
188 rate_tracker: std::sync::Mutex<RollingRateTracker>,
190 embed_timeout: std::time::Duration,
192}
193
194impl QualityGate {
195 #[must_use]
197 pub fn new(config: QualityGateConfig) -> Self {
198 Self {
199 config: Arc::new(config),
200 llm_provider: None,
201 graph_store: None,
202 rejection_counts: std::sync::Mutex::new(std::collections::HashMap::new()),
203 rate_tracker: std::sync::Mutex::new(RollingRateTracker::new(100)),
204 embed_timeout: std::time::Duration::from_secs(5),
205 }
206 }
207
208 #[must_use]
212 pub fn with_embed_timeout(mut self, timeout_secs: u64) -> Self {
213 self.embed_timeout = std::time::Duration::from_secs(timeout_secs.max(1));
214 self
215 }
216
217 #[must_use]
219 pub fn with_llm_provider(mut self, provider: AnyProvider) -> Self {
220 self.llm_provider = Some(Arc::new(provider));
221 self
222 }
223
224 #[must_use]
226 pub fn with_graph_store(mut self, store: Arc<GraphStore>) -> Self {
227 self.graph_store = Some(store);
228 self
229 }
230
231 #[must_use]
233 pub fn config(&self) -> &QualityGateConfig {
234 &self.config
235 }
236
237 #[must_use]
239 pub fn rejection_counts(&self) -> std::collections::HashMap<QualityRejectionReason, u64> {
240 self.rejection_counts
241 .lock()
242 .map(|g| g.clone())
243 .unwrap_or_default()
244 }
245
246 #[tracing::instrument(name = "memory.quality_gate.evaluate", skip_all)]
253 pub async fn evaluate(
254 &self,
255 content: &str,
256 embed_provider: &AnyProvider,
257 recent_embeddings: &[Vec<f32>],
258 ) -> Option<QualityRejectionReason> {
259 if !self.config.enabled {
260 return None;
261 }
262
263 let info_val = compute_information_value(
264 content,
265 embed_provider,
266 recent_embeddings,
267 self.embed_timeout,
268 )
269 .await;
270 let ref_comp = if self.config.reference_check_lang_en {
271 compute_reference_completeness(content)
272 } else {
273 1.0
274 };
275 let contradiction_risk =
276 compute_contradiction_risk(content, self.graph_store.as_deref(), &self.config).await;
277
278 let w_v = self.config.information_value_weight;
279 let w_c = self.config.reference_completeness_weight;
280 let w_k = self.config.contradiction_weight;
281
282 let rule_score = w_v * info_val + w_c * ref_comp + w_k * (1.0 - contradiction_risk);
283
284 let final_score = if let Some(ref llm) = self.llm_provider {
285 let llm_score = call_llm_scorer(content, llm, self.config.llm_timeout_ms).await;
286 let lw = self.config.llm_weight;
287 (1.0 - lw) * rule_score + lw * llm_score
288 } else {
289 rule_score
290 };
291
292 let rejected = final_score < self.config.threshold;
293
294 if let Ok(mut tracker) = self.rate_tracker.lock() {
296 tracker.push(rejected);
297 let rate = tracker.rate();
298 if rate > self.config.rejection_rate_alarm_ratio {
299 tracing::warn!(
300 rate = %format!("{:.2}", rate),
301 window_size = self.config.recent_window,
302 threshold = self.config.rejection_rate_alarm_ratio,
303 "quality_gate: high rejection rate alarm"
304 );
305 }
306 }
307
308 if !rejected {
309 return None;
310 }
311
312 let reason = if info_val < 0.1 {
314 QualityRejectionReason::Redundant
315 } else if ref_comp < 0.5 && self.config.reference_check_lang_en {
316 QualityRejectionReason::IncompleteReference
317 } else if contradiction_risk >= 1.0 {
318 QualityRejectionReason::Contradiction
319 } else {
320 QualityRejectionReason::LlmLowConfidence
321 };
322
323 if let Ok(mut counts) = self.rejection_counts.lock() {
324 *counts.entry(reason).or_insert(0) += 1;
325 }
326
327 tracing::debug!(
328 reason = reason.label(),
329 final_score,
330 info_val,
331 ref_comp,
332 contradiction_risk,
333 "quality_gate: rejected write"
334 );
335
336 Some(reason)
337 }
338}
339
340async fn compute_information_value(
346 content: &str,
347 provider: &AnyProvider,
348 recent_embeddings: &[Vec<f32>],
349 embed_timeout: std::time::Duration,
350) -> f32 {
351 if recent_embeddings.is_empty() {
352 return 1.0;
353 }
354 if !provider.supports_embeddings() {
355 return 1.0;
356 }
357 let candidate = match tokio::time::timeout(embed_timeout, provider.embed(content)).await {
358 Ok(Ok(v)) => v,
359 Ok(Err(e)) => {
360 tracing::debug!(error = %e, "quality_gate: embed failed, treating info_val = 1.0 (fail-open)");
361 return 1.0;
362 }
363 Err(_) => {
364 tracing::warn!("quality_gate: embed timed out, treating info_val = 1.0 (fail-open)");
365 return 1.0;
366 }
367 };
368 let max_sim = recent_embeddings
369 .iter()
370 .map(|r| zeph_common::math::cosine_similarity(&candidate, r))
371 .fold(0.0f32, f32::max);
372 (1.0 - max_sim).max(0.0)
373}
374
375#[must_use]
380pub fn compute_reference_completeness(content: &str) -> f32 {
381 const PRONOUNS: &[&str] = &[
383 " he ", " she ", " they ", " it ", " him ", " her ", " them ",
384 ];
385 const DEICTIC_TIME: &[&str] = &[
387 "yesterday",
388 "tomorrow",
389 "last week",
390 "next week",
391 "last month",
392 "next month",
393 "last year",
394 "next year",
395 ];
396 const DATE_ANCHORS: &[&str] = &[
398 "january",
399 "february",
400 "march",
401 "april",
402 "may",
403 "june",
404 "july",
405 "august",
406 "september",
407 "october",
408 "november",
409 "december",
410 "jan ",
411 "feb ",
412 "mar ",
413 "apr ",
414 "jun ",
415 "jul ",
416 "aug ",
417 "sep ",
418 "oct ",
419 "nov ",
420 "dec ",
421 ];
422
423 let lower = content.to_lowercase();
424 let padded = format!(" {lower} ");
425 let pronoun_count = PRONOUNS.iter().filter(|&&p| padded.contains(p)).count();
426
427 let has_year_anchor = has_4digit_year_anchor(&lower);
430 let has_date_anchor = has_year_anchor || DATE_ANCHORS.iter().any(|&a| lower.contains(a));
431 let deictic_count = if has_date_anchor {
432 0
433 } else {
434 DEICTIC_TIME.iter().filter(|&&t| lower.contains(t)).count()
435 };
436
437 let total_issues = pronoun_count + deictic_count;
438 if total_issues == 0 {
439 return 1.0;
440 }
441
442 let word_count = content.split_ascii_whitespace().count().max(1);
444 #[allow(clippy::cast_precision_loss)]
445 let ratio = total_issues as f32 / word_count as f32;
446 (1.0 - ratio * 2.0).clamp(0.0, 1.0)
447}
448
449fn has_4digit_year_anchor(text: &str) -> bool {
454 let bytes = text.as_bytes();
455 let len = bytes.len();
456 if len < 4 {
457 return false;
458 }
459 let mut i = 0usize;
460 while i + 3 < len {
461 let c0 = bytes[i];
462 let c1 = bytes[i + 1];
463 if ((c0 == b'1' && c1 == b'9') || (c0 == b'2' && c1 == b'0'))
464 && bytes[i + 2].is_ascii_digit()
465 && bytes[i + 3].is_ascii_digit()
466 {
467 let left_ok = i == 0 || !bytes[i - 1].is_ascii_digit();
468 let right_ok = i + 4 >= len || !bytes[i + 4].is_ascii_digit();
469 if left_ok && right_ok {
470 return true;
471 }
472 }
473 i += 1;
474 }
475 false
476}
477
478async fn compute_contradiction_risk(
487 content: &str,
488 graph: Option<&GraphStore>,
489 config: &QualityGateConfig,
490) -> f32 {
491 let Some(store) = graph else {
492 return 0.0;
493 };
494
495 let content_lower = content.to_lowercase();
496
497 let subject_query = extract_subject_tokens(&content_lower);
500 if subject_query.is_empty() {
501 return 0.0;
502 }
503
504 let Ok(entities) = store.find_entities_fuzzy(&subject_query, 1).await else {
506 return 0.0;
507 };
508 let Some(subject_entity) = entities.into_iter().next() else {
509 return 0.0;
510 };
511
512 let canonical_predicate = extract_predicate_token(&content_lower);
514
515 let Ok(edges) = store.edges_for_entity(subject_entity.id.0).await else {
517 return 0.0;
518 };
519
520 let relevant_edges: Vec<_> = edges
522 .iter()
523 .filter(|e| {
524 e.source_entity_id == subject_entity.id.0
525 && canonical_predicate
526 .as_ref()
527 .is_none_or(|p| e.relation == *p)
528 })
529 .collect();
530
531 if relevant_edges.is_empty() {
532 return 0.0;
533 }
534
535 let now_secs = std::time::SystemTime::now()
536 .duration_since(std::time::UNIX_EPOCH)
537 .map_or(0, |d| d.as_secs());
538
539 let has_old_conflict = relevant_edges.iter().any(|edge| {
540 let edge_ts = chrono::DateTime::parse_from_rfc3339(&edge.created_at)
541 .map_or(0u64, |dt| u64::try_from(dt.timestamp()).unwrap_or(0));
542 now_secs.saturating_sub(edge_ts) > config.contradiction_grace_seconds
543 });
544
545 if has_old_conflict { 1.0 } else { 0.5 }
546}
547
548fn extract_subject_tokens(content_lower: &str) -> String {
550 const VERB_MARKERS: &[&str] = &["is", "was", "are", "were", "has", "have", "had", "will"];
551 let tokens: Vec<&str> = content_lower.split_ascii_whitespace().collect();
552 let end = tokens
553 .iter()
554 .position(|t| VERB_MARKERS.contains(t))
555 .unwrap_or(2.min(tokens.len()));
556 let subject_tokens = &tokens[..end.min(3)];
557 subject_tokens.join(" ")
558}
559
560fn extract_predicate_token(content_lower: &str) -> Option<String> {
562 const VERB_MARKERS: &[&str] = &["is", "was", "are", "were", "has", "have", "had", "will"];
563 content_lower
564 .split_ascii_whitespace()
565 .find(|t| VERB_MARKERS.contains(t))
566 .map(str::to_owned)
567}
568
569async fn call_llm_scorer(content: &str, provider: &AnyProvider, timeout_ms: u64) -> f32 {
573 use zeph_llm::provider::{Message, MessageMetadata, Role};
574
575 let system = "You are a memory quality judge. Rate the quality of the following message \
576 for long-term storage on a scale of 0.0 to 1.0. Consider: information density, \
577 completeness of references, factual clarity. \
578 Respond with ONLY a JSON object: \
579 {\"information_value\": 0.0-1.0, \"reference_completeness\": 0.0-1.0, \
580 \"contradiction_risk\": 0.0-1.0}";
581
582 let user = format!(
583 "Message: {}\n\nQuality JSON:",
584 content.chars().take(500).collect::<String>()
585 );
586
587 let messages = vec![
588 Message {
589 role: Role::System,
590 content: system.to_owned(),
591 parts: vec![],
592 metadata: MessageMetadata::default(),
593 },
594 Message {
595 role: Role::User,
596 content: user,
597 parts: vec![],
598 metadata: MessageMetadata::default(),
599 },
600 ];
601
602 let timeout = Duration::from_millis(timeout_ms);
603 let result = match tokio::time::timeout(timeout, provider.chat(&messages)).await {
604 Ok(Ok(r)) => r,
605 Ok(Err(e)) => {
606 tracing::debug!(error = %e, "quality_gate: LLM scorer failed, using 0.5");
607 return 0.5;
608 }
609 Err(_) => {
610 tracing::debug!("quality_gate: LLM scorer timed out, using 0.5");
611 return 0.5;
612 }
613 };
614
615 parse_llm_score(&result)
616}
617
618fn parse_llm_score(response: &str) -> f32 {
622 let start = response.find('{');
624 let end = response.rfind('}');
625 let (Some(s), Some(e)) = (start, end) else {
626 return 0.5;
627 };
628 let json_str = &response[s..=e];
629 let Ok(val) = serde_json::from_str::<serde_json::Value>(json_str) else {
630 return 0.5;
631 };
632
633 #[allow(clippy::cast_possible_truncation)]
634 let iv = val["information_value"].as_f64().unwrap_or(0.5) as f32;
635 #[allow(clippy::cast_possible_truncation)]
636 let rc = val["reference_completeness"].as_f64().unwrap_or(0.5) as f32;
637 #[allow(clippy::cast_possible_truncation)]
638 let cr = val["contradiction_risk"].as_f64().unwrap_or(0.0) as f32;
639
640 let score =
642 0.4 * iv.clamp(0.0, 1.0) + 0.3 * rc.clamp(0.0, 1.0) + 0.3 * (1.0 - cr.clamp(0.0, 1.0));
643 score.clamp(0.0, 1.0)
644}
645
646#[cfg(test)]
649mod tests {
650 use super::*;
651
652 #[test]
653 fn reference_completeness_clean_text() {
654 let score = compute_reference_completeness("The Rust compiler enforces memory safety.");
655 assert!((score - 1.0).abs() < 0.01, "clean text should score 1.0");
656 }
657
658 #[test]
659 fn reference_completeness_pronoun_heavy() {
660 let score = compute_reference_completeness("yeah he said they confirmed it");
662 assert!(
663 score < 0.5,
664 "pronoun-heavy message should score below 0.5, got {score}"
665 );
666 }
667
668 #[test]
669 fn reference_completeness_deictic_without_anchor() {
670 let score = compute_reference_completeness("We agreed yesterday to postpone");
671 assert!(
672 score < 1.0,
673 "deictic time without anchor should penalize, got {score}"
674 );
675 }
676
677 #[test]
678 fn reference_completeness_deictic_with_anchor() {
679 let score = compute_reference_completeness("We agreed yesterday (2026-04-18) to postpone");
680 assert!(
681 score >= 0.9,
682 "deictic with anchor '20' should not penalize, got {score}"
683 );
684 }
685
686 #[test]
687 fn rejection_reason_labels() {
688 assert_eq!(QualityRejectionReason::Redundant.label(), "redundant");
689 assert_eq!(
690 QualityRejectionReason::IncompleteReference.label(),
691 "incomplete_reference"
692 );
693 assert_eq!(
694 QualityRejectionReason::Contradiction.label(),
695 "contradiction"
696 );
697 assert_eq!(
698 QualityRejectionReason::LlmLowConfidence.label(),
699 "llm_low_confidence"
700 );
701 }
702
703 #[test]
704 fn rolling_rate_tracker_basic() {
705 let mut tracker = RollingRateTracker::new(4);
706 tracker.push(true);
707 tracker.push(true);
708 tracker.push(false);
709 tracker.push(false);
710 let rate = tracker.rate();
711 assert!((rate - 0.5).abs() < 0.01, "rate should be 0.5, got {rate}");
712 }
713
714 #[test]
715 fn rolling_rate_tracker_evicts_oldest() {
716 let mut tracker = RollingRateTracker::new(3);
717 tracker.push(true); tracker.push(false);
719 tracker.push(false);
720 tracker.push(false); let rate = tracker.rate();
722 assert!(
723 rate < 0.01,
724 "evicted rejection should not count, rate={rate}"
725 );
726 }
727
728 #[test]
729 fn parse_llm_score_valid_json() {
730 let json = r#"{"information_value": 0.8, "reference_completeness": 0.9, "contradiction_risk": 0.1}"#;
731 let score = parse_llm_score(json);
732 assert!(
733 score > 0.7,
734 "high-quality JSON should yield high score, got {score}"
735 );
736 }
737
738 #[test]
739 fn parse_llm_score_malformed_returns_neutral() {
740 let score = parse_llm_score("not json");
741 assert!(
742 (score - 0.5).abs() < 0.01,
743 "malformed JSON should return 0.5"
744 );
745 }
746
747 fn mock_provider() -> zeph_llm::any::AnyProvider {
748 zeph_llm::any::AnyProvider::Mock(zeph_llm::mock::MockProvider::default())
749 }
750
751 #[tokio::test]
752 async fn gate_disabled_always_passes() {
753 let config = QualityGateConfig {
754 enabled: false,
755 ..QualityGateConfig::default()
756 };
757 let gate = QualityGate::new(config);
758 let provider = mock_provider();
759
760 let result = gate.evaluate("yeah he confirmed it", &provider, &[]).await;
761 assert!(result.is_none(), "disabled gate must always pass");
762 }
763
764 #[tokio::test]
765 async fn gate_admits_novel_clean_content() {
766 let config = QualityGateConfig {
767 enabled: true,
768 threshold: 0.3, ..QualityGateConfig::default()
770 };
771 let gate = QualityGate::new(config);
772 let provider = mock_provider();
773
774 let result = gate
776 .evaluate(
777 "The Rust compiler enforces memory safety through the borrow checker.",
778 &provider,
779 &[],
780 )
781 .await;
782 assert!(result.is_none(), "clean novel content should be admitted");
783 }
784
785 #[tokio::test]
786 async fn gate_rejects_pronoun_only_at_low_threshold() {
787 let config = QualityGateConfig {
788 enabled: true,
789 threshold: 0.75, reference_completeness_weight: 0.9,
791 information_value_weight: 0.05,
792 contradiction_weight: 0.05,
793 ..QualityGateConfig::default()
794 };
795 let gate = QualityGate::new(config);
796 let provider = mock_provider();
797
798 let result = gate
799 .evaluate("yeah he confirmed it they said so", &provider, &[])
800 .await;
801 assert!(
802 result == Some(QualityRejectionReason::IncompleteReference),
803 "pronoun-heavy message should be rejected as IncompleteReference, got {result:?}"
804 );
805 }
806
807 #[test]
808 fn quality_gate_counts_rejections() {
809 let config = QualityGateConfig {
810 enabled: true,
811 threshold: 0.99, ..QualityGateConfig::default()
813 };
814 let gate = QualityGate::new(config);
815
816 if let Ok(mut counts) = gate.rejection_counts.lock() {
818 *counts.entry(QualityRejectionReason::Redundant).or_insert(0) += 1;
819 }
820
821 let counts = gate.rejection_counts();
822 assert_eq!(counts.get(&QualityRejectionReason::Redundant), Some(&1));
823 }
824
825 #[tokio::test]
827 async fn gate_fail_open_on_embed_error() {
828 let config = QualityGateConfig {
829 enabled: true,
830 threshold: 0.5,
831 ..QualityGateConfig::default()
832 };
833 let gate = QualityGate::new(config);
834
835 let provider = zeph_llm::any::AnyProvider::Mock(
837 zeph_llm::mock::MockProvider::default().with_embed_invalid_input(),
838 );
839
840 let result = gate
841 .evaluate(
842 "Alice confirmed the meeting at 3pm.",
843 &provider,
844 &[], )
846 .await;
847 assert!(
848 result.is_none(),
849 "embed error must be treated as fail-open (admitted), got {result:?}"
850 );
851 }
852
853 #[tokio::test]
855 async fn gate_rejects_redundant_with_populated_embeddings() {
856 let config = QualityGateConfig {
857 enabled: true,
858 threshold: 0.5,
859 information_value_weight: 0.9,
861 reference_completeness_weight: 0.05,
862 contradiction_weight: 0.05,
863 ..QualityGateConfig::default()
864 };
865 let gate = QualityGate::new(config);
866
867 let fixed_embedding = vec![0.1_f32; 384];
869 let provider = zeph_llm::any::AnyProvider::Mock(
870 zeph_llm::mock::MockProvider::default().with_embedding(fixed_embedding.clone()),
871 );
872
873 let result = gate
875 .evaluate(
876 "The Rust compiler enforces memory safety through the borrow checker.",
877 &provider,
878 &[fixed_embedding],
879 )
880 .await;
881 assert_eq!(
882 result,
883 Some(QualityRejectionReason::Redundant),
884 "identical recent embedding must trigger Redundant rejection"
885 );
886 }
887
888 #[tokio::test]
891 async fn gate_fail_open_on_embed_timeout() {
892 tokio::time::pause();
893
894 let config = QualityGateConfig {
895 enabled: true,
896 threshold: 0.5,
897 information_value_weight: 0.9,
898 reference_completeness_weight: 0.05,
899 contradiction_weight: 0.05,
900 ..QualityGateConfig::default()
901 };
902 let gate = QualityGate::new(config);
903
904 let provider = zeph_llm::any::AnyProvider::Mock(
906 zeph_llm::mock::MockProvider::default().with_embed_delay(10_000),
907 );
908
909 let recent = vec![vec![0.1_f32; 384]];
912
913 let fut = gate.evaluate("Alice confirmed the meeting at 3pm.", &provider, &recent);
914 let (result, ()) = tokio::join!(fut, async {
916 tokio::time::advance(std::time::Duration::from_secs(6)).await;
917 });
918
919 assert!(
920 result.is_none(),
921 "embed timeout must be treated as fail-open (info_val=1.0, admitted), got {result:?}"
922 );
923 }
924
925 #[tokio::test]
928 async fn gate_llm_timeout_falls_back_to_rule_score() {
929 let config = QualityGateConfig {
930 enabled: true,
931 threshold: 0.3, llm_timeout_ms: 50, llm_weight: 0.5,
934 ..QualityGateConfig::default()
935 };
936 let gate = QualityGate::new(config);
937
938 let slow_provider = zeph_llm::any::AnyProvider::Mock(
940 zeph_llm::mock::MockProvider::default().with_delay(600),
941 );
942 let gate = gate.with_llm_provider(slow_provider);
943
944 let embed_provider = mock_provider(); let result = gate
947 .evaluate(
948 "The release is scheduled for next Friday.",
949 &embed_provider,
950 &[],
951 )
952 .await;
953 assert!(
956 result.is_none(),
957 "LLM timeout must fall back to rule score and admit clean content, got {result:?}"
958 );
959 }
960}