Skip to main content

converge_core/
recall.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! # Recall Types — Portable across all backends
5//!
6//! This module defines the **constitutional types** for semantic recall.
7//! These types encode the core axiom: "Recall ≠ Evidence".
8//!
9//! ## Axiom: Recall ≠ Evidence
10//!
11//! Recall provides **hints** to guide reasoning, not **citations** to justify claims.
12//! Validators MUST reject any output that cites recall content as evidence.
13//!
14//! ## What lives here (converge-core)
15//!
16//! - `RecallQuery`, `RecallCandidate`, `RecallPolicy`, `RecallBudgets`
17//! - `RecallProvenanceEnvelope`, `RecallTraceLink`
18//! - `CandidateSourceType`, `CandidateScore`, `StopReason`
19//! - `RecallUse`, `RecallConsumer` (training boundary types)
20//!
21//! ## What stays in converge-llm
22//!
23//! - `HashEmbedder`, `SemanticEmbedder` (implementations)
24//! - `RecallNormalizer` (tightly coupled to prompt injection)
25//! - PII redaction utilities
26//! - `MockRecallProvider`
27
28use crate::experience_store::{
29    EventQuery, ExperienceEvent, ExperienceRecord, ExperienceStore, ExperienceStoreResult,
30    UserExperienceEvent,
31};
32use crate::kernel_boundary::DecisionStep;
33use crate::types::TenantId;
34use serde::{Deserialize, Serialize};
35
36// ============================================================================
37// Recall Use/Consumer Types (Recall ≠ Training boundary)
38// ============================================================================
39
40/// Purpose of a recall operation.
41///
42/// Distinguishes runtime augmentation (injecting hints into prompts) from
43/// training-time candidate selection (building datasets). This separation
44/// preserves "Recall ≠ Evidence" and "Recall ≠ Training" boundaries.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
46pub enum RecallUse {
47    /// Runtime prompt augmentation (hints only, not evidence)
48    RuntimeAugmentation,
49    /// Training data candidate selection (offline, auditable)
50    TrainingCandidateSelection,
51}
52
53impl Default for RecallUse {
54    fn default() -> Self {
55        Self::RuntimeAugmentation
56    }
57}
58
59/// Consumer of recall results.
60///
61/// Tracks which component is using the recall results for audit trails
62/// and to enforce that training consumers cannot masquerade as runtime.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
64pub enum RecallConsumer {
65    /// Reasoning kernel (runtime prompts)
66    Kernel,
67    /// Analytics pipeline (eval, metrics)
68    Analytics,
69    /// Training pipeline (dataset building)
70    Trainer,
71}
72
73impl Default for RecallConsumer {
74    fn default() -> Self {
75        Self::Kernel
76    }
77}
78
79// ============================================================================
80// Recall Policy and Configuration
81// ============================================================================
82
83/// Policy controlling recall behavior.
84///
85/// This is the declarative configuration for recall operations.
86/// It controls what is allowed, not how it is implemented.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct RecallPolicy {
89    /// Whether recall is enabled
90    pub enabled: bool,
91    /// Maximum number of candidates to return total
92    pub max_k_total: usize,
93    /// Maximum tokens to inject from recall context
94    pub max_tokens_injection: usize,
95    /// Minimum similarity score threshold
96    pub min_score_threshold: f64,
97    /// Budget constraints
98    pub budgets: RecallBudgets,
99    /// Allowed recall uses (runtime, training, etc.)
100    ///
101    /// Defaults to `[RuntimeAugmentation]` only - training use must be
102    /// explicitly enabled. This preserves "Recall ≠ Training" boundary.
103    #[serde(default = "default_allowed_uses")]
104    pub allowed_uses: Vec<RecallUse>,
105
106    /// How strongly recall results weight planning priors when consumed by
107    /// `PlanningPriorAgent`. `1.0` means full weight; `0.0` disables prior
108    /// adjustment without disabling recall itself. Capped to `[0.0, 1.0]` by
109    /// consumers.
110    #[serde(default = "default_prior_weight")]
111    pub prior_weight: f64,
112}
113
114fn default_prior_weight() -> f64 {
115    1.0
116}
117
118fn default_allowed_uses() -> Vec<RecallUse> {
119    vec![RecallUse::RuntimeAugmentation]
120}
121
122impl Default for RecallPolicy {
123    fn default() -> Self {
124        Self {
125            enabled: false,
126            max_k_total: 5,
127            max_tokens_injection: 500,
128            min_score_threshold: 0.5,
129            budgets: RecallBudgets::default(),
130            allowed_uses: default_allowed_uses(),
131            prior_weight: default_prior_weight(),
132        }
133    }
134}
135
136impl RecallPolicy {
137    /// Create an enabled recall policy with default settings.
138    #[must_use]
139    pub fn enabled() -> Self {
140        Self {
141            enabled: true,
142            ..Default::default()
143        }
144    }
145
146    /// Create a disabled recall policy.
147    #[must_use]
148    pub fn disabled() -> Self {
149        Self::default()
150    }
151
152    /// Check if a specific recall use is allowed by this policy.
153    ///
154    /// Returns `true` if the policy allows the given use, `false` otherwise.
155    /// This is the primary enforcement point for "Recall ≠ Training" boundary.
156    #[must_use]
157    pub fn is_use_allowed(&self, purpose: RecallUse) -> bool {
158        self.allowed_uses.contains(&purpose)
159    }
160
161    /// Compute a deterministic hash of this policy for provenance tracking.
162    ///
163    /// This enables replay verification: same policy hash → same behavior.
164    /// Note: Includes `allowed_uses` in the hash for full provenance.
165    #[must_use]
166    pub fn snapshot_hash(&self) -> String {
167        use std::collections::hash_map::DefaultHasher;
168        use std::hash::{Hash, Hasher};
169
170        let mut hasher = DefaultHasher::new();
171        self.enabled.hash(&mut hasher);
172        self.max_k_total.hash(&mut hasher);
173        self.max_tokens_injection.hash(&mut hasher);
174        (self.min_score_threshold as u64).hash(&mut hasher);
175        self.budgets.max_latency_ms.hash(&mut hasher);
176        self.budgets.max_embedding_calls.hash(&mut hasher);
177        self.budgets.max_tokens_per_candidate.hash(&mut hasher);
178        for use_type in &self.allowed_uses {
179            (*use_type as u8).hash(&mut hasher);
180        }
181        (self.prior_weight as u64).hash(&mut hasher);
182        format!("{:016x}", hasher.finish())
183    }
184}
185
186/// Check if a recall use is allowed by the given policy.
187///
188/// Standalone function for use at kernel boundary enforcement.
189/// Returns `true` if the policy allows the given purpose.
190#[must_use]
191pub fn recall_use_allowed(policy: &RecallPolicy, purpose: RecallUse) -> bool {
192    policy.is_use_allowed(purpose)
193}
194
195/// Budget constraints for recall operations.
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct RecallBudgets {
198    /// Maximum latency in milliseconds for recall operations
199    pub max_latency_ms: u64,
200    /// Maximum number of embedding calls per chain
201    pub max_embedding_calls: usize,
202    /// Maximum tokens per candidate summary
203    pub max_tokens_per_candidate: usize,
204}
205
206impl Default for RecallBudgets {
207    fn default() -> Self {
208        Self {
209            max_latency_ms: 100,
210            max_embedding_calls: 3,
211            max_tokens_per_candidate: 100,
212        }
213    }
214}
215
216// ============================================================================
217// Recall Query and Candidate Types
218// ============================================================================
219
220/// A query for semantic recall.
221#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct RecallQuery {
223    /// The text to find similar items for
224    pub query_text: String,
225    /// Number of candidates to return
226    pub top_k: usize,
227    /// Optional step context for filtering
228    pub step_context: Option<DecisionStep>,
229    /// Optional tenant scope
230    pub tenant_scope: Option<String>,
231}
232
233impl RecallQuery {
234    /// Create a new recall query.
235    #[must_use]
236    pub fn new(query_text: impl Into<String>, top_k: usize) -> Self {
237        Self {
238            query_text: query_text.into(),
239            top_k,
240            step_context: None,
241            tenant_scope: None,
242        }
243    }
244
245    /// Add step context filter.
246    #[must_use]
247    pub fn with_step_context(mut self, step: DecisionStep) -> Self {
248        self.step_context = Some(step);
249        self
250    }
251
252    /// Add tenant scope filter.
253    #[must_use]
254    pub fn with_tenant_scope(mut self, tenant: impl Into<String>) -> Self {
255        self.tenant_scope = Some(tenant.into());
256        self
257    }
258
259    /// Compute a deterministic hash of this query for provenance tracking.
260    #[must_use]
261    pub fn query_hash(&self) -> String {
262        use std::collections::hash_map::DefaultHasher;
263        use std::hash::{Hash, Hasher};
264
265        let mut hasher = DefaultHasher::new();
266        self.query_text.hash(&mut hasher);
267        self.top_k.hash(&mut hasher);
268        if let Some(ref step) = self.step_context {
269            step.as_str().hash(&mut hasher);
270        }
271        if let Some(ref tenant) = self.tenant_scope {
272            tenant.hash(&mut hasher);
273        }
274        format!("{:016x}", hasher.finish())
275    }
276}
277
278/// A candidate returned by recall.
279#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct RecallCandidate {
281    /// Unique identifier for this candidate
282    pub id: String,
283    /// Summary text of the candidate
284    pub summary: String,
285    /// Raw similarity score from vector search
286    pub raw_score: f64,
287    /// Final normalized score
288    pub final_score: f64,
289    /// Relevance level
290    pub relevance: RelevanceLevel,
291    /// Source type (failure, success, runbook, etc.)
292    pub source_type: CandidateSourceType,
293    /// Provenance information
294    pub provenance: CandidateProvenance,
295    /// Per-candidate confidence in `[0.0, 1.0]`. Reflects how much weight a
296    /// downstream consumer (e.g. `PlanningPriorAgent`) should give this entry
297    /// when adjusting priors. Defaults to `0.5` for backends that do not yet
298    /// emit calibrated confidence.
299    #[serde(default = "default_candidate_confidence")]
300    pub confidence: f64,
301}
302
303fn default_candidate_confidence() -> f64 {
304    0.5
305}
306
307/// Relevance level for a recall candidate.
308#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
309pub enum RelevanceLevel {
310    High,
311    Medium,
312    Low,
313}
314
315impl RelevanceLevel {
316    /// Create from a score (0.0-1.0).
317    #[must_use]
318    pub fn from_score(score: f64) -> Self {
319        if score >= 0.8 {
320            Self::High
321        } else if score >= 0.5 {
322            Self::Medium
323        } else {
324            Self::Low
325        }
326    }
327
328    /// Get the string representation.
329    #[must_use]
330    pub fn as_str(&self) -> &'static str {
331        match self {
332            Self::High => "high",
333            Self::Medium => "medium",
334            Self::Low => "low",
335        }
336    }
337}
338
339/// Source type for a recall candidate.
340#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
341pub enum CandidateSourceType {
342    SimilarFailure,
343    SimilarSuccess,
344    Runbook,
345    AdapterConfig,
346    AntiPattern,
347}
348
349/// Provenance information for a recall candidate.
350#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct CandidateProvenance {
352    /// When this record was created
353    pub created_at: String,
354    /// Chain ID that produced this record
355    pub source_chain_id: Option<String>,
356    /// Step that produced this record
357    pub source_step: Option<DecisionStep>,
358    /// Corpus version when this was indexed
359    pub corpus_version: String,
360}
361
362// ============================================================================
363// Recall Provenance Types
364// ============================================================================
365
366/// Trace link for recall operations (enables reproducibility).
367#[derive(Debug, Clone, Serialize, Deserialize)]
368pub struct RecallTraceLink {
369    /// Hash of the query embedding vector
370    pub embedding_hash: String,
371    /// Corpus version used for search
372    pub corpus_version: String,
373    /// Embedder ID used
374    pub embedder_id: String,
375    /// Number of candidates searched
376    pub candidates_searched: usize,
377    /// Number of candidates returned
378    pub candidates_returned: usize,
379    /// Latency in milliseconds
380    pub latency_ms: u64,
381}
382
383/// A candidate ID with its score, for ordered provenance tracking.
384#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
385pub struct CandidateScore {
386    /// Candidate ID
387    pub id: String,
388    /// Final normalized score
389    pub score: f64,
390}
391
392/// Complete provenance envelope for recall operations.
393///
394/// This captures ALL information needed to:
395/// - Replay the exact same recall query
396/// - Audit why specific candidates were returned
397/// - Verify determinism across runs
398///
399/// All fields are required (non-optional) to make it impossible to be vague.
400#[derive(Debug, Clone, Serialize, Deserialize)]
401pub struct RecallProvenanceEnvelope {
402    // --- Query Provenance ---
403    /// Hash of the original query (before embedding)
404    pub query_hash: String,
405
406    /// Hash of the canonicalized embedding input text
407    /// (after PII redaction, whitespace normalization, Unicode NFKC)
408    pub embedding_input_hash: String,
409
410    /// Hash of the resulting embedding vector
411    pub embedding_hash: String,
412
413    // --- Embedder Provenance ---
414    /// Embedder identifier
415    pub embedder_id: String,
416
417    /// Hash of embedder settings (model, normalization, etc.)
418    pub embedder_settings_hash: String,
419
420    // --- Corpus Provenance ---
421    /// Full corpus fingerprint string
422    pub corpus_fingerprint: String,
423
424    // --- Policy Provenance ---
425    /// Hash of the RecallPolicy that was applied
426    pub policy_snapshot_hash: String,
427
428    // --- Use/Consumer Provenance (Recall ≠ Training boundary) ---
429    /// Purpose of this recall operation
430    ///
431    /// Defaults to `RuntimeAugmentation`. Training use must be explicit.
432    #[serde(default)]
433    pub purpose: RecallUse,
434
435    /// Consumers that will receive these results
436    ///
437    /// Empty by default; runtime typically sets `[Kernel]`.
438    /// Training pipelines would set `[Trainer]` or `[Analytics, Trainer]`.
439    #[serde(default)]
440    pub consumers: Vec<RecallConsumer>,
441
442    // --- Results Provenance ---
443    /// Ordered list of (candidate_id, final_score) pairs
444    /// Order matters for determinism verification
445    pub candidate_scores: Vec<CandidateScore>,
446
447    /// Number of candidates in corpus that were searched
448    pub candidates_searched: usize,
449
450    /// Number of candidates returned (after filtering)
451    pub candidates_returned: usize,
452
453    /// Why recall stopped (if applicable)
454    pub stop_reason: Option<StopReason>,
455
456    // --- Timing ---
457    /// Latency in milliseconds
458    pub latency_ms: u64,
459
460    /// Timestamp when recall was performed (ISO 8601)
461    pub timestamp: String,
462
463    // --- Future-proofing for signing ---
464    /// Optional signature for multi-tenant verification
465    /// Format: "unsigned" | "sha256:`<hash>`" | "sig://`<key-id>`/`<signature>`"
466    #[serde(default = "default_signature")]
467    pub signature: String,
468}
469
470fn default_signature() -> String {
471    "unsigned".to_string()
472}
473
474impl RecallProvenanceEnvelope {
475    /// Compute a hash of the entire provenance envelope.
476    ///
477    /// This can be used for quick equality checks and audit trails.
478    #[must_use]
479    pub fn envelope_hash(&self) -> String {
480        use std::collections::hash_map::DefaultHasher;
481        use std::hash::{Hash, Hasher};
482
483        let mut hasher = DefaultHasher::new();
484        self.query_hash.hash(&mut hasher);
485        self.embedding_input_hash.hash(&mut hasher);
486        self.embedding_hash.hash(&mut hasher);
487        self.embedder_id.hash(&mut hasher);
488        self.embedder_settings_hash.hash(&mut hasher);
489        self.corpus_fingerprint.hash(&mut hasher);
490        self.policy_snapshot_hash.hash(&mut hasher);
491        (self.purpose as u8).hash(&mut hasher);
492        for consumer in &self.consumers {
493            (*consumer as u8).hash(&mut hasher);
494        }
495        for cs in &self.candidate_scores {
496            cs.id.hash(&mut hasher);
497            (cs.score as u64).hash(&mut hasher);
498        }
499        self.candidates_searched.hash(&mut hasher);
500        self.candidates_returned.hash(&mut hasher);
501        self.latency_ms.hash(&mut hasher);
502        self.timestamp.hash(&mut hasher);
503        format!("{:016x}", hasher.finish())
504    }
505
506    /// Check if this envelope matches another for replay verification.
507    ///
508    /// Two envelopes match if they have identical:
509    /// - query_hash
510    /// - embedding_input_hash
511    /// - embedder_id + embedder_settings_hash
512    /// - corpus_fingerprint
513    /// - policy_snapshot_hash
514    /// - purpose + consumers (Recall ≠ Training boundary)
515    /// - candidate_scores (order-sensitive)
516    #[must_use]
517    pub fn matches_for_replay(&self, other: &Self) -> bool {
518        self.query_hash == other.query_hash
519            && self.embedding_input_hash == other.embedding_input_hash
520            && self.embedder_id == other.embedder_id
521            && self.embedder_settings_hash == other.embedder_settings_hash
522            && self.corpus_fingerprint == other.corpus_fingerprint
523            && self.policy_snapshot_hash == other.policy_snapshot_hash
524            && self.purpose == other.purpose
525            && self.consumers == other.consumers
526            && self.candidate_scores == other.candidate_scores
527    }
528
529    /// Get a short summary for logging.
530    #[must_use]
531    pub fn summary(&self) -> String {
532        format!(
533            "Recall[query:{:.8}...][corpus:{:.8}...][{}/{} candidates][{}ms]",
534            self.query_hash,
535            self.corpus_fingerprint,
536            self.candidates_returned,
537            self.candidates_searched,
538            self.latency_ms
539        )
540    }
541}
542
543/// Reason why recall stopped returning results.
544#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
545pub enum StopReason {
546    /// Reached the requested top_k
547    ReachedTopK,
548    /// Reached max_k_total budget
549    BudgetExhausted,
550    /// All remaining candidates below threshold
551    BelowThreshold,
552    /// Reached max tokens for injection
553    TokenLimitReached,
554    /// Latency budget exceeded
555    LatencyExceeded,
556    /// Embedder is not deterministic and policy requires replayability
557    ///
558    /// When `RecallUse::TrainingCandidateSelection` or kernel requires
559    /// deterministic replay, but embedder is `Stochastic` or `Unknown`.
560    /// Results may still be returned but marked as audit-only.
561    EmbedderNotDeterministic,
562    /// Tenant scope required but not provided
563    ///
564    /// The corpus has `TenantPolicy::Required` but the query did not
565    /// include a tenant scope. No results returned.
566    TenantScopeMissing,
567}
568
569// ============================================================================
570// Recall Executor — turns ExperienceRecords into RecallCandidates
571// ============================================================================
572
573/// Pull recall candidates from an [`ExperienceStore`].
574///
575/// First implementation: scans the ledger for recall-relevant records (user
576/// overrides, user approvals, failed engine outcomes), maps each to a
577/// `RecallCandidate`, applies `policy.min_score_threshold` and `prior_weight`,
578/// then trims to the smaller of `query.top_k` and `policy.max_k_total`.
579///
580/// Semantic ranking by embedding similarity is intentionally deferred — the
581/// goal here is to wire planning to history end-to-end. Ranking by recency is
582/// a placeholder that will be replaced once a recall provider is in place.
583pub fn recall_from_store(
584    store: &dyn ExperienceStore,
585    query: &RecallQuery,
586    policy: &RecallPolicy,
587) -> ExperienceStoreResult<Vec<RecallCandidate>> {
588    if !policy.enabled {
589        return Ok(Vec::new());
590    }
591
592    let event_query = EventQuery {
593        tenant_id: query.tenant_scope.as_deref().map(TenantId::new),
594        ..Default::default()
595    };
596
597    let records = store.query_records(&event_query)?;
598    let limit = query.top_k.min(policy.max_k_total);
599
600    let candidates = records
601        .iter()
602        .rev()
603        .filter_map(record_to_candidate)
604        .filter(|c| c.confidence >= policy.min_score_threshold)
605        .take(limit)
606        .map(|mut c| {
607            c.confidence = (c.confidence * policy.prior_weight).clamp(0.0, 1.0);
608            c
609        })
610        .collect();
611
612    Ok(candidates)
613}
614
615fn record_to_candidate(record: &ExperienceRecord) -> Option<RecallCandidate> {
616    match record {
617        ExperienceRecord::User(env) => match &env.event {
618            UserExperienceEvent::UserOverrideIssued { reason, .. } => Some(make_candidate(
619                env.event_id.as_str(),
620                env.occurred_at.as_str(),
621                format!("user override: {reason}"),
622                0.9,
623                CandidateSourceType::AntiPattern,
624            )),
625            UserExperienceEvent::UserApprovalGranted { reason, .. } => Some(make_candidate(
626                env.event_id.as_str(),
627                env.occurred_at.as_str(),
628                format!("user approval: {}", reason.as_deref().unwrap_or("granted")),
629                0.7,
630                CandidateSourceType::SimilarSuccess,
631            )),
632        },
633        ExperienceRecord::Engine(env) => match &env.event {
634            ExperienceEvent::OutcomeRecorded {
635                passed: false,
636                stop_reason,
637                ..
638            } => Some(make_candidate(
639                env.event_id.as_str(),
640                env.occurred_at.as_str(),
641                format!(
642                    "outcome failed: {}",
643                    stop_reason
644                        .as_ref()
645                        .map_or_else(|| "unspecified".to_string(), ToString::to_string)
646                ),
647                0.6,
648                CandidateSourceType::SimilarFailure,
649            )),
650            _ => None,
651        },
652    }
653}
654
655fn make_candidate(
656    id: &str,
657    occurred_at: &str,
658    summary: String,
659    confidence: f64,
660    source_type: CandidateSourceType,
661) -> RecallCandidate {
662    RecallCandidate {
663        id: id.to_string(),
664        summary,
665        raw_score: confidence,
666        final_score: confidence,
667        relevance: RelevanceLevel::from_score(confidence),
668        source_type,
669        provenance: CandidateProvenance {
670            created_at: occurred_at.to_string(),
671            source_chain_id: None,
672            source_step: None,
673            corpus_version: "experience-store-v0".to_string(),
674        },
675        confidence,
676    }
677}
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682
683    #[test]
684    fn test_recall_policy_enabled() {
685        let policy = RecallPolicy::enabled();
686        assert!(policy.enabled);
687    }
688
689    #[test]
690    fn test_recall_policy_disabled() {
691        let policy = RecallPolicy::disabled();
692        assert!(!policy.enabled);
693    }
694
695    #[test]
696    fn test_relevance_from_score() {
697        assert_eq!(RelevanceLevel::from_score(0.9), RelevanceLevel::High);
698        assert_eq!(RelevanceLevel::from_score(0.6), RelevanceLevel::Medium);
699        assert_eq!(RelevanceLevel::from_score(0.3), RelevanceLevel::Low);
700    }
701
702    #[test]
703    fn test_recall_query_builder() {
704        let query = RecallQuery::new("test", 5)
705            .with_step_context(DecisionStep::Reasoning)
706            .with_tenant_scope("tenant-1");
707
708        assert_eq!(query.query_text, "test");
709        assert_eq!(query.top_k, 5);
710        assert_eq!(query.step_context, Some(DecisionStep::Reasoning));
711        assert_eq!(query.tenant_scope, Some("tenant-1".to_string()));
712    }
713
714    #[test]
715    fn test_recall_policy_defaults_to_runtime_only() {
716        let policy = RecallPolicy::default();
717        assert!(
718            policy
719                .allowed_uses
720                .contains(&RecallUse::RuntimeAugmentation),
721            "Default policy must allow RuntimeAugmentation"
722        );
723        assert!(
724            !policy
725                .allowed_uses
726                .contains(&RecallUse::TrainingCandidateSelection),
727            "Default policy must NOT allow TrainingCandidateSelection"
728        );
729    }
730
731    #[test]
732    fn test_recall_training_purpose_is_blocked_in_kernel() {
733        let policy = RecallPolicy {
734            allowed_uses: vec![RecallUse::RuntimeAugmentation],
735            ..Default::default()
736        };
737
738        assert!(
739            recall_use_allowed(&policy, RecallUse::RuntimeAugmentation),
740            "RuntimeAugmentation must be allowed"
741        );
742        assert!(
743            !recall_use_allowed(&policy, RecallUse::TrainingCandidateSelection),
744            "TrainingCandidateSelection must be blocked when not in allowed_uses"
745        );
746    }
747
748    #[test]
749    fn test_recall_training_can_be_explicitly_enabled() {
750        let policy = RecallPolicy {
751            allowed_uses: vec![
752                RecallUse::RuntimeAugmentation,
753                RecallUse::TrainingCandidateSelection,
754            ],
755            ..Default::default()
756        };
757
758        assert!(recall_use_allowed(&policy, RecallUse::RuntimeAugmentation));
759        assert!(recall_use_allowed(
760            &policy,
761            RecallUse::TrainingCandidateSelection
762        ));
763    }
764
765    #[test]
766    fn test_policy_hash_deterministic() {
767        let policy = RecallPolicy::default();
768        let hash1 = policy.snapshot_hash();
769        let hash2 = policy.snapshot_hash();
770        assert_eq!(hash1, hash2, "Same policy must produce same hash");
771    }
772
773    #[test]
774    fn test_policy_hash_changes_with_allowed_uses() {
775        let policy1 = RecallPolicy::default();
776        let policy2 = RecallPolicy {
777            allowed_uses: vec![
778                RecallUse::RuntimeAugmentation,
779                RecallUse::TrainingCandidateSelection,
780            ],
781            ..Default::default()
782        };
783
784        assert_ne!(
785            policy1.snapshot_hash(),
786            policy2.snapshot_hash(),
787            "Different allowed_uses must produce different hash"
788        );
789    }
790
791    #[test]
792    fn test_recall_query_hash_deterministic() {
793        let query = RecallQuery::new("test query", 5);
794        let hash1 = query.query_hash();
795        let hash2 = query.query_hash();
796        assert_eq!(hash1, hash2, "Same query must produce same hash");
797    }
798
799    #[test]
800    fn test_recall_provenance_matches_for_replay() {
801        let env = RecallProvenanceEnvelope {
802            query_hash: "q".to_string(),
803            embedding_input_hash: "e".to_string(),
804            embedding_hash: "h".to_string(),
805            embedder_id: "id".to_string(),
806            embedder_settings_hash: "s".to_string(),
807            corpus_fingerprint: "c".to_string(),
808            policy_snapshot_hash: "p".to_string(),
809            purpose: RecallUse::RuntimeAugmentation,
810            consumers: vec![RecallConsumer::Kernel],
811            candidate_scores: vec![],
812            candidates_searched: 10,
813            candidates_returned: 2,
814            stop_reason: None,
815            latency_ms: 10,
816            timestamp: "t".to_string(),
817            signature: "unsigned".to_string(),
818        };
819
820        // Same envelope matches
821        assert!(env.matches_for_replay(&env.clone()));
822
823        // Different purpose does not match
824        let mut env2 = env.clone();
825        env2.purpose = RecallUse::TrainingCandidateSelection;
826        assert!(
827            !env.matches_for_replay(&env2),
828            "Different purpose must not match"
829        );
830
831        // Different consumers does not match
832        let mut env3 = env.clone();
833        env3.consumers = vec![RecallConsumer::Trainer];
834        assert!(
835            !env.matches_for_replay(&env3),
836            "Different consumers must not match"
837        );
838    }
839}