use anyhow::{anyhow, Result}; use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; use serde::{Serialize, Deserialize}; use mem_store::{PgRepo, Level}; /// Memory record from log #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MemoryRecord { pub level: String, // "L0", "L1", "L2" pub project: String, pub query_id: Option, // Some for L0/L1, None for L2 pub text: String, // Final memory text pub updated: String, // ISO8601 timestamp from run pub run_id: String, pub t: i32, // Timestamp/sequence pub source: Option, // "pi", "claude", "transcript" for L0 pub chunks_seen: Option, pub chunks_used: Option, pub parents: Vec, // Provenance (L0 chunks, L1 references) pub gate: Option, // M1.8 evidence gate } /// Parent reference for provenance #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MemoryParent { pub source: String, // "pi", "claude", etc. pub t: i32, // Timestamp/sequence pub text: String, // Parent text content pub description: Option, // e.g., "chunk 66 — Kong body buffer" } /// Verification options #[derive(Debug, Clone)] pub struct VerifyOpts { pub project: String, pub check_db: bool, pub check_log: bool, pub log_dir: Option, pub format: OutputFormat, } #[derive(Debug, Clone, Copy)] pub enum OutputFormat { Text, Json, } /// Violation of an invariant #[derive(Debug, Clone, Serialize)] pub struct Violation { pub invariant: u32, pub description: String, pub sha: Option, pub level: Option, pub run_id: Option, pub log_line: Option, } /// Verification result #[derive(Debug, Serialize)] pub struct VerificationResult { pub project: String, pub clean: bool, pub violations: Vec, pub total_violations: usize, } pub struct Verifier { repo: PgRepo, } impl Verifier { pub async fn new(db_url: &str) -> Result { let repo = PgRepo::connect(db_url).await?; Ok(Self { repo }) } /// Run all verifications pub async fn verify(&self, opts: VerifyOpts) -> Result { let log_dir = opts.log_dir.unwrap_or_else(|| PathBuf::from("log")); let mut violations = Vec::new(); // Read log let (memories, _) = Self::read_log(&log_dir, &opts.project).await?; // Check database if requested if opts.check_db { let db_violations = self.check_database(&opts.project, &memories).await?; violations.extend(db_violations); } // Check log if requested if opts.check_log { let log_violations = Self::check_log_invariants(&memories); violations.extend(log_violations); } // Deduplicate violations violations.sort_by_key(|v| (v.invariant, v.sha.clone().unwrap_or_default())); violations.dedup_by_key(|v| (v.invariant, v.sha.clone().unwrap_or_default())); let clean = violations.is_empty(); let total_violations = violations.len(); Ok(VerificationResult { project: opts.project, clean, violations, total_violations, }) } /// Check invariants against the database async fn check_database( &self, _project: &str, _memories: &[MemoryRecord], ) -> Result> { // TODO: Implement list_nodes and list_edges on PgRepo // For now, return empty violations (database verification stub) Ok(Vec::new()) } /// Check invariants against the log fn check_log_invariants(memories: &[MemoryRecord]) -> Vec { let mut violations = Vec::new(); // Build maps let mut memory_map: HashMap = HashMap::new(); let mut memory_parents: HashMap> = HashMap::new(); let mut evidence_shas: HashSet = HashSet::new(); let mut level_map: HashMap = HashMap::new(); let mut evidence_gate_count = 0; let mut evidence_records = 0; for (line_num, memory) in memories.iter().enumerate() { let sha = Self::memory_sha(&memory.text); memory_map.insert(sha.clone(), memory); level_map.insert(sha.clone(), memory.level.clone()); let parents: Vec = memory .parents .iter() .map(|p| Self::memory_sha(&p.text)) .collect(); memory_parents.insert(sha, parents.clone()); // Count evidence and gates for parent_sha in &parents { evidence_shas.insert(parent_sha.clone()); evidence_records += 1; } if memory.gate == Some(true) { evidence_gate_count += 1; } } // Invariant 1: Every L1 has at least one L0 parent for (sha, memory) in &memory_map { if memory.level == "L1" { if let Some(parents) = memory_parents.get(sha) { if parents.is_empty() { violations.push(Violation { invariant: 1, description: "L1 memory has no parents (evidence)".to_string(), sha: Some(sha.clone()), level: Some("L1".to_string()), run_id: Some(memory.run_id.clone()), log_line: None, }); } } else { violations.push(Violation { invariant: 1, description: "L1 memory not found in parent map".to_string(), sha: Some(sha.clone()), level: Some("L1".to_string()), run_id: Some(memory.run_id.clone()), log_line: None, }); } } } // Invariant 2: Every parent sha resolves to a memory that exists for (sha, parents) in &memory_parents { for parent_sha in parents { if !memory_map.contains_key(parent_sha) { violations.push(Violation { invariant: 2, description: format!("Parent sha {} not found in log", parent_sha), sha: Some(parent_sha.clone()), level: None, run_id: None, log_line: None, }); } } } // Invariant 3: Every evidence sha appears as a parent of at least one memory for evidence_sha in &evidence_shas { let mut is_cited = false; for (sha, parents) in &memory_parents { if parents.contains(evidence_sha) { is_cited = true; break; } } if !is_cited { violations.push(Violation { invariant: 3, description: format!("Evidence sha {} is not cited by any memory", evidence_sha), sha: Some(evidence_sha.clone()), level: None, run_id: None, log_line: None, }); } } // Invariant 4: evidence count equals gate.update == true count if evidence_records != evidence_gate_count { violations.push(Violation { invariant: 4, description: format!( "Evidence count {} does not match update-gate count {}", evidence_records, evidence_gate_count ), sha: None, level: None, run_id: None, log_line: None, }); } // Invariant 5: No cycles for (sha, parents) in &memory_parents { if let Some(cycle) = Self::detect_cycle(sha, parents, &memory_parents) { violations.push(Violation { invariant: 5, description: format!("Cycle detected: {} → {}", sha, cycle), sha: Some(sha.clone()), level: None, run_id: None, log_line: None, }); } } // Invariant 6: Level consistency in log (L1 parents are L0, L2 parents are L1) for (sha, memory) in &memory_map { if memory.level == "L1" { if let Some(parents) = memory_parents.get(sha) { for parent_sha in parents { if let Some(parent_level) = level_map.get(parent_sha) { if parent_level != "L0" { violations.push(Violation { invariant: 6, description: format!( "L1 memory has parent with level {} (expected L0)", parent_level ), sha: Some(sha.clone()), level: Some("L1".to_string()), run_id: Some(memory.run_id.clone()), log_line: None, }); } } } } } else if memory.level == "L2" { if let Some(parents) = memory_parents.get(sha) { for parent_sha in parents { if let Some(parent_level) = level_map.get(parent_sha) { if parent_level != "L1" { violations.push(Violation { invariant: 6, description: format!( "L2 memory has parent with level {} (expected L1)", parent_level ), sha: Some(sha.clone()), level: Some("L2".to_string()), run_id: Some(memory.run_id.clone()), log_line: None, }); } } } } } } violations } /// Detect cycle in parent graph fn detect_cycle( node: &str, _parents: &[String], all_parents: &HashMap>, ) -> Option { let mut visited = HashSet::new(); let mut rec_stack = HashSet::new(); fn dfs( node: &str, all_parents: &HashMap>, visited: &mut HashSet, rec_stack: &mut HashSet, ) -> Option { visited.insert(node.to_string()); rec_stack.insert(node.to_string()); if let Some(parents) = all_parents.get(node) { for parent in parents { if !visited.contains(parent) { if let Some(cycle) = dfs(parent, all_parents, visited, rec_stack) { return Some(cycle); } } else if rec_stack.contains(parent) { return Some(parent.clone()); } } } rec_stack.remove(node); None } dfs(node, all_parents, &mut visited, &mut rec_stack) } /// Read log directory async fn read_log( log_dir: &Path, project: &str, ) -> Result<(Vec, usize)> { let mut memories = Vec::new(); let mut total_lines = 0; if !log_dir.exists() { return Ok((memories, total_lines)); } let entries = fs::read_dir(log_dir)?; for entry in entries { let entry = entry?; let path = entry.path(); if path.extension().map(|e| e == "jsonl").unwrap_or(false) { let content = fs::read_to_string(&path)?; for line in content.lines() { total_lines += 1; if line.trim().is_empty() { continue; } if let Ok(record) = serde_json::from_str::(line) { if record.project == project { memories.push(record); } } } } } Ok((memories, total_lines)) } /// Compute memory sha256 fn memory_sha(text: &str) -> String { use sha2::Digest; let mut hasher = sha2::Sha256::new(); hasher.update(text.as_bytes()); format!("{:x}", hasher.finalize()) } }