496 lines
18 KiB
Rust
496 lines
18 KiB
Rust
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>> {
|
||
|
|
let mut violations = Vec::new();
|
||
|
|
|
||
|
|
// Get all nodes in database for this project
|
||
|
|
let nodes = self.repo.list_nodes(project).await?;
|
||
|
|
let node_shas: HashSet<_> = nodes.iter().map(|n| n.sha256.clone()).collect();
|
||
|
|
|
||
|
|
// Get all edges in database
|
||
|
|
let edges = self.repo.list_edges(project).await?;
|
||
|
|
|
||
|
|
// Build parent map from memories
|
||
|
|
let mut memory_parents: HashMap<String, Vec<String>> = HashMap::new();
|
||
|
|
let mut memory_levels: HashMap<String, String> = HashMap::new();
|
||
|
|
let mut evidence_shas: HashSet<String> = HashSet::new();
|
||
|
|
let mut memory_shas_with_evidence: HashSet<String> = HashSet::new();
|
||
|
|
|
||
|
|
for memory in memories {
|
||
|
|
let sha = Self::memory_sha(&memory.text);
|
||
|
|
let level_str = &memory.level;
|
||
|
|
memory_levels.insert(sha.clone(), level_str.clone());
|
||
|
|
|
||
|
|
let parents: Vec<String> = memory
|
||
|
|
.parents
|
||
|
|
.iter()
|
||
|
|
.map(|p| Self::memory_sha(&p.text))
|
||
|
|
.collect();
|
||
|
|
|
||
|
|
if !parents.is_empty() {
|
||
|
|
memory_shas_with_evidence.insert(sha.clone());
|
||
|
|
}
|
||
|
|
|
||
|
|
for parent_sha in &parents {
|
||
|
|
evidence_shas.insert(parent_sha.clone());
|
||
|
|
}
|
||
|
|
|
||
|
|
memory_parents.insert(sha, parents);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Invariant 2: Every parent sha in edges exists as a node
|
||
|
|
for edge in &edges {
|
||
|
|
if !node_shas.contains(&edge.parent_sha) {
|
||
|
|
violations.push(Violation {
|
||
|
|
invariant: 2,
|
||
|
|
description: format!(
|
||
|
|
"Parent sha {} referenced in edge but not found as node",
|
||
|
|
&edge.parent_sha
|
||
|
|
),
|
||
|
|
sha: Some(edge.parent_sha.clone()),
|
||
|
|
level: None,
|
||
|
|
run_id: None,
|
||
|
|
log_line: None,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
if !node_shas.contains(&edge.child_sha) {
|
||
|
|
violations.push(Violation {
|
||
|
|
invariant: 2,
|
||
|
|
description: format!(
|
||
|
|
"Child sha {} referenced in edge but not found as node",
|
||
|
|
&edge.child_sha
|
||
|
|
),
|
||
|
|
sha: Some(edge.child_sha.clone()),
|
||
|
|
level: None,
|
||
|
|
run_id: None,
|
||
|
|
log_line: None,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Invariant 6: Check level consistency in database (L1 parents are L0, L2 parents are L1)
|
||
|
|
for node in &nodes {
|
||
|
|
if node.level == "L1" {
|
||
|
|
for edge in &edges {
|
||
|
|
if edge.child_sha == node.sha256 {
|
||
|
|
if let Some(parent_node) = nodes.iter().find(|n| n.sha256 == edge.parent_sha) {
|
||
|
|
if parent_node.level != "L0" {
|
||
|
|
violations.push(Violation {
|
||
|
|
invariant: 6,
|
||
|
|
description: format!(
|
||
|
|
"L1 node {} has parent with level {} (expected L0)",
|
||
|
|
&node.sha256, &parent_node.level
|
||
|
|
),
|
||
|
|
sha: Some(node.sha256.clone()),
|
||
|
|
level: Some("L1".to_string()),
|
||
|
|
run_id: node.run_id.clone(),
|
||
|
|
log_line: None,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} else if node.level == "L2" {
|
||
|
|
for edge in &edges {
|
||
|
|
if edge.child_sha == node.sha256 {
|
||
|
|
if let Some(parent_node) = nodes.iter().find(|n| n.sha256 == edge.parent_sha) {
|
||
|
|
if parent_node.level != "L1" {
|
||
|
|
violations.push(Violation {
|
||
|
|
invariant: 6,
|
||
|
|
description: format!(
|
||
|
|
"L2 node {} has parent with level {} (expected L1)",
|
||
|
|
&node.sha256, &parent_node.level
|
||
|
|
),
|
||
|
|
sha: Some(node.sha256.clone()),
|
||
|
|
level: Some("L2".to_string()),
|
||
|
|
run_id: node.run_id.clone(),
|
||
|
|
log_line: None,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Ok(violations)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// 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: 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: 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: 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: 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())
|
||
|
|
}
|
||
|
|
}
|