Skip to main content

zeph_memory/
quality_gate.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! `MemReader` write quality gate (#3222).
5//!
6//! [`QualityGate`] runs **after** A-MAC admission and before any persistence write.
7//! It scores three signals — information value, reference completeness, and contradiction
8//! risk — and rejects writes below a configurable threshold.
9//!
10//! Rule-based scoring ships as MVP; an optional LLM-assisted path is enabled by setting
11//! `quality_gate_provider` in `[memory.quality_gate]`.
12//!
13//! # Composition in `SemanticMemory`
14//!
15//! ```text
16//! remember(content)
17//!   → A-MAC::evaluate()  →  Ok(None) if rejected
18//!   → QualityGate::evaluate()  →  Ok(None) if rejected
19//!   → SQLite / Qdrant persist
20//! ```
21//!
22//! # Fail-open contract
23//!
24//! Any scoring failure (embed error, LLM timeout, graph query error) is treated as a
25//! pass — the write is admitted. Quality scoring is best-effort, never a hard dependency.
26
27use 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// ── Config ────────────────────────────────────────────────────────────────────
36
37/// Configuration for the write quality gate (`[memory.quality_gate]` TOML section).
38#[derive(Debug, Clone)]
39pub struct QualityGateConfig {
40    /// Enable the quality gate. When `false`, all writes pass through. Default: `false`.
41    pub enabled: bool,
42    /// Combined score threshold below which writes are rejected. Range `[0, 1]`. Default: `0.55`.
43    pub threshold: f32,
44    /// Number of recent writes to compare against for information-value scoring. Default: `32`.
45    pub recent_window: usize,
46    /// Seconds: edges older than this are considered stable for contradiction detection.
47    /// Default: `300`.
48    pub contradiction_grace_seconds: u64,
49    /// Weight of `information_value` sub-score. Default: `0.4`.
50    pub information_value_weight: f32,
51    /// Weight of `reference_completeness` sub-score. Default: `0.3`.
52    pub reference_completeness_weight: f32,
53    /// Weight of `contradiction` sub-score (applied as `1 - contradiction_risk`). Default: `0.3`.
54    pub contradiction_weight: f32,
55    /// Ratio of rejections (rolling 100-write window) above which a `WARN` is emitted.
56    /// Default: `0.35`.
57    pub rejection_rate_alarm_ratio: f32,
58    /// LLM timeout for optional scoring path. Default: `500 ms`.
59    pub llm_timeout_ms: u64,
60    /// Weight blended into the final score when an LLM provider is set. Default: `0.5`.
61    pub llm_weight: f32,
62    /// Whether pronoun/deictic reference checks are active. Disable for non-English sessions.
63    /// Default: `true`.
64    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// ── Types ─────────────────────────────────────────────────────────────────────
86
87/// Per-signal scores from the quality gate evaluation.
88#[derive(Debug, Clone)]
89pub struct QualityScore {
90    /// `1.0 - max_cosine(candidate, recent_writes)`. `1.0` when the store is empty.
91    pub information_value: f32,
92    /// `1.0 - unresolved_reference_ratio`. Lower = more unresolved pronouns/deictic time.
93    pub reference_completeness: f32,
94    /// `1.0` if a conflicting graph edge exists (older than grace period); `0.0` otherwise.
95    /// Returns `0.0` when no graph store is attached — improves automatically when
96    /// APEX-MEM (#3223) lands and a `GraphStore` is wired in.
97    pub contradiction_risk: f32,
98    /// Weighted combination of the three sub-scores.
99    pub combined: f32,
100    /// LLM-blended final score. Equals `combined` when no LLM provider is configured.
101    pub final_score: f32,
102}
103
104/// Reason for a quality gate rejection.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
106#[serde(rename_all = "snake_case")]
107#[non_exhaustive]
108pub enum QualityRejectionReason {
109    /// Cosine similarity to recent writes is too high — the content is redundant.
110    Redundant,
111    /// Unresolved pronoun or deictic time expression without an absolute referent.
112    IncompleteReference,
113    /// A conflicting graph edge exists for the same `(subject, predicate)` pair.
114    Contradiction,
115    /// Optional LLM scorer returned a score below the threshold.
116    LlmLowConfidence,
117}
118
119impl QualityRejectionReason {
120    /// Stable lowercase-snake label suitable for metric tags.
121    #[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
132/// Rolling window counter for tracking the rejection rate over the last N writes.
133struct 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
170// ── QualityGate ───────────────────────────────────────────────────────────────
171
172/// Write quality gate that runs after A-MAC admission.
173///
174/// Constructed once and attached to [`crate::semantic::SemanticMemory`] via
175/// [`crate::semantic::SemanticMemory::with_quality_gate`]. Shared via `Arc`.
176///
177/// # Fail-open
178///
179/// Any internal error (embed failure, LLM timeout, graph query error) is caught
180/// and treated as a pass. The gate never causes `remember()` to return an `Err`.
181pub struct QualityGate {
182    config: Arc<QualityGateConfig>,
183    /// Optional LLM provider for the blended scoring path.
184    llm_provider: Option<Arc<AnyProvider>>,
185    graph_store: Option<Arc<GraphStore>>,
186    /// Rejection counters keyed by reason.
187    rejection_counts: std::sync::Mutex<std::collections::HashMap<QualityRejectionReason, u64>>,
188    /// Rolling rejection-rate tracker (last 100 writes).
189    rate_tracker: std::sync::Mutex<RollingRateTracker>,
190    /// Per-call timeout for every `embed()` invocation. Default: 5 s.
191    embed_timeout: std::time::Duration,
192}
193
194impl QualityGate {
195    /// Create a new quality gate with the given config.
196    #[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    /// Set the per-call timeout for every `embed()` invocation.
209    ///
210    /// Default: 5 s. Must be non-zero; the minimum effective value is 1 s.
211    #[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    /// Attach an LLM provider for optional blended scoring.
218    #[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    /// Attach a graph store for contradiction detection.
225    #[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    /// Return a reference to the configuration.
232    #[must_use]
233    pub fn config(&self) -> &QualityGateConfig {
234        &self.config
235    }
236
237    /// Return cumulative rejection counts per reason.
238    #[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    /// Evaluate the quality gate for a candidate write.
247    ///
248    /// Returns `None` when the write passes (should be persisted).
249    /// Returns `Some(reason)` when the write should be rejected.
250    ///
251    /// Failures inside scoring are caught and treated as pass (fail-open).
252    #[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        // Track rolling rejection rate.
295        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        // Determine the most specific rejection reason.
313        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
340// ── Sub-scorers ───────────────────────────────────────────────────────────────
341
342/// Compute `information_value` as `1.0 - max_cosine(candidate, recent_embeddings)`.
343///
344/// Returns `1.0` when the store is empty or on any embedding error (fail-open: treat as novel).
345async 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 crate::llm_judge::embed_with_timeout_fail_open(
358        provider,
359        content,
360        embed_timeout,
361        1.0,
362        "quality_gate: information_value",
363    )
364    .await
365    {
366        Ok(v) => v,
367        Err(fail_open) => return fail_open,
368    };
369    let max_sim = recent_embeddings
370        .iter()
371        .map(|r| zeph_common::math::cosine_similarity(&candidate, r))
372        .fold(0.0f32, f32::max);
373    (1.0 - max_sim).max(0.0)
374}
375
376/// Compute `reference_completeness` as `1.0 - unresolved_reference_ratio`.
377///
378/// Heuristic: counts unresolved English pronouns and deictic time expressions.
379/// English-only; callers must skip this when `reference_check_lang_en = false`.
380#[must_use]
381pub fn compute_reference_completeness(content: &str) -> f32 {
382    // Third-person pronouns that likely refer to an unresolved entity.
383    const PRONOUNS: &[&str] = &[
384        " he ", " she ", " they ", " it ", " him ", " her ", " them ",
385    ];
386    // Deictic time expressions without an accompanying absolute date.
387    const DEICTIC_TIME: &[&str] = &[
388        "yesterday",
389        "tomorrow",
390        "last week",
391        "next week",
392        "last month",
393        "next month",
394        "last year",
395        "next year",
396    ];
397    // Absolute date anchors that resolve deictic expressions.
398    const DATE_ANCHORS: &[&str] = &[
399        "january",
400        "february",
401        "march",
402        "april",
403        "may",
404        "june",
405        "july",
406        "august",
407        "september",
408        "october",
409        "november",
410        "december",
411        "jan ",
412        "feb ",
413        "mar ",
414        "apr ",
415        "jun ",
416        "jul ",
417        "aug ",
418        "sep ",
419        "oct ",
420        "nov ",
421        "dec ",
422    ];
423
424    let lower = content.to_lowercase();
425    let padded = format!(" {lower} ");
426    let pronoun_count = PRONOUNS.iter().filter(|&&p| padded.contains(p)).count();
427
428    // Require a 4-digit year (19xx or 20xx) at a word boundary, not just "20"
429    // which produces false positives on counts like "20 items" or "id=200".
430    let has_year_anchor = has_4digit_year_anchor(&lower);
431    let has_date_anchor = has_year_anchor || DATE_ANCHORS.iter().any(|&a| lower.contains(a));
432    let deictic_count = if has_date_anchor {
433        0
434    } else {
435        DEICTIC_TIME.iter().filter(|&&t| lower.contains(t)).count()
436    };
437
438    let total_issues = pronoun_count + deictic_count;
439    if total_issues == 0 {
440        return 1.0;
441    }
442
443    // Normalize by approximate word count; each issue costs ~0.25, floor at 0.0.
444    let word_count = content.split_ascii_whitespace().count().max(1);
445    #[allow(clippy::cast_precision_loss)]
446    let ratio = total_issues as f32 / word_count as f32;
447    (1.0 - ratio * 2.0).clamp(0.0, 1.0)
448}
449
450/// Returns `true` when `text` (lowercased) contains a 4-digit year (19xx or 20xx)
451/// at a word boundary.
452///
453/// Avoids false positives from 2-digit numbers like "20 items" or "id=200".
454fn has_4digit_year_anchor(text: &str) -> bool {
455    let bytes = text.as_bytes();
456    let len = bytes.len();
457    if len < 4 {
458        return false;
459    }
460    let mut i = 0usize;
461    while i + 3 < len {
462        let c0 = bytes[i];
463        let c1 = bytes[i + 1];
464        if ((c0 == b'1' && c1 == b'9') || (c0 == b'2' && c1 == b'0'))
465            && bytes[i + 2].is_ascii_digit()
466            && bytes[i + 3].is_ascii_digit()
467        {
468            let left_ok = i == 0 || !bytes[i - 1].is_ascii_digit();
469            let right_ok = i + 4 >= len || !bytes[i + 4].is_ascii_digit();
470            if left_ok && right_ok {
471                return true;
472            }
473        }
474        i += 1;
475    }
476    false
477}
478
479/// Compute `contradiction_risk` via graph edge lookup (FR-006).
480///
481/// Extracts the subject entity from the candidate message, then queries for existing
482/// active edges with the same `(source_entity_id, canonical_relation)`. A conflicting
483/// value on the same predicate that is older than `grace_seconds` is treated as a
484/// hard contradiction (returns `1.0`).
485///
486/// Returns `0.0` when no graph store is attached, on any error, or when no conflict found.
487async fn compute_contradiction_risk(
488    content: &str,
489    graph: Option<&GraphStore>,
490    config: &QualityGateConfig,
491) -> f32 {
492    let Some(store) = graph else {
493        return 0.0;
494    };
495
496    let content_lower = content.to_lowercase();
497
498    // Extract subject: longest noun-phrase before a verb-like token ("is", "has", "was", "are").
499    // Fallback: first two tokens.
500    let subject_query = extract_subject_tokens(&content_lower);
501    if subject_query.is_empty() {
502        return 0.0;
503    }
504
505    // Resolve the subject entity.
506    let Ok(entities) = store.find_entities_fuzzy(&subject_query, 1).await else {
507        return 0.0;
508    };
509    let Some(subject_entity) = entities.into_iter().next() else {
510        return 0.0;
511    };
512
513    // Extract candidate predicate from "X <predicate> Y" pattern.
514    let canonical_predicate = extract_predicate_token(&content_lower);
515
516    // Load all active edges where this entity is the source.
517    let Ok(edges) = store.edges_for_entity(subject_entity.id.0).await else {
518        return 0.0;
519    };
520
521    // Filter to edges where source matches subject and canonical_relation matches predicate.
522    let relevant_edges: Vec<_> = edges
523        .iter()
524        .filter(|e| {
525            e.source_entity_id == subject_entity.id.0
526                && canonical_predicate
527                    .as_ref()
528                    .is_none_or(|p| e.relation == *p)
529        })
530        .collect();
531
532    if relevant_edges.is_empty() {
533        return 0.0;
534    }
535
536    let now_secs = std::time::SystemTime::now()
537        .duration_since(std::time::UNIX_EPOCH)
538        .map_or(0, |d| d.as_secs());
539
540    let has_old_conflict = relevant_edges.iter().any(|edge| {
541        let edge_ts = chrono::DateTime::parse_from_rfc3339(&edge.created_at)
542            .map_or(0u64, |dt| u64::try_from(dt.timestamp()).unwrap_or(0));
543        now_secs.saturating_sub(edge_ts) > config.contradiction_grace_seconds
544    });
545
546    if has_old_conflict { 1.0 } else { 0.5 }
547}
548
549/// Extract subject tokens from the content (first noun phrase before verb-like token).
550fn extract_subject_tokens(content_lower: &str) -> String {
551    const VERB_MARKERS: &[&str] = &["is", "was", "are", "were", "has", "have", "had", "will"];
552    let tokens: Vec<&str> = content_lower.split_ascii_whitespace().collect();
553    let end = tokens
554        .iter()
555        .position(|t| VERB_MARKERS.contains(t))
556        .unwrap_or(2.min(tokens.len()));
557    let subject_tokens = &tokens[..end.min(3)];
558    subject_tokens.join(" ")
559}
560
561/// Extract the canonical predicate token (first verb-like token in the content).
562fn extract_predicate_token(content_lower: &str) -> Option<String> {
563    const VERB_MARKERS: &[&str] = &["is", "was", "are", "were", "has", "have", "had", "will"];
564    content_lower
565        .split_ascii_whitespace()
566        .find(|t| VERB_MARKERS.contains(t))
567        .map(str::to_owned)
568}
569
570/// Call the optional LLM scorer and return a blended quality score.
571///
572/// Returns `0.5` (neutral) on timeout or any error — ensures fail-open behavior.
573async fn call_llm_scorer(content: &str, provider: &AnyProvider, timeout_ms: u64) -> f32 {
574    let system = "You are a memory quality judge. Rate the quality of the following message \
575        for long-term storage on a scale of 0.0 to 1.0. Consider: information density, \
576        completeness of references, factual clarity. \
577        Respond with ONLY a JSON object: \
578        {\"information_value\": 0.0-1.0, \"reference_completeness\": 0.0-1.0, \
579        \"contradiction_risk\": 0.0-1.0}";
580
581    let user = format!(
582        "Message: {}\n\nQuality JSON:",
583        content.chars().take(500).collect::<String>()
584    );
585
586    crate::llm_judge::llm_judge_score(
587        provider,
588        system,
589        user,
590        Duration::from_millis(timeout_ms),
591        0.5,
592        "quality_gate: LLM scorer",
593        |s| Some(parse_llm_score(s)),
594    )
595    .await
596}
597
598/// Parse LLM JSON response into a combined quality score.
599///
600/// Returns `0.5` on any parse failure.
601fn parse_llm_score(response: &str) -> f32 {
602    // Find JSON object in the response.
603    let start = response.find('{');
604    let end = response.rfind('}');
605    let (Some(s), Some(e)) = (start, end) else {
606        return 0.5;
607    };
608    let json_str = &response[s..=e];
609    let Ok(val) = serde_json::from_str::<serde_json::Value>(json_str) else {
610        return 0.5;
611    };
612
613    #[allow(clippy::cast_possible_truncation)]
614    let iv = val["information_value"].as_f64().unwrap_or(0.5) as f32;
615    #[allow(clippy::cast_possible_truncation)]
616    let rc = val["reference_completeness"].as_f64().unwrap_or(0.5) as f32;
617    #[allow(clippy::cast_possible_truncation)]
618    let cr = val["contradiction_risk"].as_f64().unwrap_or(0.0) as f32;
619
620    // Mirror the rule-based formula with default weights.
621    let score =
622        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));
623    score.clamp(0.0, 1.0)
624}
625
626// ── Tests ─────────────────────────────────────────────────────────────────────
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631
632    #[test]
633    fn reference_completeness_clean_text() {
634        let score = compute_reference_completeness("The Rust compiler enforces memory safety.");
635        assert!((score - 1.0).abs() < 0.01, "clean text should score 1.0");
636    }
637
638    #[test]
639    fn reference_completeness_pronoun_heavy() {
640        // "he", "they", "it" — three unresolved pronouns in a short message.
641        let score = compute_reference_completeness("yeah he said they confirmed it");
642        assert!(
643            score < 0.5,
644            "pronoun-heavy message should score below 0.5, got {score}"
645        );
646    }
647
648    #[test]
649    fn reference_completeness_deictic_without_anchor() {
650        let score = compute_reference_completeness("We agreed yesterday to postpone");
651        assert!(
652            score < 1.0,
653            "deictic time without anchor should penalize, got {score}"
654        );
655    }
656
657    #[test]
658    fn reference_completeness_deictic_with_anchor() {
659        let score = compute_reference_completeness("We agreed yesterday (2026-04-18) to postpone");
660        assert!(
661            score >= 0.9,
662            "deictic with anchor '20' should not penalize, got {score}"
663        );
664    }
665
666    #[test]
667    fn rejection_reason_labels() {
668        assert_eq!(QualityRejectionReason::Redundant.label(), "redundant");
669        assert_eq!(
670            QualityRejectionReason::IncompleteReference.label(),
671            "incomplete_reference"
672        );
673        assert_eq!(
674            QualityRejectionReason::Contradiction.label(),
675            "contradiction"
676        );
677        assert_eq!(
678            QualityRejectionReason::LlmLowConfidence.label(),
679            "llm_low_confidence"
680        );
681    }
682
683    #[test]
684    fn rolling_rate_tracker_basic() {
685        let mut tracker = RollingRateTracker::new(4);
686        tracker.push(true);
687        tracker.push(true);
688        tracker.push(false);
689        tracker.push(false);
690        let rate = tracker.rate();
691        assert!((rate - 0.5).abs() < 0.01, "rate should be 0.5, got {rate}");
692    }
693
694    #[test]
695    fn rolling_rate_tracker_evicts_oldest() {
696        let mut tracker = RollingRateTracker::new(3);
697        tracker.push(true); // will be evicted
698        tracker.push(false);
699        tracker.push(false);
700        tracker.push(false); // evicts first `true`
701        let rate = tracker.rate();
702        assert!(
703            rate < 0.01,
704            "evicted rejection should not count, rate={rate}"
705        );
706    }
707
708    #[test]
709    fn parse_llm_score_valid_json() {
710        let json = r#"{"information_value": 0.8, "reference_completeness": 0.9, "contradiction_risk": 0.1}"#;
711        let score = parse_llm_score(json);
712        assert!(
713            score > 0.7,
714            "high-quality JSON should yield high score, got {score}"
715        );
716    }
717
718    #[test]
719    fn parse_llm_score_malformed_returns_neutral() {
720        let score = parse_llm_score("not json");
721        assert!(
722            (score - 0.5).abs() < 0.01,
723            "malformed JSON should return 0.5"
724        );
725    }
726
727    fn mock_provider() -> zeph_llm::any::AnyProvider {
728        zeph_llm::any::AnyProvider::Mock(zeph_llm::mock::MockProvider::default())
729    }
730
731    #[tokio::test]
732    async fn gate_disabled_always_passes() {
733        let config = QualityGateConfig {
734            enabled: false,
735            ..QualityGateConfig::default()
736        };
737        let gate = QualityGate::new(config);
738        let provider = mock_provider();
739
740        let result = gate.evaluate("yeah he confirmed it", &provider, &[]).await;
741        assert!(result.is_none(), "disabled gate must always pass");
742    }
743
744    #[tokio::test]
745    async fn gate_admits_novel_clean_content() {
746        let config = QualityGateConfig {
747            enabled: true,
748            threshold: 0.3, // lenient threshold for rule-only test
749            ..QualityGateConfig::default()
750        };
751        let gate = QualityGate::new(config);
752        let provider = mock_provider();
753
754        // Novel content with no recent embeddings and clean references → should pass.
755        let result = gate
756            .evaluate(
757                "The Rust compiler enforces memory safety through the borrow checker.",
758                &provider,
759                &[],
760            )
761            .await;
762        assert!(result.is_none(), "clean novel content should be admitted");
763    }
764
765    #[tokio::test]
766    async fn gate_rejects_pronoun_only_at_low_threshold() {
767        let config = QualityGateConfig {
768            enabled: true,
769            threshold: 0.75, // strict threshold
770            reference_completeness_weight: 0.9,
771            information_value_weight: 0.05,
772            contradiction_weight: 0.05,
773            ..QualityGateConfig::default()
774        };
775        let gate = QualityGate::new(config);
776        let provider = mock_provider();
777
778        let result = gate
779            .evaluate("yeah he confirmed it they said so", &provider, &[])
780            .await;
781        assert!(
782            result == Some(QualityRejectionReason::IncompleteReference),
783            "pronoun-heavy message should be rejected as IncompleteReference, got {result:?}"
784        );
785    }
786
787    #[test]
788    fn quality_gate_counts_rejections() {
789        let config = QualityGateConfig {
790            enabled: true,
791            threshold: 0.99, // reject almost everything
792            ..QualityGateConfig::default()
793        };
794        let gate = QualityGate::new(config);
795
796        // Manually record a rejection.
797        if let Ok(mut counts) = gate.rejection_counts.lock() {
798            *counts.entry(QualityRejectionReason::Redundant).or_insert(0) += 1;
799        }
800
801        let counts = gate.rejection_counts();
802        assert_eq!(counts.get(&QualityRejectionReason::Redundant), Some(&1));
803    }
804
805    /// Embed error → fail-open: gate must admit the write (return `None`).
806    #[tokio::test]
807    async fn gate_fail_open_on_embed_error() {
808        let config = QualityGateConfig {
809            enabled: true,
810            threshold: 0.5,
811            ..QualityGateConfig::default()
812        };
813        let gate = QualityGate::new(config);
814
815        // Provider that returns an embed error.
816        let provider = zeph_llm::any::AnyProvider::Mock(
817            zeph_llm::mock::MockProvider::default().with_embed_invalid_input(),
818        );
819
820        let result = gate
821            .evaluate(
822                "Alice confirmed the meeting at 3pm.",
823                &provider,
824                &[], // no recent embeddings; error occurs during info_value embed
825            )
826            .await;
827        assert!(
828            result.is_none(),
829            "embed error must be treated as fail-open (admitted), got {result:?}"
830        );
831    }
832
833    /// Pre-populated `recent_embeddings` with an identical vector triggers `Redundant` rejection.
834    #[tokio::test]
835    async fn gate_rejects_redundant_with_populated_embeddings() {
836        let config = QualityGateConfig {
837            enabled: true,
838            threshold: 0.5,
839            // Heavy weight on information_value so redundancy dominates the score.
840            information_value_weight: 0.9,
841            reference_completeness_weight: 0.05,
842            contradiction_weight: 0.05,
843            ..QualityGateConfig::default()
844        };
845        let gate = QualityGate::new(config);
846
847        // MockProvider returns the same fixed embedding for every call.
848        let fixed_embedding = vec![0.1_f32; 384];
849        let provider = zeph_llm::any::AnyProvider::Mock(
850            zeph_llm::mock::MockProvider::default().with_embedding(fixed_embedding.clone()),
851        );
852
853        // Pass the identical vector as the recent-embeddings window so cosine similarity = 1.0.
854        let result = gate
855            .evaluate(
856                "The Rust compiler enforces memory safety through the borrow checker.",
857                &provider,
858                &[fixed_embedding],
859            )
860            .await;
861        assert_eq!(
862            result,
863            Some(QualityRejectionReason::Redundant),
864            "identical recent embedding must trigger Redundant rejection"
865        );
866    }
867
868    /// `embed()` timeout → fail-open: `compute_information_value` returns 1.0,
869    /// gate admits the write (returns `None`).
870    #[tokio::test]
871    async fn gate_fail_open_on_embed_timeout() {
872        tokio::time::pause();
873
874        let config = QualityGateConfig {
875            enabled: true,
876            threshold: 0.5,
877            information_value_weight: 0.9,
878            reference_completeness_weight: 0.05,
879            contradiction_weight: 0.05,
880            ..QualityGateConfig::default()
881        };
882        let gate = QualityGate::new(config);
883
884        // embed_delay_ms >> 5000ms timeout; time is paused so the test is instant.
885        let provider = zeph_llm::any::AnyProvider::Mock(
886            zeph_llm::mock::MockProvider::default().with_embed_delay(10_000),
887        );
888
889        // Provide a non-empty recent-embeddings window so compute_information_value
890        // actually calls embed() (it returns early on empty).
891        let recent = vec![vec![0.1_f32; 384]];
892
893        let fut = gate.evaluate("Alice confirmed the meeting at 3pm.", &provider, &recent);
894        // Advance time past the 5s embed timeout.
895        let (result, ()) = tokio::join!(fut, async {
896            tokio::time::advance(std::time::Duration::from_secs(6)).await;
897        });
898
899        assert!(
900            result.is_none(),
901            "embed timeout must be treated as fail-open (info_val=1.0, admitted), got {result:?}"
902        );
903    }
904
905    /// LLM provider with 600ms latency exceeds `llm_timeout_ms`; gate falls back to rule score
906    /// and still returns a result (pass or reject based on rule score alone).
907    #[tokio::test]
908    async fn gate_llm_timeout_falls_back_to_rule_score() {
909        let config = QualityGateConfig {
910            enabled: true,
911            threshold: 0.3,     // lenient so rule score alone is likely to pass
912            llm_timeout_ms: 50, // tight timeout
913            llm_weight: 0.5,
914            ..QualityGateConfig::default()
915        };
916        let gate = QualityGate::new(config);
917
918        // Chat provider with 600ms delay — will exceed the 50ms timeout.
919        let slow_provider = zeph_llm::any::AnyProvider::Mock(
920            zeph_llm::mock::MockProvider::default().with_delay(600),
921        );
922        let gate = gate.with_llm_provider(slow_provider);
923
924        let embed_provider = mock_provider(); // no embeddings needed for this path
925
926        let result = gate
927            .evaluate(
928                "The release is scheduled for next Friday.",
929                &embed_provider,
930                &[],
931            )
932            .await;
933        // Gate must complete (no panic/hang) and fall back to rule-only score.
934        // With a lenient threshold and clean content the rule score should admit it.
935        assert!(
936            result.is_none(),
937            "LLM timeout must fall back to rule score and admit clean content, got {result:?}"
938        );
939    }
940}