Skip to main content

claw10_memory/
admission.rs

1//! Admission pipeline untuk memory candidate.
2//!
3//! Mengimplementasikan PRD section 27.3:
4//!
5//! ```text
6//! Candidate
7//! → Classification
8//! → Injection Scan
9//! → Deduplication
10//! → Source Check
11//! → Confidence Score
12//! → Scope Assignment
13//! → Verification
14//! → Activation
15//! ```
16//!
17//! Setiap stage mengembalikan `AdmissionDecision`.
18
19use claw10_domain::{Memory, MemoryId, MemoryStatus};
20
21/// Hasil dari satu tahap admission pipeline.
22#[derive(Debug, Clone, PartialEq)]
23pub enum AdmissionDecision {
24    /// Lanjut ke tahap berikutnya.
25    Continue,
26    /// Pipeline selesai, memory diaktifkan.
27    Activate,
28    /// Pipeline ditolak, memory di-reject.
29    Reject { reason: String },
30}
31
32/// Konfigurasi admission pipeline.
33#[derive(Debug, Clone)]
34pub struct AdmissionConfig {
35    /// Confidence minimum untuk lolos pipeline.
36    pub min_confidence: f64,
37    /// Kata kunci yang mengindikasikan injection attack.
38    pub injection_keywords: Vec<String>,
39    /// Apakah memory duplicate diperbolehkan (same scope + content).
40    pub allow_duplicates: bool,
41}
42
43impl Default for AdmissionConfig {
44    fn default() -> Self {
45        Self {
46            min_confidence: 0.6,
47            injection_keywords: vec![
48                "ignore previous".into(),
49                "disregard".into(),
50                "forget all".into(),
51                "system prompt".into(),
52                "jailbreak".into(),
53                "</system>".into(),
54                "<|im_start|>".into(),
55            ],
56            allow_duplicates: false,
57        }
58    }
59}
60
61/// Hasil akhir admission pipeline.
62#[derive(Debug)]
63pub enum AdmissionResult {
64    /// Memory diaktifkan.
65    Activated,
66    /// Memory ditolak, dengan alasan.
67    Rejected { reason: String },
68}
69
70/// Admission pipeline untuk memory candidate.
71pub struct AdmissionPipeline {
72    config: AdmissionConfig,
73}
74
75impl AdmissionPipeline {
76    #[must_use]
77    pub fn new(config: AdmissionConfig) -> Self {
78        Self { config }
79    }
80
81    #[must_use]
82    pub fn with_defaults() -> Self {
83        Self::new(AdmissionConfig::default())
84    }
85
86    /// Jalankan seluruh pipeline terhadap satu memory candidate.
87    /// Mengembalikan `AdmissionResult` yang menentukan apakah memory diaktifkan.
88    pub fn evaluate(&self, candidate: &Memory, existing: &[Memory]) -> AdmissionResult {
89        // Pastikan candidate dalam status Candidate
90        if candidate.status != MemoryStatus::Candidate
91            && candidate.status != MemoryStatus::Scanning
92        {
93            return AdmissionResult::Rejected {
94                reason: format!(
95                    "status tidak valid untuk admission: {:?}",
96                    candidate.status
97                ),
98            };
99        }
100
101        // Stage 1: Injection scan
102        if let AdmissionDecision::Reject { reason } = self.scan_injection(&candidate.content) {
103            return AdmissionResult::Rejected { reason };
104        }
105
106        // Stage 2: Confidence check
107        if let AdmissionDecision::Reject { reason } =
108            self.check_confidence(candidate.confidence)
109        {
110            return AdmissionResult::Rejected { reason };
111        }
112
113        // Stage 3: Deduplication
114        if let AdmissionDecision::Reject { reason } =
115            self.check_duplicate(candidate, existing)
116        {
117            return AdmissionResult::Rejected { reason };
118        }
119
120        // Stage 4: Source check (harus punya source agent yang valid)
121        if let AdmissionDecision::Reject { reason } = self.check_source(candidate) {
122            return AdmissionResult::Rejected { reason };
123        }
124
125        // Stage 5: Classification check (tidak boleh empty)
126        if let AdmissionDecision::Reject { reason } = self.check_classification(candidate) {
127            return AdmissionResult::Rejected { reason };
128        }
129
130        AdmissionResult::Activated
131    }
132
133    // ── Stages ────────────────────────────────────────────────────
134
135    fn scan_injection(&self, content: &str) -> AdmissionDecision {
136        let lower = content.to_lowercase();
137        for keyword in &self.config.injection_keywords {
138            if lower.contains(keyword.as_str()) {
139                return AdmissionDecision::Reject {
140                    reason: format!("injection pattern terdeteksi: '{keyword}'"),
141                };
142            }
143        }
144        AdmissionDecision::Continue
145    }
146
147    fn check_confidence(&self, confidence: f64) -> AdmissionDecision {
148        if confidence < self.config.min_confidence {
149            AdmissionDecision::Reject {
150                reason: format!(
151                    "confidence {:.2} di bawah minimum {:.2}",
152                    confidence, self.config.min_confidence
153                ),
154            }
155        } else {
156            AdmissionDecision::Continue
157        }
158    }
159
160    fn check_duplicate(&self, candidate: &Memory, existing: &[Memory]) -> AdmissionDecision {
161        if self.config.allow_duplicates {
162            return AdmissionDecision::Continue;
163        }
164
165        for mem in existing {
166            if mem.id == candidate.id {
167                continue; // sama diri sendiri
168            }
169            if mem.scope == candidate.scope
170                && mem.content.trim().to_lowercase()
171                    == candidate.content.trim().to_lowercase()
172                && mem.status == MemoryStatus::Active
173            {
174                return AdmissionDecision::Reject {
175                    reason: format!(
176                        "duplikat dengan memory aktif {} dalam scope '{}'",
177                        mem.id.0, candidate.scope
178                    ),
179                };
180            }
181        }
182
183        AdmissionDecision::Continue
184    }
185
186    fn check_source(&self, candidate: &Memory) -> AdmissionDecision {
187        // Agent ID tidak boleh nil UUID
188        if candidate.source.agent_id.0.is_nil() {
189            return AdmissionDecision::Reject {
190                reason: "source agent_id tidak valid (nil UUID)".into(),
191            };
192        }
193        AdmissionDecision::Continue
194    }
195
196    fn check_classification(&self, candidate: &Memory) -> AdmissionDecision {
197        if candidate.classification.trim().is_empty() {
198            return AdmissionDecision::Reject {
199                reason: "classification tidak boleh kosong".into(),
200            };
201        }
202        AdmissionDecision::Continue
203    }
204}
205
206/// Helper untuk mendapatkan MemoryId dari rejection target.
207pub fn rejection_reason_for(id: &MemoryId, reason: &str) -> String {
208    format!("memory {} ditolak: {}", id.0, reason)
209}
210
211#[cfg(test)]
212#[path = "admission_test.rs"]
213mod tests;
214