Files
poimen-memory/crates/mem-cli/src/verify.rs
T

386 lines
13 KiB
Rust
Raw Normal View History

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<String>, // 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<String>, // "pi", "claude", "transcript" for L0
pub chunks_seen: Option<i32>,
pub chunks_used: Option<i32>,
pub parents: Vec<MemoryParent>, // Provenance (L0 chunks, L1 references)
pub gate: Option<bool>, // 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<String>, // 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<PathBuf>,
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<String>,
pub level: Option<String>,
pub run_id: Option<String>,
pub log_line: Option<usize>,
}
/// Verification result
#[derive(Debug, Serialize)]
pub struct VerificationResult {
pub project: String,
pub clean: bool,
pub violations: Vec<Violation>,
pub total_violations: usize,
}
pub struct Verifier {
repo: PgRepo,
}
impl Verifier {
pub async fn new(db_url: &str) -> Result<Self> {
let repo = PgRepo::connect(db_url).await?;
Ok(Self { repo })
}
/// Run all verifications
pub async fn verify(&self, opts: VerifyOpts) -> Result<VerificationResult> {
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<Vec<Violation>> {
// 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<Violation> {
let mut violations = Vec::new();
// Build maps
let mut memory_map: HashMap<String, &MemoryRecord> = HashMap::new();
let mut memory_parents: HashMap<String, Vec<String>> = HashMap::new();
let mut evidence_shas: HashSet<String> = HashSet::new();
let mut level_map: HashMap<String, String> = 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<String> = 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<String, Vec<String>>,
) -> Option<String> {
let mut visited = HashSet::new();
let mut rec_stack = HashSet::new();
fn dfs(
node: &str,
all_parents: &HashMap<String, Vec<String>>,
visited: &mut HashSet<String>,
rec_stack: &mut HashSet<String>,
) -> Option<String> {
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<MemoryRecord>, 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::<MemoryRecord>(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())
}
}