Skip to main content

zeph_memory/
admission.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! A-MAC adaptive memory admission control (#2317).
5//!
6//! Write-time gate inserted before `SQLite` persistence in `remember()` and `remember_with_parts()`.
7//! Evaluates 5 factors and rejects messages below the configured threshold.
8
9use std::sync::Arc;
10use std::time::Duration;
11
12use zeph_llm::any::AnyProvider;
13use zeph_llm::provider::LlmProvider as _;
14
15use crate::embedding_store::EmbeddingStore;
16use zeph_common::math::cosine_similarity;
17
18/// Per-factor scores for the admission decision.
19#[derive(Debug, Clone, serde::Serialize)]
20pub struct AdmissionFactors {
21    /// LLM-estimated reuse probability. `[0, 1]`. Set to 0.5 on fast path or LLM failure.
22    pub future_utility: f32,
23    /// Inverse hedging heuristic: high confidence → high score. `[0, 1]`.
24    pub factual_confidence: f32,
25    /// `1.0 - max_similarity_to_top3_neighbors`. `[0, 1]`. 1.0 when memory is empty.
26    pub semantic_novelty: f32,
27    /// Always `1.0` at write time (decay applied at recall). `[0, 1]`.
28    pub temporal_recency: f32,
29    /// Prior based on message role. `[0, 1]`.
30    pub content_type_prior: f32,
31    /// Goal-conditioned utility (#2408). Cosine similarity between goal embedding and
32    /// candidate memory. `0.0` when `goal_conditioned_write = false` or goal text is absent/trivial.
33    pub goal_utility: f32,
34}
35
36/// Result of an admission evaluation.
37#[derive(Debug, Clone)]
38pub struct AdmissionDecision {
39    pub admitted: bool,
40    pub composite_score: f32,
41    pub factors: AdmissionFactors,
42}
43
44/// Normalized weights for the composite score.
45#[derive(Debug, Clone, Copy)]
46pub struct AdmissionWeights {
47    pub future_utility: f32,
48    pub factual_confidence: f32,
49    pub semantic_novelty: f32,
50    pub temporal_recency: f32,
51    pub content_type_prior: f32,
52    /// Goal-conditioned utility weight. `0.0` when goal gate is disabled.
53    pub goal_utility: f32,
54}
55
56impl AdmissionWeights {
57    /// Return a copy with all fields clamped to `>= 0.0` and normalized so they sum to `1.0`.
58    ///
59    /// Falls back to equal weights when the sum is effectively zero (all fields were zero/negative).
60    #[must_use]
61    pub fn normalized(&self) -> Self {
62        let fu = self.future_utility.max(0.0);
63        let fc = self.factual_confidence.max(0.0);
64        let sn = self.semantic_novelty.max(0.0);
65        let tr = self.temporal_recency.max(0.0);
66        let cp = self.content_type_prior.max(0.0);
67        let gu = self.goal_utility.max(0.0);
68        let sum = fu + fc + sn + tr + cp + gu;
69        if sum <= f32::EPSILON {
70            // Equal fallback weights (6 factors when goal gate is enabled).
71            return Self {
72                future_utility: 0.2,
73                factual_confidence: 0.2,
74                semantic_novelty: 0.2,
75                temporal_recency: 0.2,
76                content_type_prior: 0.2,
77                goal_utility: 0.0,
78            };
79        }
80        Self {
81            future_utility: fu / sum,
82            factual_confidence: fc / sum,
83            semantic_novelty: sn / sum,
84            temporal_recency: tr / sum,
85            content_type_prior: cp / sum,
86            goal_utility: gu / sum,
87        }
88    }
89}
90
91/// Goal-conditioned write gate configuration for `AdmissionControl`.
92#[derive(Debug, Clone)]
93pub struct GoalGateConfig {
94    /// Minimum cosine similarity to consider memory goal-relevant.
95    pub threshold: f32,
96    /// LLM provider for borderline refinement (similarity within 0.1 of threshold).
97    pub provider: Option<AnyProvider>,
98    /// Weight of the `goal_utility` factor in the composite score.
99    pub weight: f32,
100}
101
102/// A-MAC adaptive memory admission controller (#2317).
103///
104/// Evaluates five factors (future utility, factual confidence, semantic novelty,
105/// temporal recency, content-type prior) and rejects messages below the configured
106/// composite score threshold before they are persisted.
107///
108/// Optionally extended with a goal-conditioned write gate (#2408) that adds a
109/// sixth factor based on the cosine similarity between the current goal embedding
110/// and the candidate memory.
111///
112/// # Examples
113///
114/// ```rust,no_run
115/// use zeph_memory::{AdmissionControl, AdmissionWeights};
116///
117/// let weights = AdmissionWeights {
118///     future_utility: 0.3,
119///     factual_confidence: 0.2,
120///     semantic_novelty: 0.2,
121///     temporal_recency: 0.1,
122///     content_type_prior: 0.2,
123///     goal_utility: 0.0,
124/// };
125/// let controller = AdmissionControl::new(0.4, 0.1, weights);
126/// ```
127pub struct AdmissionControl {
128    threshold: f32,
129    fast_path_margin: f32,
130    weights: AdmissionWeights,
131    /// Dedicated provider for LLM-based evaluation. Falls back to the caller-supplied provider
132    /// when `None` (e.g. in tests or when `admission_provider` is not configured).
133    provider: Option<AnyProvider>,
134    /// Goal-conditioned write gate. `None` when `goal_conditioned_write = false`.
135    goal_gate: Option<GoalGateConfig>,
136    /// Per-call timeout for every `embed()` invocation. Default: 5 s.
137    embed_timeout: Duration,
138}
139
140impl AdmissionControl {
141    /// Create a new admission controller.
142    ///
143    /// - `threshold` — composite score `[0, 1]` below which messages are rejected.
144    /// - `fast_path_margin` — when all non-LLM factors already push the score far above
145    ///   the threshold (by at least this margin), the LLM `future_utility` call is skipped.
146    /// - `weights` — factor weights; normalized automatically so they sum to `1.0`.
147    #[must_use]
148    pub fn new(threshold: f32, fast_path_margin: f32, weights: AdmissionWeights) -> Self {
149        Self {
150            threshold,
151            fast_path_margin,
152            weights: weights.normalized(),
153            provider: None,
154            goal_gate: None,
155            embed_timeout: Duration::from_secs(5),
156        }
157    }
158
159    /// Set the per-call timeout for every `embed()` invocation.
160    ///
161    /// Default: 5 s. Must be non-zero; the minimum effective value is 1 s.
162    #[must_use]
163    pub fn with_embed_timeout(mut self, timeout_secs: u64) -> Self {
164        self.embed_timeout = Duration::from_secs(timeout_secs.max(1));
165        self
166    }
167
168    /// Attach a dedicated LLM provider for `future_utility` evaluation.
169    ///
170    /// When set, this provider is used instead of the caller-supplied fallback.
171    #[must_use]
172    pub fn with_provider(mut self, provider: AnyProvider) -> Self {
173        self.provider = Some(provider);
174        self
175    }
176
177    /// Enable goal-conditioned write gate (#2408).
178    #[must_use]
179    pub fn with_goal_gate(mut self, config: GoalGateConfig) -> Self {
180        // Redistribute goal_utility weight from future_utility.
181        let gu = config.weight.clamp(0.0, 1.0);
182        let mut weights = self.weights;
183        weights.goal_utility = gu;
184        // Reduce future_utility by the same amount (soft redistribution).
185        weights.future_utility = (weights.future_utility - gu).max(0.0);
186        self.weights = weights.normalized();
187        self.goal_gate = Some(config);
188        self
189    }
190
191    /// Return the configured admission threshold.
192    #[must_use]
193    pub fn threshold(&self) -> f32 {
194        self.threshold
195    }
196
197    /// Evaluate admission for a message.
198    ///
199    /// `goal_text`: optional current-turn goal context for goal-conditioned scoring.
200    /// Ignored when the goal gate is disabled or `goal_text` is `None`/trivial (< 10 chars).
201    ///
202    /// Fast path: skips LLM when heuristic-only score is already above `threshold + fast_path_margin`.
203    /// Slow path: calls LLM for `future_utility` when borderline.
204    ///
205    /// On LLM failure, `future_utility` defaults to `0.5` (neutral).
206    #[cfg_attr(
207        feature = "profiling",
208        tracing::instrument(name = "memory.admission", skip_all)
209    )]
210    pub async fn evaluate(
211        &self,
212        content: &str,
213        role: &str,
214        fallback_provider: &AnyProvider,
215        qdrant: Option<&Arc<EmbeddingStore>>,
216        goal_text: Option<&str>,
217    ) -> AdmissionDecision {
218        let effective_provider = self.provider.as_ref().unwrap_or(fallback_provider);
219        let factual_confidence = compute_factual_confidence(content);
220        let temporal_recency = 1.0f32;
221        let content_type_prior = compute_content_type_prior(role);
222
223        // Semantic novelty requires an async embedding search.
224        let semantic_novelty =
225            compute_semantic_novelty(content, effective_provider, qdrant, self.embed_timeout).await;
226
227        // Goal-conditioned utility (W3.1 fix: skip trivial goal text < 10 chars).
228        let goal_utility = match &self.goal_gate {
229            Some(gate) => {
230                let effective_goal = goal_text.filter(|t| t.trim().len() >= 10);
231                if let Some(goal) = effective_goal {
232                    compute_goal_utility(
233                        content,
234                        goal,
235                        gate,
236                        effective_provider,
237                        qdrant,
238                        self.embed_timeout,
239                    )
240                    .await
241                } else {
242                    0.0
243                }
244            }
245            None => 0.0,
246        };
247
248        // Heuristic-only composite (future_utility treated as 0.5 neutral placeholder).
249        let heuristic_score = self.weighted_score(
250            0.5,
251            factual_confidence,
252            semantic_novelty,
253            temporal_recency,
254            content_type_prior,
255            goal_utility,
256        );
257
258        // Fast path: admit without LLM if score is clearly above threshold + margin.
259        let future_utility = if heuristic_score >= self.threshold + self.fast_path_margin {
260            0.5 // not used in final score since we admit early, but kept for audit
261        } else {
262            compute_future_utility(content, role, effective_provider).await
263        };
264
265        let composite_score = self.weighted_score(
266            future_utility,
267            factual_confidence,
268            semantic_novelty,
269            temporal_recency,
270            content_type_prior,
271            goal_utility,
272        );
273
274        let admitted = composite_score >= self.threshold
275            || heuristic_score >= self.threshold + self.fast_path_margin;
276
277        AdmissionDecision {
278            admitted,
279            composite_score,
280            factors: AdmissionFactors {
281                future_utility,
282                factual_confidence,
283                semantic_novelty,
284                temporal_recency,
285                content_type_prior,
286                goal_utility,
287            },
288        }
289    }
290
291    fn weighted_score(
292        &self,
293        future_utility: f32,
294        factual_confidence: f32,
295        semantic_novelty: f32,
296        temporal_recency: f32,
297        content_type_prior: f32,
298        goal_utility: f32,
299    ) -> f32 {
300        future_utility * self.weights.future_utility
301            + factual_confidence * self.weights.factual_confidence
302            + semantic_novelty * self.weights.semantic_novelty
303            + temporal_recency * self.weights.temporal_recency
304            + content_type_prior * self.weights.content_type_prior
305            + goal_utility * self.weights.goal_utility
306    }
307}
308
309/// Heuristic: detect hedging markers and compute confidence score.
310///
311/// Returns `1.0` for confident content, lower for content with hedging language.
312#[must_use]
313pub fn compute_factual_confidence(content: &str) -> f32 {
314    // Common English hedging markers. Content in other languages scores 1.0 (no penalty).
315    const HEDGING_MARKERS: &[&str] = &[
316        "maybe",
317        "might",
318        "perhaps",
319        "i think",
320        "i believe",
321        "not sure",
322        "could be",
323        "possibly",
324        "probably",
325        "uncertain",
326        "not certain",
327        "i'm not sure",
328        "im not sure",
329        "not confident",
330    ];
331    let lower = content.to_lowercase();
332    let matches = HEDGING_MARKERS
333        .iter()
334        .filter(|&&m| lower.contains(m))
335        .count();
336    // Each hedging marker reduces confidence by 0.1, min 0.2.
337    #[allow(clippy::cast_precision_loss)]
338    let penalty = (matches as f32) * 0.1;
339    (1.0 - penalty).max(0.2)
340}
341
342/// Prior score based on message role.
343///
344/// Tool results (role "tool") are treated as high-value since they contain factual data.
345/// The table is not symmetric to role importance — it's calibrated by typical content density.
346#[must_use]
347pub fn compute_content_type_prior(role: &str) -> f32 {
348    match role {
349        "user" => 0.7,
350        "assistant" => 0.6,
351        "tool" | "tool_result" => 0.8,
352        "system" => 0.3,
353        _ => 0.5,
354    }
355}
356
357/// Compute semantic novelty as `1.0 - max_cosine_similarity_to_top3_neighbors`.
358///
359/// Returns `1.0` when the memory is empty (everything is novel at cold start).
360#[tracing::instrument(name = "memory.admission.semantic_novelty", skip_all)]
361async fn compute_semantic_novelty(
362    content: &str,
363    provider: &AnyProvider,
364    qdrant: Option<&Arc<EmbeddingStore>>,
365    embed_timeout: Duration,
366) -> f32 {
367    let Some(store) = qdrant else {
368        return 1.0;
369    };
370    if !provider.supports_embeddings() {
371        return 1.0;
372    }
373    let vector = match crate::llm_judge::embed_with_timeout_fail_open(
374        provider,
375        content,
376        embed_timeout,
377        1.0,
378        "A-MAC: semantic_novelty",
379    )
380    .await
381    {
382        Ok(v) => v,
383        Err(fail_open) => return fail_open,
384    };
385    if let Err(e) = store.ensure_collection_for_vector(&vector).await {
386        tracing::debug!(error = %e, "A-MAC: collection not ready for novelty check");
387        return 1.0;
388    }
389    let results = match store.search(&vector, 3, None).await {
390        Ok(r) => r,
391        Err(e) => {
392            tracing::debug!(error = %e, "A-MAC: novelty search failed, using 1.0");
393            return 1.0;
394        }
395    };
396    let max_sim = results.iter().map(|r| r.score).fold(0.0f32, f32::max);
397    (1.0 - max_sim).max(0.0)
398}
399
400/// LLM-based future utility estimate.
401///
402/// On timeout or error, returns `0.5` (neutral — no bias toward admit or reject).
403#[tracing::instrument(name = "memory.admission.future_utility_llm", skip_all)]
404async fn compute_future_utility(content: &str, role: &str, provider: &AnyProvider) -> f32 {
405    let system = "You are a memory relevance judge. Rate how likely this message will be \
406        referenced in future conversations on a scale of 0.0 to 1.0. \
407        Respond with ONLY a decimal number between 0.0 and 1.0, nothing else.";
408
409    let user = format!(
410        "Role: {role}\nContent: {}\n\nFuture utility score (0.0-1.0):",
411        content.chars().take(500).collect::<String>()
412    );
413
414    crate::llm_judge::llm_judge_score(
415        provider,
416        system,
417        user,
418        Duration::from_secs(8),
419        0.5,
420        "A-MAC: future_utility",
421        |s| s.trim().parse::<f32>().ok(),
422    )
423    .await
424}
425
426/// Compute goal-conditioned utility for a candidate memory.
427///
428/// Embeds the goal text and candidate content, then returns cosine similarity.
429/// For borderline cases (similarity within 0.1 of threshold), optionally calls
430/// the LLM for refinement if a `goal_utility_provider` is configured.
431///
432/// Returns a soft-floored score: min similarity is 0.1 to avoid fully eliminating
433/// memories that are somewhat off-goal but otherwise high-value (W3.4 fix).
434///
435/// Returns `0.0` on embedding failure (safe admission without goal factor).
436async fn compute_goal_utility(
437    content: &str,
438    goal_text: &str,
439    gate: &GoalGateConfig,
440    provider: &AnyProvider,
441    qdrant: Option<&Arc<EmbeddingStore>>,
442    embed_timeout: Duration,
443) -> f32 {
444    use zeph_llm::provider::LlmProvider as _;
445
446    if !provider.supports_embeddings() {
447        return 0.0;
448    }
449
450    let goal_emb = match crate::llm_judge::embed_with_timeout_fail_open(
451        provider,
452        goal_text,
453        embed_timeout,
454        0.0,
455        "goal_utility: goal text",
456    )
457    .await
458    {
459        Ok(v) => v,
460        Err(fail_open) => return fail_open,
461    };
462    let content_emb = match crate::llm_judge::embed_with_timeout_fail_open(
463        provider,
464        content,
465        embed_timeout,
466        0.0,
467        "goal_utility: content",
468    )
469    .await
470    {
471        Ok(v) => v,
472        Err(fail_open) => return fail_open,
473    };
474
475    // Qdrant is used for novelty search, not for goal utility — we compute cosine directly.
476    let _ = qdrant; // unused here; kept for API symmetry
477
478    let similarity = cosine_similarity(&goal_emb, &content_emb);
479
480    // Borderline: call LLM for refinement when configured (W3.5: skipped when no provider).
481    let borderline_lo = gate.threshold - 0.1;
482    let borderline_hi = gate.threshold + 0.1;
483    let in_borderline = similarity >= borderline_lo && similarity <= borderline_hi;
484
485    let final_similarity = if in_borderline {
486        if let Some(ref goal_provider) = gate.provider {
487            refine_goal_utility_llm(content, goal_text, similarity, goal_provider).await
488        } else {
489            similarity
490        }
491    } else {
492        similarity
493    };
494
495    // Hard gate below threshold; soft floor of 0.1 above threshold (W3.4 fix).
496    if final_similarity < gate.threshold {
497        0.0
498    } else {
499        final_similarity.max(0.1)
500    }
501}
502
503/// LLM-based goal utility refinement for borderline cases.
504///
505/// Returns the original `embedding_sim` on failure (safe fallback).
506#[tracing::instrument(name = "memory.admission.goal_utility_refine_llm", skip_all)]
507async fn refine_goal_utility_llm(
508    content: &str,
509    goal_text: &str,
510    embedding_sim: f32,
511    provider: &AnyProvider,
512) -> f32 {
513    let system = "You are a memory relevance judge. Given a task goal and a candidate memory, \
514        rate how relevant the memory is to the goal on a scale of 0.0 to 1.0. \
515        Respond with ONLY a decimal number between 0.0 and 1.0, nothing else.";
516
517    let user = format!(
518        "Goal: {}\nMemory: {}\n\nRelevance score (0.0-1.0):",
519        goal_text.chars().take(200).collect::<String>(),
520        content.chars().take(300).collect::<String>(),
521    );
522
523    crate::llm_judge::llm_judge_score(
524        provider,
525        system,
526        user,
527        Duration::from_secs(6),
528        embedding_sim,
529        "goal_utility LLM refinement",
530        |s| s.trim().parse::<f32>().ok(),
531    )
532    .await
533}
534
535/// Log an admission decision to the audit log via `tracing`.
536///
537/// Rejections are always logged at debug level. Admissions are trace-level.
538pub fn log_admission_decision(
539    decision: &AdmissionDecision,
540    content_preview: &str,
541    role: &str,
542    threshold: f32,
543) {
544    if decision.admitted {
545        tracing::trace!(
546            role,
547            composite_score = decision.composite_score,
548            threshold,
549            content_preview,
550            "A-MAC: admitted"
551        );
552    } else {
553        tracing::debug!(
554            role,
555            composite_score = decision.composite_score,
556            threshold,
557            future_utility = decision.factors.future_utility,
558            factual_confidence = decision.factors.factual_confidence,
559            semantic_novelty = decision.factors.semantic_novelty,
560            content_type_prior = decision.factors.content_type_prior,
561            content_preview,
562            "A-MAC: rejected"
563        );
564    }
565}
566
567/// Error type for admission-rejected persists.
568#[derive(Debug)]
569pub struct AdmissionRejected {
570    pub composite_score: f32,
571    pub threshold: f32,
572}
573
574impl std::fmt::Display for AdmissionRejected {
575    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
576        write!(
577            f,
578            "A-MAC admission rejected (score={:.3} < threshold={:.3})",
579            self.composite_score, self.threshold
580        )
581    }
582}
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587
588    #[test]
589    fn factual_confidence_no_hedging() {
590        assert!((compute_factual_confidence("The server uses TLS 1.3.") - 1.0).abs() < 0.01);
591    }
592
593    #[test]
594    fn factual_confidence_with_one_marker() {
595        let score = compute_factual_confidence("Maybe we should use TLS 1.3.");
596        assert!((score - 0.9).abs() < 0.01);
597    }
598
599    #[test]
600    fn factual_confidence_many_markers_floors_at_0_2() {
601        let content = "maybe i think perhaps possibly might not sure i believe";
602        let score = compute_factual_confidence(content);
603        assert!(score >= 0.2);
604        assert!(score < 0.5);
605    }
606
607    #[test]
608    fn content_type_prior_values() {
609        assert!((compute_content_type_prior("user") - 0.7).abs() < 0.01);
610        assert!((compute_content_type_prior("assistant") - 0.6).abs() < 0.01);
611        assert!((compute_content_type_prior("tool") - 0.8).abs() < 0.01);
612        assert!((compute_content_type_prior("system") - 0.3).abs() < 0.01);
613        assert!((compute_content_type_prior("unknown") - 0.5).abs() < 0.01);
614    }
615
616    #[test]
617    fn admission_control_admits_high_score() {
618        let weights = AdmissionWeights {
619            future_utility: 0.30,
620            factual_confidence: 0.15,
621            semantic_novelty: 0.30,
622            temporal_recency: 0.10,
623            content_type_prior: 0.15,
624            goal_utility: 0.0,
625        };
626        let ctrl = AdmissionControl::new(0.40, 0.15, weights);
627        // Score all factors at 1.0 → composite = 1.0.
628        let score = ctrl.weighted_score(1.0, 1.0, 1.0, 1.0, 1.0, 0.0);
629        assert!(score >= 0.99);
630        // Admitted when score >= threshold.
631        let admitted = score >= ctrl.threshold;
632        assert!(admitted);
633    }
634
635    #[test]
636    fn admission_control_rejects_low_score() {
637        let weights = AdmissionWeights {
638            future_utility: 0.30,
639            factual_confidence: 0.15,
640            semantic_novelty: 0.30,
641            temporal_recency: 0.10,
642            content_type_prior: 0.15,
643            goal_utility: 0.0,
644        };
645        let ctrl = AdmissionControl::new(0.40, 0.15, weights);
646        // Score all factors at 0.0 → composite = 0.0.
647        let score = ctrl.weighted_score(0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
648        assert!(score < ctrl.threshold);
649    }
650
651    // Test: fast-path score above threshold + margin bypasses slow-path (LLM call skipped).
652    // We verify the branch logic in weighted_score: if heuristic >= threshold + margin, admitted.
653    #[test]
654    fn fast_path_admits_when_heuristic_above_threshold_plus_margin() {
655        let weights = AdmissionWeights {
656            future_utility: 0.20,
657            factual_confidence: 0.20,
658            semantic_novelty: 0.20,
659            temporal_recency: 0.20,
660            content_type_prior: 0.20,
661            goal_utility: 0.0,
662        };
663        let threshold = 0.40f32;
664        let margin = 0.15f32;
665        let ctrl = AdmissionControl::new(threshold, margin, weights);
666
667        // All non-future_utility factors at 1.0; future_utility treated as 0.5 (fast path neutral).
668        let heuristic = ctrl.weighted_score(0.5, 1.0, 1.0, 1.0, 1.0, 0.0);
669        // heuristic = 0.5*0.2 + 1.0*0.2 + 1.0*0.2 + 1.0*0.2 + 1.0*0.2 = 0.1 + 0.8 = 0.9
670        assert!(
671            heuristic >= threshold + margin,
672            "heuristic {heuristic} must exceed threshold+margin {}",
673            threshold + margin
674        );
675        // In evaluate(), admitted = composite >= threshold || heuristic >= threshold + margin.
676        let admitted = heuristic >= threshold + margin;
677        assert!(admitted, "fast path must admit without LLM call");
678    }
679
680    // Test: slow-path engages when heuristic is below threshold + margin.
681    #[test]
682    fn slow_path_required_when_heuristic_below_threshold_plus_margin() {
683        let weights = AdmissionWeights {
684            future_utility: 0.40,
685            factual_confidence: 0.15,
686            semantic_novelty: 0.15,
687            temporal_recency: 0.15,
688            content_type_prior: 0.15,
689            goal_utility: 0.0,
690        };
691        let threshold = 0.50f32;
692        let margin = 0.20f32;
693        let ctrl = AdmissionControl::new(threshold, margin, weights);
694
695        // All factors low — heuristic will be below threshold + margin.
696        let heuristic = ctrl.weighted_score(0.5, 0.3, 0.3, 0.3, 0.3, 0.0);
697        assert!(
698            heuristic < threshold + margin,
699            "heuristic {heuristic} must be below threshold+margin {}",
700            threshold + margin
701        );
702    }
703
704    // Test: log_admission_decision runs without panic for both admitted and rejected.
705    #[test]
706    fn log_admission_decision_does_not_panic() {
707        let admitted_decision = AdmissionDecision {
708            admitted: true,
709            composite_score: 0.75,
710            factors: AdmissionFactors {
711                future_utility: 0.8,
712                factual_confidence: 0.9,
713                semantic_novelty: 0.7,
714                temporal_recency: 1.0,
715                content_type_prior: 0.7,
716                goal_utility: 0.0,
717            },
718        };
719        log_admission_decision(&admitted_decision, "preview text", "user", 0.40);
720
721        let rejected_decision = AdmissionDecision {
722            admitted: false,
723            composite_score: 0.20,
724            factors: AdmissionFactors {
725                future_utility: 0.1,
726                factual_confidence: 0.2,
727                semantic_novelty: 0.3,
728                temporal_recency: 1.0,
729                content_type_prior: 0.3,
730                goal_utility: 0.0,
731            },
732        };
733        log_admission_decision(&rejected_decision, "maybe short content", "assistant", 0.40);
734    }
735
736    // Test: AdmissionRejected Display formats correctly.
737    #[test]
738    fn admission_rejected_display() {
739        let err = AdmissionRejected {
740            composite_score: 0.25,
741            threshold: 0.45,
742        };
743        let msg = format!("{err}");
744        assert!(msg.contains("0.250"));
745        assert!(msg.contains("0.450"));
746    }
747
748    // Test: threshold() accessor returns the configured value.
749    #[test]
750    fn threshold_accessor() {
751        let weights = AdmissionWeights {
752            future_utility: 0.20,
753            factual_confidence: 0.20,
754            semantic_novelty: 0.20,
755            temporal_recency: 0.20,
756            content_type_prior: 0.20,
757            goal_utility: 0.0,
758        };
759        let ctrl = AdmissionControl::new(0.55, 0.10, weights);
760        assert!((ctrl.threshold() - 0.55).abs() < 0.001);
761    }
762
763    // Test: content_type_prior for "tool_result" alias.
764    #[test]
765    fn content_type_prior_tool_result_alias() {
766        assert!((compute_content_type_prior("tool_result") - 0.8).abs() < 0.01);
767    }
768
769    // ── cosine_similarity tests ───────────────────────────────────────────────
770
771    #[test]
772    fn cosine_similarity_identical_vectors() {
773        let v = vec![1.0f32, 0.0, 0.0];
774        assert!((cosine_similarity(&v, &v) - 1.0).abs() < 1e-6);
775    }
776
777    #[test]
778    fn cosine_similarity_orthogonal_vectors() {
779        let a = vec![1.0f32, 0.0];
780        let b = vec![0.0f32, 1.0];
781        assert!(cosine_similarity(&a, &b).abs() < 1e-6);
782    }
783
784    #[test]
785    fn cosine_similarity_zero_vector_returns_zero() {
786        let z = vec![0.0f32, 0.0, 0.0];
787        let v = vec![1.0f32, 2.0, 3.0];
788        assert!(cosine_similarity(&z, &v).abs() < f32::EPSILON);
789    }
790
791    #[test]
792    fn cosine_similarity_length_mismatch_returns_zero() {
793        let a = vec![1.0f32, 0.0];
794        let b = vec![1.0f32, 0.0, 0.0];
795        assert!(cosine_similarity(&a, &b).abs() < f32::EPSILON);
796    }
797
798    // ── with_goal_gate tests ──────────────────────────────────────────────────
799
800    #[test]
801    fn with_goal_gate_sets_goal_utility_weight() {
802        let weights = AdmissionWeights {
803            future_utility: 0.30,
804            factual_confidence: 0.15,
805            semantic_novelty: 0.30,
806            temporal_recency: 0.10,
807            content_type_prior: 0.15,
808            goal_utility: 0.0,
809        };
810        let ctrl = AdmissionControl::new(0.40, 0.15, weights);
811        let config = GoalGateConfig {
812            weight: 0.20,
813            threshold: 0.5,
814            provider: None,
815        };
816        let ctrl = ctrl.with_goal_gate(config);
817        assert!(
818            ctrl.weights.goal_utility > 0.0,
819            "goal_utility must be nonzero after with_goal_gate"
820        );
821        // Normalized weights must sum to ~1.0.
822        let w = &ctrl.weights;
823        let total = w.future_utility
824            + w.factual_confidence
825            + w.semantic_novelty
826            + w.temporal_recency
827            + w.content_type_prior
828            + w.goal_utility;
829        assert!(
830            (total - 1.0).abs() < 0.01,
831            "normalized weights must sum to 1.0, got {total}"
832        );
833    }
834
835    #[test]
836    fn with_goal_gate_zero_weight_leaves_goal_utility_at_zero() {
837        let weights = AdmissionWeights {
838            future_utility: 0.30,
839            factual_confidence: 0.15,
840            semantic_novelty: 0.30,
841            temporal_recency: 0.10,
842            content_type_prior: 0.15,
843            goal_utility: 0.0,
844        };
845        let ctrl = AdmissionControl::new(0.40, 0.15, weights);
846        let config = GoalGateConfig {
847            weight: 0.0,
848            threshold: 0.5,
849            provider: None,
850        };
851        let ctrl = ctrl.with_goal_gate(config);
852        assert!(ctrl.weights.goal_utility.abs() < f32::EPSILON);
853    }
854
855    // ── timeout regression tests (#4212) ─────────────────────────────────────
856
857    #[tokio::test]
858    async fn compute_semantic_novelty_returns_one_on_embed_timeout() {
859        tokio::time::pause();
860        let mock = zeph_llm::mock::MockProvider::default()
861            .with_embed_delay(10_000)
862            .with_embedding(vec![0.0; 4]);
863        let provider = zeph_llm::any::AnyProvider::Mock(mock);
864        let novelty_fut = async move {
865            compute_semantic_novelty("hello", &provider, None, Duration::from_secs(5)).await
866        };
867        let handle = tokio::spawn(novelty_fut); // EXEMPT: test-only tokio::time::pause harness
868        tokio::time::advance(std::time::Duration::from_secs(6)).await;
869        let result = handle.await.expect("task panicked");
870        assert!(
871            (result - 1.0).abs() < f32::EPSILON,
872            "expected 1.0 on embed timeout, got {result}"
873        );
874    }
875
876    #[tokio::test]
877    async fn compute_goal_utility_returns_zero_on_embed_timeout() {
878        tokio::time::pause();
879        let mock = zeph_llm::mock::MockProvider::default()
880            .with_embed_delay(10_000)
881            .with_embedding(vec![0.0; 4]);
882        let provider = zeph_llm::any::AnyProvider::Mock(mock);
883        let gate = GoalGateConfig {
884            weight: 0.5,
885            threshold: 0.5,
886            provider: None,
887        };
888        let goal_fut = async move {
889            compute_goal_utility(
890                "content",
891                "goal",
892                &gate,
893                &provider,
894                None,
895                Duration::from_secs(5),
896            )
897            .await
898        };
899        let handle = tokio::spawn(goal_fut); // EXEMPT: test-only tokio::time::pause harness
900        tokio::time::advance(std::time::Duration::from_secs(6)).await;
901        let result = handle.await.expect("task panicked");
902        assert!(
903            result.abs() < f32::EPSILON,
904            "expected 0.0 on embed timeout, got {result}"
905        );
906    }
907}