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};
36use sha2::{Digest, Sha256};
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
48pub enum RecallUse {
49 RuntimeAugmentation,
51 TrainingCandidateSelection,
53}
54
55impl Default for RecallUse {
56 fn default() -> Self {
57 Self::RuntimeAugmentation
58 }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
66pub enum RecallConsumer {
67 Kernel,
69 Analytics,
71 Trainer,
73}
74
75impl Default for RecallConsumer {
76 fn default() -> Self {
77 Self::Kernel
78 }
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct RecallPolicy {
91 pub enabled: bool,
93 pub max_k_total: usize,
95 pub max_tokens_injection: usize,
97 pub min_score_threshold: UnitInterval,
99 pub budgets: RecallBudgets,
101 #[serde(default = "default_allowed_uses")]
106 pub allowed_uses: Vec<RecallUse>,
107
108 #[serde(default = "default_prior_weight")]
113 pub prior_weight: UnitInterval,
114}
115
116fn default_prior_weight() -> UnitInterval {
117 UnitInterval::ONE
118}
119
120fn default_allowed_uses() -> Vec<RecallUse> {
121 vec![RecallUse::RuntimeAugmentation]
122}
123
124impl Default for RecallPolicy {
125 fn default() -> Self {
126 Self {
127 enabled: false,
128 max_k_total: 5,
129 max_tokens_injection: 500,
130 min_score_threshold: UnitInterval::clamped(0.5),
131 budgets: RecallBudgets::default(),
132 allowed_uses: default_allowed_uses(),
133 prior_weight: default_prior_weight(),
134 }
135 }
136}
137
138impl RecallPolicy {
139 #[must_use]
141 pub fn enabled() -> Self {
142 Self {
143 enabled: true,
144 ..Default::default()
145 }
146 }
147
148 #[must_use]
150 pub fn disabled() -> Self {
151 Self::default()
152 }
153
154 #[must_use]
159 pub fn is_use_allowed(&self, purpose: RecallUse) -> bool {
160 self.allowed_uses.contains(&purpose)
161 }
162
163 #[must_use]
168 pub fn snapshot_hash(&self) -> String {
169 let mut hasher = StableHash::new("recall-policy-v1");
170 hasher.bool(self.enabled);
171 hasher.usize(self.max_k_total);
172 hasher.usize(self.max_tokens_injection);
173 hasher.u16(self.min_score_threshold.to_basis_points());
174 hasher.u64(self.budgets.max_latency_ms);
175 hasher.usize(self.budgets.max_embedding_calls);
176 hasher.usize(self.budgets.max_tokens_per_candidate);
177 for use_type in &self.allowed_uses {
178 hasher.u8(*use_type as u8);
179 }
180 hasher.u16(self.prior_weight.to_basis_points());
181 hasher.finish_hex()
182 }
183}
184
185#[must_use]
190pub fn recall_use_allowed(policy: &RecallPolicy, purpose: RecallUse) -> bool {
191 policy.is_use_allowed(purpose)
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct RecallBudgets {
197 pub max_latency_ms: u64,
199 pub max_embedding_calls: usize,
201 pub max_tokens_per_candidate: usize,
203}
204
205impl Default for RecallBudgets {
206 fn default() -> Self {
207 Self {
208 max_latency_ms: 100,
209 max_embedding_calls: 3,
210 max_tokens_per_candidate: 100,
211 }
212 }
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
221pub struct RecallQuery {
222 pub query_text: String,
224 pub top_k: usize,
226 pub step_context: Option<DecisionStep>,
228 pub tenant_scope: Option<String>,
230}
231
232impl RecallQuery {
233 #[must_use]
235 pub fn new(query_text: impl Into<String>, top_k: usize) -> Self {
236 Self {
237 query_text: query_text.into(),
238 top_k,
239 step_context: None,
240 tenant_scope: None,
241 }
242 }
243
244 #[must_use]
246 pub fn with_step_context(mut self, step: DecisionStep) -> Self {
247 self.step_context = Some(step);
248 self
249 }
250
251 #[must_use]
253 pub fn with_tenant_scope(mut self, tenant: impl Into<String>) -> Self {
254 self.tenant_scope = Some(tenant.into());
255 self
256 }
257
258 #[must_use]
260 pub fn query_hash(&self) -> String {
261 let mut hasher = StableHash::new("recall-query-v1");
262 hasher.str(&self.query_text);
263 hasher.usize(self.top_k);
264 if let Some(ref step) = self.step_context {
265 hasher.str(step.as_str());
266 } else {
267 hasher.none();
268 }
269 if let Some(ref tenant) = self.tenant_scope {
270 hasher.str(tenant);
271 } else {
272 hasher.none();
273 }
274 hasher.finish_hex()
275 }
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct RecallCandidate {
281 pub id: String,
283 pub summary: String,
285 pub raw_score: UnitInterval,
287 pub final_score: UnitInterval,
289 pub relevance: RelevanceLevel,
291 pub source_type: CandidateSourceType,
293 pub provenance: CandidateProvenance,
295 #[serde(default = "default_candidate_confidence")]
300 pub confidence: UnitInterval,
301}
302
303fn default_candidate_confidence() -> UnitInterval {
304 UnitInterval::clamped(0.5)
305}
306
307#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
309pub enum RelevanceLevel {
310 High,
311 Medium,
312 Low,
313}
314
315impl RelevanceLevel {
316 #[must_use]
318 pub fn from_score(score: UnitInterval) -> Self {
319 let score = score.as_f64();
320 if score >= 0.8 {
321 Self::High
322 } else if score >= 0.5 {
323 Self::Medium
324 } else {
325 Self::Low
326 }
327 }
328
329 #[must_use]
331 pub fn as_str(&self) -> &'static str {
332 match self {
333 Self::High => "high",
334 Self::Medium => "medium",
335 Self::Low => "low",
336 }
337 }
338}
339
340#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
342pub enum CandidateSourceType {
343 SimilarFailure,
344 SimilarSuccess,
345 Runbook,
346 AdapterConfig,
347 AntiPattern,
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct CandidateProvenance {
353 pub created_at: String,
355 pub source_chain_id: Option<String>,
357 pub source_step: Option<DecisionStep>,
359 pub corpus_version: String,
361}
362
363#[derive(Debug, Clone, Serialize, Deserialize)]
369pub struct RecallTraceLink {
370 pub embedding_hash: String,
372 pub corpus_version: String,
374 pub embedder_id: String,
376 pub candidates_searched: usize,
378 pub candidates_returned: usize,
380 pub latency_ms: u64,
382}
383
384#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
386pub struct CandidateScore {
387 pub id: String,
389 pub score: UnitInterval,
391}
392
393#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct RecallProvenanceEnvelope {
403 pub query_hash: String,
406
407 pub embedding_input_hash: String,
410
411 pub embedding_hash: String,
413
414 pub embedder_id: String,
417
418 pub embedder_settings_hash: String,
420
421 pub corpus_fingerprint: String,
424
425 pub policy_snapshot_hash: String,
428
429 #[serde(default)]
434 pub purpose: RecallUse,
435
436 #[serde(default)]
441 pub consumers: Vec<RecallConsumer>,
442
443 pub candidate_scores: Vec<CandidateScore>,
447
448 pub candidates_searched: usize,
450
451 pub candidates_returned: usize,
453
454 pub stop_reason: Option<StopReason>,
456
457 pub latency_ms: u64,
460
461 pub timestamp: String,
463
464 #[serde(default = "default_signature")]
468 pub signature: String,
469}
470
471fn default_signature() -> String {
472 "unsigned".to_string()
473}
474
475impl RecallProvenanceEnvelope {
476 #[must_use]
480 pub fn envelope_hash(&self) -> String {
481 let mut hasher = StableHash::new("recall-envelope-v1");
482 hasher.str(&self.query_hash);
483 hasher.str(&self.embedding_input_hash);
484 hasher.str(&self.embedding_hash);
485 hasher.str(&self.embedder_id);
486 hasher.str(&self.embedder_settings_hash);
487 hasher.str(&self.corpus_fingerprint);
488 hasher.str(&self.policy_snapshot_hash);
489 hasher.u8(self.purpose as u8);
490 for consumer in &self.consumers {
491 hasher.u8(*consumer as u8);
492 }
493 for cs in &self.candidate_scores {
494 hasher.str(&cs.id);
495 hasher.u16(cs.score.to_basis_points());
496 }
497 hasher.usize(self.candidates_searched);
498 hasher.usize(self.candidates_returned);
499 hasher.u64(self.latency_ms);
500 hasher.str(&self.timestamp);
501 hasher.finish_hex()
502 }
503
504 #[must_use]
515 pub fn matches_for_replay(&self, other: &Self) -> bool {
516 self.query_hash == other.query_hash
517 && self.embedding_input_hash == other.embedding_input_hash
518 && self.embedder_id == other.embedder_id
519 && self.embedder_settings_hash == other.embedder_settings_hash
520 && self.corpus_fingerprint == other.corpus_fingerprint
521 && self.policy_snapshot_hash == other.policy_snapshot_hash
522 && self.purpose == other.purpose
523 && self.consumers == other.consumers
524 && self.candidate_scores == other.candidate_scores
525 }
526
527 #[must_use]
529 pub fn summary(&self) -> String {
530 format!(
531 "Recall[query:{:.8}...][corpus:{:.8}...][{}/{} candidates][{}ms]",
532 self.query_hash,
533 self.corpus_fingerprint,
534 self.candidates_returned,
535 self.candidates_searched,
536 self.latency_ms
537 )
538 }
539}
540
541struct StableHash {
542 hasher: Sha256,
543}
544
545impl StableHash {
546 fn new(domain: &'static str) -> Self {
547 let mut stable = Self {
548 hasher: Sha256::new(),
549 };
550 stable.str(domain);
551 stable
552 }
553
554 fn bytes(&mut self, bytes: &[u8]) {
555 self.hasher.update((bytes.len() as u64).to_be_bytes());
556 self.hasher.update(bytes);
557 }
558
559 fn str(&mut self, value: &str) {
560 self.bytes(value.as_bytes());
561 }
562
563 fn bool(&mut self, value: bool) {
564 self.u8(u8::from(value));
565 }
566
567 fn none(&mut self) {
568 self.bytes(&[]);
569 }
570
571 fn u8(&mut self, value: u8) {
572 self.bytes(&[value]);
573 }
574
575 fn u16(&mut self, value: u16) {
576 self.bytes(&value.to_be_bytes());
577 }
578
579 fn u64(&mut self, value: u64) {
580 self.bytes(&value.to_be_bytes());
581 }
582
583 fn usize(&mut self, value: usize) {
584 self.u64(value as u64);
585 }
586
587 fn finish_hex(self) -> String {
588 hex::encode(self.hasher.finalize())
589 }
590}
591
592#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
594pub enum StopReason {
595 ReachedTopK,
597 BudgetExhausted,
599 BelowThreshold,
601 TokenLimitReached,
603 LatencyExceeded,
605 EmbedderNotDeterministic,
611 TenantScopeMissing,
616}
617
618pub fn recall_from_store(
633 store: &dyn ExperienceStore,
634 query: &RecallQuery,
635 policy: &RecallPolicy,
636) -> ExperienceStoreResult<Vec<RecallCandidate>> {
637 if !policy.enabled {
638 return Ok(Vec::new());
639 }
640
641 let event_query = EventQuery {
642 tenant_id: query.tenant_scope.as_deref().map(TenantId::new),
643 ..Default::default()
644 };
645
646 let records = store.query_records(&event_query)?;
647 let limit = query.top_k.min(policy.max_k_total);
648
649 let candidates = records
650 .iter()
651 .rev()
652 .filter_map(record_to_candidate)
653 .filter(|c| c.confidence >= policy.min_score_threshold)
654 .take(limit)
655 .map(|mut c| {
656 c.confidence = c.confidence.scale_by(policy.prior_weight);
657 c
658 })
659 .collect();
660
661 Ok(candidates)
662}
663
664fn record_to_candidate(record: &ExperienceRecord) -> Option<RecallCandidate> {
665 match record {
666 ExperienceRecord::User(env) => match &env.event {
667 UserExperienceEvent::UserOverrideIssued { reason, .. } => Some(make_candidate(
668 env.event_id.as_str(),
669 env.occurred_at.as_str(),
670 format!("user override: {reason}"),
671 UnitInterval::clamped(0.9),
672 CandidateSourceType::AntiPattern,
673 )),
674 UserExperienceEvent::UserApprovalGranted { reason, .. } => Some(make_candidate(
675 env.event_id.as_str(),
676 env.occurred_at.as_str(),
677 format!("user approval: {}", reason.as_deref().unwrap_or("granted")),
678 UnitInterval::clamped(0.7),
679 CandidateSourceType::SimilarSuccess,
680 )),
681 UserExperienceEvent::UserApprovalRejected { reason, .. } => Some(make_candidate(
682 env.event_id.as_str(),
683 env.occurred_at.as_str(),
684 format!(
685 "user rejection: {}",
686 reason.as_deref().unwrap_or("declined")
687 ),
688 UnitInterval::clamped(0.7),
689 CandidateSourceType::AntiPattern,
690 )),
691 UserExperienceEvent::UserCorrection { target, reason, .. } => Some(make_candidate(
692 env.event_id.as_str(),
693 env.occurred_at.as_str(),
694 format!("correction ({}): {reason}", target.kind_label()),
695 UnitInterval::clamped(0.85),
696 CandidateSourceType::Runbook,
697 )),
698 UserExperienceEvent::UserBoundaryAdjusted {
699 boundary,
700 target,
701 reason,
702 ..
703 } => Some(make_candidate(
704 env.event_id.as_str(),
705 env.occurred_at.as_str(),
706 format!(
707 "{} boundary adjusted on {}: {reason}",
708 boundary_kind_label(*boundary),
709 boundary_target_label(target)
710 ),
711 UnitInterval::clamped(0.8),
712 CandidateSourceType::Runbook,
713 )),
714 },
715 ExperienceRecord::Engine(env) => match &env.event {
716 ExperienceEvent::OutcomeRecorded {
717 passed: false,
718 stop_reason,
719 ..
720 } => Some(make_candidate(
721 env.event_id.as_str(),
722 env.occurred_at.as_str(),
723 format!(
724 "outcome failed: {}",
725 stop_reason
726 .as_ref()
727 .map_or_else(|| "unspecified".to_string(), ToString::to_string)
728 ),
729 UnitInterval::clamped(0.6),
730 CandidateSourceType::SimilarFailure,
731 )),
732 _ => None,
733 },
734 }
735}
736
737fn boundary_kind_label(kind: crate::BoundaryKind) -> &'static str {
738 match kind {
739 crate::BoundaryKind::Authority => "authority",
740 crate::BoundaryKind::Forbidden => "forbidden",
741 crate::BoundaryKind::Expiry => "expiry",
742 crate::BoundaryKind::Reversibility => "reversibility",
743 }
744}
745
746fn boundary_target_label(target: &crate::BoundaryTarget) -> String {
747 match target {
748 crate::BoundaryTarget::Pack { pack_id } => format!("pack:{}", pack_id.as_str()),
749 crate::BoundaryTarget::Intent { intent_id } => format!("intent:{}", intent_id.as_str()),
750 crate::BoundaryTarget::Global => "global".to_string(),
751 }
752}
753
754fn make_candidate(
755 id: &str,
756 occurred_at: &str,
757 summary: String,
758 confidence: UnitInterval,
759 source_type: CandidateSourceType,
760) -> RecallCandidate {
761 RecallCandidate {
762 id: id.to_string(),
763 summary,
764 raw_score: confidence,
765 final_score: confidence,
766 relevance: RelevanceLevel::from_score(confidence),
767 source_type,
768 provenance: CandidateProvenance {
769 created_at: occurred_at.to_string(),
770 source_chain_id: None,
771 source_step: None,
772 corpus_version: "experience-store-v0".to_string(),
773 },
774 confidence,
775 }
776}
777
778#[cfg(test)]
779mod tests {
780 use super::*;
781 use crate::{
782 BoundaryKind, BoundaryTarget, ContentHash, CorrectionTarget, ExperienceRecord, FactContent,
783 FactContentKind, UserExperienceEventEnvelope,
784 };
785
786 fn candidate_for_user_event(event: UserExperienceEvent) -> RecallCandidate {
787 let envelope = UserExperienceEventEnvelope::new("evt-user", event);
788 record_to_candidate(&ExperienceRecord::User(envelope)).expect("candidate")
789 }
790
791 #[test]
792 fn test_recall_policy_enabled() {
793 let policy = RecallPolicy::enabled();
794 assert!(policy.enabled);
795 }
796
797 #[test]
798 fn test_recall_policy_disabled() {
799 let policy = RecallPolicy::disabled();
800 assert!(!policy.enabled);
801 }
802
803 #[test]
804 fn test_relevance_from_score() {
805 assert_eq!(
806 RelevanceLevel::from_score(UnitInterval::clamped(0.9)),
807 RelevanceLevel::High
808 );
809 assert_eq!(
810 RelevanceLevel::from_score(UnitInterval::clamped(0.6)),
811 RelevanceLevel::Medium
812 );
813 assert_eq!(
814 RelevanceLevel::from_score(UnitInterval::clamped(0.3)),
815 RelevanceLevel::Low
816 );
817 }
818
819 #[test]
820 fn test_recall_query_builder() {
821 let query = RecallQuery::new("test", 5)
822 .with_step_context(DecisionStep::Reasoning)
823 .with_tenant_scope("tenant-1");
824
825 assert_eq!(query.query_text, "test");
826 assert_eq!(query.top_k, 5);
827 assert_eq!(query.step_context, Some(DecisionStep::Reasoning));
828 assert_eq!(query.tenant_scope, Some("tenant-1".to_string()));
829 }
830
831 #[test]
832 fn recall_maps_rejected_user_approval_to_antipattern() {
833 let candidate = candidate_for_user_event(UserExperienceEvent::UserApprovalRejected {
834 gate_request_id: "gate-1".into(),
835 actor: "operator-1".into(),
836 policy_snapshot_hash: None,
837 reason: Some("risk too high".into()),
838 });
839
840 assert_eq!(candidate.summary, "user rejection: risk too high");
841 assert_eq!(candidate.confidence, UnitInterval::clamped(0.7));
842 assert_eq!(candidate.source_type, CandidateSourceType::AntiPattern);
843 }
844
845 #[test]
846 fn recall_maps_user_correction_to_runbook() {
847 let candidate = candidate_for_user_event(UserExperienceEvent::UserCorrection {
848 target: CorrectionTarget::Fact {
849 fact_id: "fact-1".into(),
850 },
851 actor: "operator-1".into(),
852 policy_snapshot_hash: None,
853 original_content: ContentHash::zero(),
854 corrected_content: FactContent::new(FactContentKind::Claim, "corrected"),
855 reason: "source was stale".into(),
856 });
857
858 assert_eq!(candidate.summary, "correction (fact): source was stale");
859 assert_eq!(candidate.confidence, UnitInterval::clamped(0.85));
860 assert_eq!(candidate.source_type, CandidateSourceType::Runbook);
861 }
862
863 #[test]
864 fn recall_maps_boundary_adjustment_to_scoped_runbook() {
865 let candidate = candidate_for_user_event(UserExperienceEvent::UserBoundaryAdjusted {
866 boundary: BoundaryKind::Authority,
867 target: BoundaryTarget::Pack {
868 pack_id: "loan-pack".into(),
869 },
870 actor: "operator-1".into(),
871 policy_snapshot_hash: None,
872 previous_value: serde_json::json!({"limit": 100}),
873 new_value: serde_json::json!({"limit": 50}),
874 reason: "manual review needed".into(),
875 });
876
877 assert_eq!(
878 candidate.summary,
879 "authority boundary adjusted on pack:loan-pack: manual review needed"
880 );
881 assert_eq!(candidate.confidence, UnitInterval::clamped(0.8));
882 assert_eq!(candidate.source_type, CandidateSourceType::Runbook);
883 }
884
885 #[test]
886 fn test_recall_policy_defaults_to_runtime_only() {
887 let policy = RecallPolicy::default();
888 assert!(
889 policy
890 .allowed_uses
891 .contains(&RecallUse::RuntimeAugmentation),
892 "Default policy must allow RuntimeAugmentation"
893 );
894 assert!(
895 !policy
896 .allowed_uses
897 .contains(&RecallUse::TrainingCandidateSelection),
898 "Default policy must NOT allow TrainingCandidateSelection"
899 );
900 }
901
902 #[test]
903 fn test_recall_training_purpose_is_blocked_in_kernel() {
904 let policy = RecallPolicy {
905 allowed_uses: vec![RecallUse::RuntimeAugmentation],
906 ..Default::default()
907 };
908
909 assert!(
910 recall_use_allowed(&policy, RecallUse::RuntimeAugmentation),
911 "RuntimeAugmentation must be allowed"
912 );
913 assert!(
914 !recall_use_allowed(&policy, RecallUse::TrainingCandidateSelection),
915 "TrainingCandidateSelection must be blocked when not in allowed_uses"
916 );
917 }
918
919 #[test]
920 fn test_recall_training_can_be_explicitly_enabled() {
921 let policy = RecallPolicy {
922 allowed_uses: vec![
923 RecallUse::RuntimeAugmentation,
924 RecallUse::TrainingCandidateSelection,
925 ],
926 ..Default::default()
927 };
928
929 assert!(recall_use_allowed(&policy, RecallUse::RuntimeAugmentation));
930 assert!(recall_use_allowed(
931 &policy,
932 RecallUse::TrainingCandidateSelection
933 ));
934 }
935
936 #[test]
937 fn recall_policy_deserialization_rejects_out_of_range_threshold() {
938 let json = r#"{
939 "enabled": true,
940 "max_k_total": 5,
941 "max_tokens_injection": 500,
942 "min_score_threshold": 1.2,
943 "budgets": {
944 "max_latency_ms": 100,
945 "max_embedding_calls": 3,
946 "max_tokens_per_candidate": 100
947 },
948 "allowed_uses": ["RuntimeAugmentation"],
949 "prior_weight": 1.0
950 }"#;
951 let result = serde_json::from_str::<RecallPolicy>(json);
952 assert!(result.is_err());
953 }
954
955 #[test]
956 fn test_policy_hash_deterministic() {
957 let policy = RecallPolicy::default();
958 let hash1 = policy.snapshot_hash();
959 let hash2 = policy.snapshot_hash();
960 assert_eq!(hash1, hash2, "Same policy must produce same hash");
961 }
962
963 #[test]
964 fn test_policy_hash_changes_with_allowed_uses() {
965 let policy1 = RecallPolicy::default();
966 let policy2 = RecallPolicy {
967 allowed_uses: vec![
968 RecallUse::RuntimeAugmentation,
969 RecallUse::TrainingCandidateSelection,
970 ],
971 ..Default::default()
972 };
973
974 assert_ne!(
975 policy1.snapshot_hash(),
976 policy2.snapshot_hash(),
977 "Different allowed_uses must produce different hash"
978 );
979 }
980
981 #[test]
982 fn test_recall_query_hash_deterministic() {
983 let query = RecallQuery::new("test query", 5);
984 let hash1 = query.query_hash();
985 let hash2 = query.query_hash();
986 assert_eq!(hash1, hash2, "Same query must produce same hash");
987 }
988
989 #[test]
990 fn test_recall_provenance_matches_for_replay() {
991 let env = RecallProvenanceEnvelope {
992 query_hash: "q".to_string(),
993 embedding_input_hash: "e".to_string(),
994 embedding_hash: "h".to_string(),
995 embedder_id: "id".to_string(),
996 embedder_settings_hash: "s".to_string(),
997 corpus_fingerprint: "c".to_string(),
998 policy_snapshot_hash: "p".to_string(),
999 purpose: RecallUse::RuntimeAugmentation,
1000 consumers: vec![RecallConsumer::Kernel],
1001 candidate_scores: vec![],
1002 candidates_searched: 10,
1003 candidates_returned: 2,
1004 stop_reason: None,
1005 latency_ms: 10,
1006 timestamp: "t".to_string(),
1007 signature: "unsigned".to_string(),
1008 };
1009
1010 assert!(env.matches_for_replay(&env.clone()));
1012
1013 let mut env2 = env.clone();
1015 env2.purpose = RecallUse::TrainingCandidateSelection;
1016 assert!(
1017 !env.matches_for_replay(&env2),
1018 "Different purpose must not match"
1019 );
1020
1021 let mut env3 = env.clone();
1023 env3.consumers = vec![RecallConsumer::Trainer];
1024 assert!(
1025 !env.matches_for_replay(&env3),
1026 "Different consumers must not match"
1027 );
1028 }
1029}