1use std::fmt;
9
10use cortex_core::ProofState as CoreProofState;
11use cortex_memory::{
12 AdmissionDecision, AdmissionProofState, AdmissionRejectionReason, AxiomImportClass,
13 AxiomMemoryAdmissionRequest, CandidateState, ContradictionScan, DurableAdmissionRefusal,
14 EvidenceClass, PhaseContext, RedactionStatus, SourceAnchor, SourceAnchorKind, ToolProvenance,
15 AXIOM_ADMISSION_PROOF_CLOSURE_INVARIANT,
16};
17
18use crate::schema::{MemoryCandidate, SessionReflection};
19
20pub const REFLECTION_ADMISSION_PROOF_CLOSURE_INVARIANT: &str =
30 "cortex_reflect.admission.proof_closure";
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum ReflectionAdmissionDisposition {
35 Reject,
37 Quarantine,
39}
40
41impl fmt::Display for ReflectionAdmissionDisposition {
42 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43 match self {
44 Self::Reject => f.write_str("reject"),
45 Self::Quarantine => f.write_str("quarantine"),
46 }
47 }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct ReflectionAdmissionError {
53 pub memory_index: usize,
55 pub disposition: ReflectionAdmissionDisposition,
57 pub reasons: Vec<AdmissionRejectionReason>,
59}
60
61impl fmt::Display for ReflectionAdmissionError {
62 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63 let memory_index = self.memory_index;
64 let disposition = self.disposition;
65 let reasons = &self.reasons;
66 write!(
67 f,
68 "memory_candidates[{memory_index}] admission {disposition}: {reasons:?}"
69 )
70 }
71}
72
73impl std::error::Error for ReflectionAdmissionError {}
74
75pub fn validate_reflection_admission(
78 reflection: &SessionReflection,
79 adapter_id: &str,
80 raw_hash: &str,
81) -> Result<(), ReflectionAdmissionError> {
82 for (memory_index, memory) in reflection.memory_candidates.iter().enumerate() {
83 let request = admission_request_for_memory(reflection, memory, adapter_id, raw_hash);
84 match request.admission_decision() {
85 AdmissionDecision::AdmitCandidate => {}
86 AdmissionDecision::Reject { reasons } => {
87 return Err(ReflectionAdmissionError {
88 memory_index,
89 disposition: ReflectionAdmissionDisposition::Reject,
90 reasons,
91 });
92 }
93 AdmissionDecision::Quarantine { reasons } => {
94 return Err(ReflectionAdmissionError {
95 memory_index,
96 disposition: ReflectionAdmissionDisposition::Quarantine,
97 reasons,
98 });
99 }
100 }
101 }
102
103 Ok(())
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct ReflectionDurableAdmissionRefusal {
115 pub memory_index: usize,
117 pub axiom_refusal: DurableAdmissionRefusal,
119}
120
121impl fmt::Display for ReflectionDurableAdmissionRefusal {
122 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123 let memory_index = self.memory_index;
124 let axiom_refusal = &self.axiom_refusal;
125 write!(
126 f,
127 "invariant={REFLECTION_ADMISSION_PROOF_CLOSURE_INVARIANT} memory_candidates[{memory_index}] {axiom_refusal}"
128 )
129 }
130}
131
132impl std::error::Error for ReflectionDurableAdmissionRefusal {}
133
134pub fn require_reflection_durable_admission_allowed(
155 reflection: &SessionReflection,
156 adapter_id: &str,
157 raw_hash: &str,
158) -> Result<(), ReflectionDurableAdmissionRefusal> {
159 for (memory_index, memory) in reflection.memory_candidates.iter().enumerate() {
160 let request = admission_request_for_memory(reflection, memory, adapter_id, raw_hash);
161 if let Err(axiom_refusal) = request.require_durable_admission_allowed() {
162 return Err(ReflectionDurableAdmissionRefusal {
163 memory_index,
164 axiom_refusal,
165 });
166 }
167 }
168 Ok(())
169}
170
171#[must_use]
175pub fn reflection_memory_proof_state(
176 reflection: &SessionReflection,
177 memory_index: usize,
178 adapter_id: &str,
179 raw_hash: &str,
180) -> Option<CoreProofState> {
181 let memory = reflection.memory_candidates.get(memory_index)?;
182 let request = admission_request_for_memory(reflection, memory, adapter_id, raw_hash);
183 Some(request.proof_closure_report().state())
184}
185
186#[must_use]
190pub const fn axiom_admission_proof_closure_invariant() -> &'static str {
191 AXIOM_ADMISSION_PROOF_CLOSURE_INVARIANT
192}
193
194#[must_use]
196pub fn admission_request_for_memory(
197 reflection: &SessionReflection,
198 memory: &MemoryCandidate,
199 adapter_id: &str,
200 raw_hash: &str,
201) -> AxiomMemoryAdmissionRequest {
202 let source_anchors = source_anchors_for_memory(reflection, memory);
203 let proof_state = if source_anchors.is_empty() {
204 AdmissionProofState::Unknown
205 } else {
206 AdmissionProofState::Partial
207 };
208
209 AxiomMemoryAdmissionRequest {
210 candidate_state: CandidateState::Candidate,
211 evidence_class: EvidenceClass::Inferred,
212 phase_context: PhaseContext::Check,
213 tool_provenance: ToolProvenance::new(
214 format!("cortex-reflect:{adapter_id}"),
215 raw_hash,
216 AxiomImportClass::AgentProcedure,
217 ),
218 source_anchors,
219 redaction_status: RedactionStatus::Abstracted,
220 proof_state,
221 contradiction_scan: contradiction_scan_for_reflection(reflection),
222 explicit_non_promotion: true,
223 }
224}
225
226fn source_anchors_for_memory(
227 reflection: &SessionReflection,
228 memory: &MemoryCandidate,
229) -> Vec<SourceAnchor> {
230 memory
231 .source_episode_indexes
232 .iter()
233 .filter_map(|idx| reflection.episode_candidates.get(*idx))
234 .flat_map(|episode| &episode.source_event_ids)
235 .map(|event_id| SourceAnchor::new(event_id.to_string(), SourceAnchorKind::Event))
236 .collect()
237}
238
239fn contradiction_scan_for_reflection(reflection: &SessionReflection) -> ContradictionScan {
240 if reflection.contradictions.is_empty() {
241 ContradictionScan::ScannedClean
242 } else {
243 ContradictionScan::OpenContradictions(
244 reflection
245 .contradictions
246 .iter()
247 .enumerate()
248 .map(|(idx, _)| format!("reflection.contradictions[{idx}]"))
249 .collect(),
250 )
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use cortex_core::TraceId;
257 use cortex_memory::{AdmissionDecision, AdmissionRejectionReason};
258
259 use super::*;
260 use crate::schema::{EpisodeCandidate, InitialSalience, MemoryType};
261
262 fn valid_reflection() -> SessionReflection {
263 SessionReflection {
264 trace_id: "trc_01ARZ3NDEKTSV4RRFFQ69G5FAV"
265 .parse::<TraceId>()
266 .expect("valid trace id"),
267 episode_candidates: vec![EpisodeCandidate {
268 summary: "Reflection summary".to_string(),
269 source_event_ids: vec!["evt_01ARZ3NDEKTSV4RRFFQ69G5FAV"
270 .parse()
271 .expect("valid event id")],
272 domains: vec!["agents".to_string()],
273 entities: vec!["Cortex".to_string()],
274 candidate_meaning: Some("candidate meaning".to_string()),
275 confidence: 0.8,
276 }],
277 memory_candidates: vec![MemoryCandidate {
278 memory_type: MemoryType::Strategic,
279 claim: "Reflection memory remains candidate-only.".to_string(),
280 source_episode_indexes: vec![0],
281 applies_when: vec!["reflecting".to_string()],
282 does_not_apply_when: vec!["promoting".to_string()],
283 confidence: 0.8,
284 initial_salience: InitialSalience {
285 reusability: 0.5,
286 consequence: 0.5,
287 emotional_charge: 0.0,
288 },
289 }],
290 contradictions: Vec::new(),
291 doctrine_suggestions: Vec::new(),
292 }
293 }
294
295 #[test]
296 fn reflected_memory_builds_candidate_only_admission_request() {
297 let reflection = valid_reflection();
298 let request = admission_request_for_memory(
299 &reflection,
300 &reflection.memory_candidates[0],
301 "fixed",
302 "hash_01",
303 );
304
305 assert_eq!(request.candidate_state, CandidateState::Candidate);
306 assert_eq!(request.evidence_class, EvidenceClass::Inferred);
307 assert_eq!(request.phase_context, PhaseContext::Check);
308 assert_eq!(request.redaction_status, RedactionStatus::Abstracted);
309 assert_eq!(request.proof_state, AdmissionProofState::Partial);
310 assert!(request.explicit_non_promotion);
311 assert_eq!(request.source_anchors.len(), 1);
312 assert_eq!(
313 request.admission_decision(),
314 AdmissionDecision::AdmitCandidate
315 );
316 }
317
318 #[test]
319 fn reflected_memory_without_source_anchor_fails_admission() {
320 let mut reflection = valid_reflection();
321 reflection.episode_candidates[0].source_event_ids.clear();
322
323 let err = validate_reflection_admission(&reflection, "fixed", "hash_01")
324 .expect_err("missing anchors must fail admission");
325
326 assert_eq!(err.memory_index, 0);
327 assert_eq!(err.disposition, ReflectionAdmissionDisposition::Reject);
328 assert!(err
329 .reasons
330 .contains(&AdmissionRejectionReason::SourceAnchorRequired));
331 assert!(err
332 .reasons
333 .contains(&AdmissionRejectionReason::ProofStateRequired));
334 }
335
336 #[test]
337 fn reflected_memory_with_open_contradiction_is_quarantined() {
338 let mut reflection = valid_reflection();
339 reflection
340 .contradictions
341 .push(serde_json::json!({"claim": "conflict"}));
342
343 let err = validate_reflection_admission(&reflection, "fixed", "hash_01")
344 .expect_err("open contradictions must fail admission");
345
346 assert_eq!(err.memory_index, 0);
347 assert_eq!(err.disposition, ReflectionAdmissionDisposition::Quarantine);
348 assert_eq!(
349 err.reasons,
350 vec![AdmissionRejectionReason::OpenContradiction]
351 );
352 }
353
354 #[test]
368 fn reflection_durable_gate_refuses_default_partial_proof_state() {
369 let reflection = valid_reflection();
373 let err = require_reflection_durable_admission_allowed(&reflection, "fixed", "hash_01")
374 .expect_err("default reflection envelope is Partial; durable gate must refuse");
375 assert_eq!(err.memory_index, 0);
376 assert_eq!(err.axiom_refusal.proof_state, CoreProofState::Partial);
377 assert!(
378 err.to_string()
379 .contains(REFLECTION_ADMISSION_PROOF_CLOSURE_INVARIANT),
380 "refusal must carry stable invariant: {err}"
381 );
382 assert!(
383 err.to_string()
384 .contains(AXIOM_ADMISSION_PROOF_CLOSURE_INVARIANT),
385 "refusal must also surface the upstream AXIOM admission invariant: {err}"
386 );
387 }
388
389 #[test]
390 fn reflection_durable_gate_refuses_when_lineage_is_missing() {
391 let mut reflection = valid_reflection();
393 reflection.episode_candidates[0].source_event_ids.clear();
394
395 let err = require_reflection_durable_admission_allowed(&reflection, "fixed", "hash_01")
396 .expect_err("missing lineage must refuse durable promotion");
397 assert_eq!(err.memory_index, 0);
398 assert_eq!(err.axiom_refusal.proof_state, CoreProofState::Partial);
400 }
401
402 #[test]
403 fn reflection_memory_proof_state_helper_returns_observed_state() {
404 let reflection = valid_reflection();
405 let state = reflection_memory_proof_state(&reflection, 0, "fixed", "hash_01")
406 .expect("memory exists");
407 assert_eq!(state, CoreProofState::Partial);
409
410 assert!(reflection_memory_proof_state(&reflection, 99, "fixed", "hash_01").is_none());
413 }
414
415 #[test]
416 fn reflection_durable_gate_invariant_key_is_stable() {
417 assert_eq!(
418 REFLECTION_ADMISSION_PROOF_CLOSURE_INVARIANT,
419 "cortex_reflect.admission.proof_closure"
420 );
421 assert_eq!(
422 axiom_admission_proof_closure_invariant(),
423 AXIOM_ADMISSION_PROOF_CLOSURE_INVARIANT
424 );
425 }
426}