1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
47pub enum RecallUse {
48 RuntimeAugmentation,
50 TrainingCandidateSelection,
52}
53
54impl Default for RecallUse {
55 fn default() -> Self {
56 Self::RuntimeAugmentation
57 }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
65pub enum RecallConsumer {
66 Kernel,
68 Analytics,
70 Trainer,
72}
73
74impl Default for RecallConsumer {
75 fn default() -> Self {
76 Self::Kernel
77 }
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct RecallPolicy {
90 pub enabled: bool,
92 pub max_k_total: usize,
94 pub max_tokens_injection: usize,
96 pub min_score_threshold: UnitInterval,
98 pub budgets: RecallBudgets,
100 #[serde(default = "default_allowed_uses")]
105 pub allowed_uses: Vec<RecallUse>,
106
107 #[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 #[must_use]
140 pub fn enabled() -> Self {
141 Self {
142 enabled: true,
143 ..Default::default()
144 }
145 }
146
147 #[must_use]
149 pub fn disabled() -> Self {
150 Self::default()
151 }
152
153 #[must_use]
158 pub fn is_use_allowed(&self, purpose: RecallUse) -> bool {
159 self.allowed_uses.contains(&purpose)
160 }
161
162 #[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#[must_use]
192pub fn recall_use_allowed(policy: &RecallPolicy, purpose: RecallUse) -> bool {
193 policy.is_use_allowed(purpose)
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct RecallBudgets {
199 pub max_latency_ms: u64,
201 pub max_embedding_calls: usize,
203 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#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct RecallQuery {
224 pub query_text: String,
226 pub top_k: usize,
228 pub step_context: Option<DecisionStep>,
230 pub tenant_scope: Option<String>,
232}
233
234impl RecallQuery {
235 #[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 #[must_use]
248 pub fn with_step_context(mut self, step: DecisionStep) -> Self {
249 self.step_context = Some(step);
250 self
251 }
252
253 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct RecallCandidate {
282 pub id: String,
284 pub summary: String,
286 pub raw_score: UnitInterval,
288 pub final_score: UnitInterval,
290 pub relevance: RelevanceLevel,
292 pub source_type: CandidateSourceType,
294 pub provenance: CandidateProvenance,
296 #[serde(default = "default_candidate_confidence")]
301 pub confidence: UnitInterval,
302}
303
304fn default_candidate_confidence() -> UnitInterval {
305 UnitInterval::clamped(0.5)
306}
307
308#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
310pub enum RelevanceLevel {
311 High,
312 Medium,
313 Low,
314}
315
316impl RelevanceLevel {
317 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
343pub enum CandidateSourceType {
344 SimilarFailure,
345 SimilarSuccess,
346 Runbook,
347 AdapterConfig,
348 AntiPattern,
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct CandidateProvenance {
354 pub created_at: String,
356 pub source_chain_id: Option<String>,
358 pub source_step: Option<DecisionStep>,
360 pub corpus_version: String,
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize)]
370pub struct RecallTraceLink {
371 pub embedding_hash: String,
373 pub corpus_version: String,
375 pub embedder_id: String,
377 pub candidates_searched: usize,
379 pub candidates_returned: usize,
381 pub latency_ms: u64,
383}
384
385#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
387pub struct CandidateScore {
388 pub id: String,
390 pub score: UnitInterval,
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize)]
403pub struct RecallProvenanceEnvelope {
404 pub query_hash: String,
407
408 pub embedding_input_hash: String,
411
412 pub embedding_hash: String,
414
415 pub embedder_id: String,
418
419 pub embedder_settings_hash: String,
421
422 pub corpus_fingerprint: String,
425
426 pub policy_snapshot_hash: String,
429
430 #[serde(default)]
435 pub purpose: RecallUse,
436
437 #[serde(default)]
442 pub consumers: Vec<RecallConsumer>,
443
444 pub candidate_scores: Vec<CandidateScore>,
448
449 pub candidates_searched: usize,
451
452 pub candidates_returned: usize,
454
455 pub stop_reason: Option<StopReason>,
457
458 pub latency_ms: u64,
461
462 pub timestamp: String,
464
465 #[serde(default = "default_signature")]
469 pub signature: String,
470}
471
472fn default_signature() -> String {
473 "unsigned".to_string()
474}
475
476impl RecallProvenanceEnvelope {
477 #[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 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
547pub enum StopReason {
548 ReachedTopK,
550 BudgetExhausted,
552 BelowThreshold,
554 TokenLimitReached,
556 LatencyExceeded,
558 EmbedderNotDeterministic,
564 TenantScopeMissing,
569}
570
571pub 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 assert!(env.matches_for_replay(&env.clone()));
965
966 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 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}