diff --git a/src/store/ops.rs b/src/store/ops.rs index eee5078..7b79906 100644 --- a/src/store/ops.rs +++ b/src/store/ops.rs @@ -61,6 +61,67 @@ impl Store { 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. + /// + /// Appends: (new_key, v+1) + (old_key, deleted, v+1) + updated relations. + pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> Result<(), String> { + if old_key == new_key { + return Ok(()); + } + if self.nodes.contains_key(new_key) { + return Err(format!("Key '{}' already exists", new_key)); + } + let node = self.nodes.get(old_key) + .ok_or_else(|| format!("No node '{}'", old_key))? + .clone(); + + // New version under the new key + let mut renamed = node.clone(); + renamed.key = new_key.to_string(); + renamed.version += 1; + + // Deletion record for the old key (same UUID, independent version counter) + let mut tombstone = node.clone(); + tombstone.deleted = true; + tombstone.version += 1; + + // 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 (each append acquires its own file lock) + self.append_nodes(&[renamed.clone(), tombstone])?; + if !updated_rels.is_empty() { + self.append_relations(&updated_rels)?; + } + + // 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(()) + } + /// Modify a node in-place, bump version, and persist to capnp log. fn modify_node(&mut self, key: &str, f: impl FnOnce(&mut Node)) -> Result<(), String> { let node = self.nodes.get_mut(key) @@ -111,30 +172,30 @@ impl Store { let threshold = self.params.prune_threshold as f32; let mut decayed = 0; let mut pruned = 0; - let mut to_remove = Vec::new(); + let mut updated = Vec::new(); - for (key, node) in &mut self.nodes { + for (_key, node) in &mut self.nodes { let factor = node.category.decay_factor(base) as f32; + let old_weight = node.weight; node.weight *= factor; - node.version += 1; - decayed += 1; + + // Clamp near-prune nodes instead of removing if node.weight < threshold { - to_remove.push(key.clone()); + node.weight = node.weight.max(0.01); pruned += 1; } - } - // Don't actually remove — just mark very low weight - // Actual pruning happens during GC - for key in &to_remove { - if let Some(node) = self.nodes.get_mut(key) { - node.weight = node.weight.max(0.01); + // Only persist nodes whose weight actually changed + if (node.weight - old_weight).abs() > 1e-6 { + node.version += 1; + updated.push(node.clone()); + decayed += 1; } } - // Persist all decayed weights to capnp log - let updated: Vec = self.nodes.values().cloned().collect(); - let _ = self.append_nodes(&updated); + if !updated.is_empty() { + let _ = self.append_nodes(&updated); + } (decayed, pruned) }