// Mutation operations on the store // // CRUD (upsert, delete), maintenance (decay, cap_degree), and graph metrics. use super::{index, types::*}; use anyhow::{anyhow, bail, Result}; use std::collections::{HashMap, HashSet}; /// Fallback provenance for non-tool-dispatch paths (CLI, digest, etc.). /// Tool dispatch passes provenance directly through thought::dispatch. pub fn current_provenance() -> String { std::env::var("POC_PROVENANCE") .unwrap_or_else(|_| "manual".to_string()) } impl Store { /// Add or update a node (appends to log + updates index). pub fn upsert_node(&mut self, mut node: Node) -> Result<()> { if let Some(existing) = self.nodes.get(&node.key) { node.uuid = existing.uuid; node.version = existing.version + 1; } let offset = self.append_nodes(&[node.clone()])?; if let Some(ref database) = self.db { index::index_node(database, &node.key, offset, &node.uuid)?; } self.uuid_to_key.insert(node.uuid, node.key.clone()); self.nodes.insert(node.key.clone(), node); Ok(()) } /// Add a relation (appends to log + updates cache) pub fn add_relation(&mut self, rel: Relation) -> Result<()> { self.append_relations(std::slice::from_ref(&rel))?; self.relations.push(rel); Ok(()) } /// Recent nodes by provenance, sorted newest-first. Returns (key, timestamp). pub fn recent_by_provenance(&self, provenance: &str, limit: usize) -> Vec<(String, i64)> { let mut nodes: Vec<_> = self.nodes.values() .filter(|n| !n.deleted && n.provenance == provenance) .map(|n| (n.key.clone(), n.timestamp)) .collect(); nodes.sort_by(|a, b| b.1.cmp(&a.1)); nodes.truncate(limit); nodes } /// Upsert a node: update if exists (and content changed), create if not. /// Returns: "created", "updated", or "unchanged". /// /// Provenance is determined by the POC_PROVENANCE env var if set, /// otherwise defaults to Manual. pub fn upsert(&mut self, key: &str, content: &str) -> Result<&'static str> { let prov = current_provenance(); self.upsert_provenance(key, content, &prov) } /// Upsert with explicit provenance (for agent-created nodes). pub fn upsert_provenance(&mut self, key: &str, content: &str, provenance: &str) -> Result<&'static str> { if let Some(existing) = self.nodes.get(key) { if existing.content == content { return Ok("unchanged"); } let mut node = existing.clone(); node.content = content.to_string(); node.provenance = provenance.to_string(); node.timestamp = now_epoch(); node.version += 1; let offset = self.append_nodes(std::slice::from_ref(&node))?; if let Some(ref database) = self.db { index::index_node(database, &node.key, offset, &node.uuid)?; } self.nodes.insert(key.to_string(), node); Ok("updated") } else { let mut node = new_node(key, content); node.provenance = provenance.to_string(); let offset = self.append_nodes(std::slice::from_ref(&node))?; if let Some(ref database) = self.db { index::index_node(database, &node.key, offset, &node.uuid)?; } self.uuid_to_key.insert(node.uuid, node.key.clone()); self.nodes.insert(key.to_string(), node); Ok("created") } } /// Soft-delete a node (appends deleted version, removes from index). pub fn delete_node(&mut self, key: &str) -> Result<()> { let prov = current_provenance(); let node = self.nodes.get(key) .ok_or_else(|| anyhow!("No node '{}'", key))?; let uuid = node.uuid; let mut deleted = node.clone(); deleted.deleted = true; deleted.version += 1; deleted.provenance = prov; deleted.timestamp = now_epoch(); self.append_nodes(std::slice::from_ref(&deleted))?; if let Some(ref database) = self.db { index::remove_node(database, key, &uuid)?; } self.nodes.remove(key); Ok(()) } /// Rename a node: change its key, update debug strings on all edges. /// /// Graph edges (source/target UUIDs) are unaffected — they're already /// UUID-based. We update the human-readable source_key/target_key strings /// on relations, and created_at is preserved untouched. pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> Result<()> { if old_key == new_key { return Ok(()); } if self.nodes.contains_key(new_key) { bail!("Key '{}' already exists", new_key); } let node = self.nodes.get(old_key) .ok_or_else(|| anyhow!("No node '{}'", old_key))? .clone(); let prov = current_provenance(); // New version under the new key let mut renamed = node.clone(); renamed.key = new_key.to_string(); renamed.version += 1; renamed.provenance = prov.clone(); renamed.timestamp = now_epoch(); // Deletion record for the old key (same UUID, independent version counter) let mut tombstone = node.clone(); tombstone.deleted = true; tombstone.version += 1; tombstone.provenance = prov; tombstone.timestamp = now_epoch(); // Collect affected relations and update their debug key strings let updated_rels: Vec<_> = self.relations.iter() .filter(|r| r.source_key == old_key || r.target_key == old_key) .map(|r| { let mut r = r.clone(); r.version += 1; if r.source_key == old_key { r.source_key = new_key.to_string(); } if r.target_key == old_key { r.target_key = new_key.to_string(); } r }) .collect(); // Persist under single lock let offset = self.append_nodes(&[renamed.clone(), tombstone.clone()])?; if !updated_rels.is_empty() { self.append_relations(&updated_rels)?; } // Update index: remove old key, add renamed if let Some(ref database) = self.db { index::remove_node(database, old_key, &tombstone.uuid)?; index::index_node(database, new_key, offset, &renamed.uuid)?; } // Update in-memory cache self.nodes.remove(old_key); self.uuid_to_key.insert(renamed.uuid, new_key.to_string()); self.nodes.insert(new_key.to_string(), renamed); for updated in &updated_rels { if let Some(r) = self.relations.iter_mut().find(|r| r.uuid == updated.uuid) { r.source_key = updated.source_key.clone(); r.target_key = updated.target_key.clone(); r.version = updated.version; } } Ok(()) } /// Cap node degree by soft-deleting edges from mega-hubs. pub fn cap_degree(&mut self, max_degree: usize) -> Result<(usize, usize)> { let mut node_degree: HashMap = HashMap::new(); for rel in &self.relations { if rel.deleted { continue; } *node_degree.entry(rel.source_key.clone()).or_default() += 1; *node_degree.entry(rel.target_key.clone()).or_default() += 1; } let mut node_edges: HashMap> = HashMap::new(); for (i, rel) in self.relations.iter().enumerate() { if rel.deleted { continue; } node_edges.entry(rel.source_key.clone()).or_default().push(i); node_edges.entry(rel.target_key.clone()).or_default().push(i); } let mut to_delete: HashSet = HashSet::new(); let mut hubs_capped = 0; for (_key, edge_indices) in &node_edges { let active: Vec = edge_indices.iter() .filter(|&&i| !to_delete.contains(&i)) .copied() .collect(); if active.len() <= max_degree { continue; } let mut auto_indices: Vec<(usize, f32)> = Vec::new(); let mut link_indices: Vec<(usize, usize)> = Vec::new(); for &i in &active { let rel = &self.relations[i]; if rel.rel_type == RelationType::Auto { auto_indices.push((i, rel.strength)); } else { let other = if &rel.source_key == _key { &rel.target_key } else { &rel.source_key }; let other_deg = node_degree.get(other).copied().unwrap_or(0); link_indices.push((i, other_deg)); } } let excess = active.len() - max_degree; auto_indices.sort_by(|a, b| a.1.total_cmp(&b.1)); let auto_prune = excess.min(auto_indices.len()); for &(i, _) in auto_indices.iter().take(auto_prune) { to_delete.insert(i); } let remaining_excess = excess.saturating_sub(auto_prune); if remaining_excess > 0 { link_indices.sort_by(|a, b| b.1.cmp(&a.1)); let link_prune = remaining_excess.min(link_indices.len()); for &(i, _) in link_indices.iter().take(link_prune) { to_delete.insert(i); } } hubs_capped += 1; } let mut pruned_rels = Vec::new(); for &i in &to_delete { self.relations[i].deleted = true; self.relations[i].version += 1; pruned_rels.push(self.relations[i].clone()); } if !pruned_rels.is_empty() { self.append_relations(&pruned_rels)?; } self.relations.retain(|r| !r.deleted); Ok((hubs_capped, to_delete.len())) } /// Set a node's weight directly. Returns (old, new). pub fn set_weight(&mut self, key: &str, weight: f32) -> Result<(f32, f32)> { let weight = weight.clamp(0.01, 1.0); let node = self.nodes.get_mut(key) .ok_or_else(|| anyhow!("node not found: {}", key))?; let old = node.weight; node.weight = weight; Ok((old, weight)) } /// Set the strength of a link between two nodes. Deduplicates if /// multiple links exist. Returns the old strength, or error if no link. pub fn set_link_strength(&mut self, source: &str, target: &str, strength: f32) -> Result { let strength = strength.clamp(0.01, 1.0); let mut old = 0.0f32; let mut found = false; let mut first = true; for rel in &mut self.relations { if rel.deleted { continue; } if (rel.source_key == source && rel.target_key == target) || (rel.source_key == target && rel.target_key == source) { if first { old = rel.strength; rel.strength = strength; first = false; } else { rel.deleted = true; // deduplicate } found = true; } } if !found { // Upsert: create the link if it doesn't exist self.add_link(source, target, "link_set")?; // Set the strength on the newly created link for rel in self.relations.iter_mut().rev() { if !rel.deleted && rel.source_key == source && rel.target_key == target { rel.strength = strength; break; } } return Ok(0.0); } Ok(old) } /// Add a link between two nodes with Jaccard-based initial strength. /// Returns the strength, or a message if the link already exists. pub fn add_link(&mut self, source: &str, target: &str, provenance: &str) -> Result { // Check for existing let exists = self.relations.iter().any(|r| !r.deleted && ((r.source_key == source && r.target_key == target) || (r.source_key == target && r.target_key == source))); if exists { bail!("link already exists: {} ↔ {}", source, target); } let source_uuid = self.nodes.get(source) .map(|n| n.uuid) .ok_or_else(|| anyhow!("source not found: {}", source))?; let target_uuid = self.nodes.get(target) .map(|n| n.uuid) .ok_or_else(|| anyhow!("target not found: {}", target))?; let graph = self.build_graph(); let jaccard = graph.jaccard(source, target); let strength = (jaccard * 3.0).clamp(0.1, 1.0) as f32; let mut rel = new_relation( source_uuid, target_uuid, RelationType::Link, strength, source, target, ); rel.provenance = provenance.to_string(); self.add_relation(rel)?; Ok(strength) } }