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