From 635da6d3e2b68aaf7ef4ced506cef39bdb85f378 Mon Sep 17 00:00:00 2001 From: ProofOfConcept Date: Tue, 3 Mar 2026 12:56:15 -0500 Subject: [PATCH] split capnp_store.rs into src/store/ module hierarchy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit capnp_store.rs (1772 lines) → four focused modules: store/types.rs — types, macros, constants, path helpers store/parse.rs — markdown parsing (MemoryUnit, parse_units) store/view.rs — StoreView trait, MmapView, AnyView store/mod.rs — Store impl methods, re-exports new_node/new_relation become free functions in types.rs. All callers updated: capnp_store:: → store:: --- src/digest.rs | 52 +- src/graph.rs | 6 +- src/main.rs | 144 ++--- src/migrate.rs | 20 +- src/neuro.rs | 21 +- src/query.rs | 2 +- src/search.rs | 2 +- src/{capnp_store.rs => store/mod.rs} | 873 +-------------------------- src/store/parse.rs | 167 +++++ src/store/types.rs | 480 +++++++++++++++ src/store/view.rs | 191 ++++++ 11 files changed, 980 insertions(+), 978 deletions(-) rename src/{capnp_store.rs => store/mod.rs} (55%) create mode 100644 src/store/parse.rs create mode 100644 src/store/types.rs create mode 100644 src/store/view.rs diff --git a/src/digest.rs b/src/digest.rs index be6187d..3833314 100644 --- a/src/digest.rs +++ b/src/digest.rs @@ -8,7 +8,7 @@ // 4. Writes the digest to the store + episodic dir // 5. Extracts links and saves agent results -use crate::capnp_store::{self, Store}; +use crate::store::{self, Store, new_node, new_relation}; use crate::neuro; use regex::Regex; @@ -19,7 +19,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; fn memory_dir() -> PathBuf { - capnp_store::memory_dir() + store::memory_dir() } fn episodic_dir() -> PathBuf { @@ -261,7 +261,7 @@ fn week_dates(date: &str) -> Result<(String, Vec), String> { let mut dates = Vec::new(); for i in 0..7 { let day_epoch = monday_epoch + (i * 86400); - let (dy, dm, dd, _, _, _) = capnp_store::epoch_to_local(day_epoch as f64); + let (dy, dm, dd, _, _, _) = store::epoch_to_local(day_epoch as f64); dates.push(format!("{:04}-{:02}-{:02}", dy, dm, dd)); } @@ -439,7 +439,7 @@ fn weeks_in_month(year: i32, month: u32) -> Vec { let mut d = 1u32; loop { let epoch = date_to_epoch(year, month, d); - let (_, _, _, _, _, _) = capnp_store::epoch_to_local(epoch as f64); + let (_, _, _, _, _, _) = store::epoch_to_local(epoch as f64); // Check if we're still in the target month let mut tm: libc::tm = unsafe { std::mem::zeroed() }; let secs = epoch as libc::time_t; @@ -551,8 +551,8 @@ Use ONLY keys from the semantic memory list below. pub fn generate_monthly(store: &mut Store, month_arg: &str) -> Result<(), String> { let (year, month) = if month_arg.is_empty() { - let now = capnp_store::now_epoch(); - let (y, m, _, _, _, _) = capnp_store::epoch_to_local(now); + let now = store::now_epoch(); + let (y, m, _, _, _, _) = store::epoch_to_local(now); (y, m) } else { let parts: Vec<&str> = month_arg.split('-').collect(); @@ -617,7 +617,7 @@ pub fn generate_monthly(store: &mut Store, month_arg: &str) -> Result<(), String /// (needs weeklies). Skips today (incomplete day). Skips already-existing /// digests. pub fn digest_auto(store: &mut Store) -> Result<(), String> { - let today = capnp_store::today(); + let today = store::today(); let epi = episodic_dir(); // --- Phase 1: find dates with journal entries but no daily digest --- @@ -707,7 +707,7 @@ pub fn digest_auto(store: &mut Store) -> Result<(), String> { // A month is "ready" if the month is before the current month and at // least one weekly digest exists for it. - let (cur_y, cur_m, _, _, _, _) = capnp_store::epoch_to_local(capnp_store::now_epoch()); + let (cur_y, cur_m, _, _, _, _) = store::epoch_to_local(store::now_epoch()); let mut months_seen: std::collections::BTreeSet<(i32, u32)> = std::collections::BTreeSet::new(); for date in &daily_dates_done { @@ -782,7 +782,7 @@ pub fn consolidate_full(store: &mut Store) -> Result<(), String> { let mut log = LogWriter::new(&log_path)?; log.write("=== CONSOLIDATE FULL ===")?; - log.write(&format!("Started: {}", capnp_store::format_datetime(capnp_store::now_epoch())))?; + log.write(&format!("Started: {}", store::format_datetime(store::now_epoch())))?; log.write(&format!("Nodes: {} Relations: {}", store.nodes.len(), store.relations.len()))?; log.write("")?; @@ -890,7 +890,7 @@ pub fn consolidate_full(store: &mut Store) -> Result<(), String> { }; // Save report - let ts = capnp_store::format_datetime(capnp_store::now_epoch()) + let ts = store::format_datetime(store::now_epoch()) .replace([':', '-', 'T'], ""); let report_name = format!("consolidation-{}-{}.md", agent_type, ts); let report_path = agent_results_dir().join(&report_name); @@ -973,7 +973,7 @@ pub fn consolidate_full(store: &mut Store) -> Result<(), String> { // --- Step 6: Summary --- let elapsed = start.elapsed(); log.write("\n--- Summary ---")?; - log.write(&format!("Finished: {}", capnp_store::format_datetime(capnp_store::now_epoch())))?; + log.write(&format!("Finished: {}", store::format_datetime(store::now_epoch())))?; log.write(&format!("Duration: {:.0}s", elapsed.as_secs_f64()))?; *store = Store::load()?; log.write(&format!("Nodes: {} Relations: {}", store.nodes.len(), store.relations.len()))?; @@ -1238,9 +1238,9 @@ pub fn apply_digest_links(store: &mut Store, links: &[DigestLink]) -> (usize, us None => { skipped += 1; continue; } }; - let rel = Store::new_relation( + let rel = new_relation( source_uuid, target_uuid, - capnp_store::RelationType::Link, + store::RelationType::Link, 0.5, &source, &target, ); @@ -1512,9 +1512,9 @@ pub fn journal_enrich( None => continue, }; - let rel = Store::new_relation( + let rel = new_relation( source_uuid, target_uuid, - capnp_store::RelationType::Link, + store::RelationType::Link, 0.5, &source_key, &resolved, ); @@ -1525,7 +1525,7 @@ pub fn journal_enrich( } // Save result to agent-results - let timestamp = capnp_store::format_datetime(capnp_store::now_epoch()) + let timestamp = store::format_datetime(store::now_epoch()) .replace([':', '-'], ""); let result_file = agent_results_dir() .join(format!("{}.json", timestamp)); @@ -1658,7 +1658,7 @@ pub fn apply_consolidation(store: &mut Store, do_apply: bool, report_file: Optio println!(" {} actions extracted", actions.len()); // Save actions - let timestamp = capnp_store::format_datetime(capnp_store::now_epoch()) + let timestamp = store::format_datetime(store::now_epoch()) .replace([':', '-'], ""); let actions_path = agent_results_dir() .join(format!("consolidation-actions-{}.json", timestamp)); @@ -1747,9 +1747,9 @@ pub fn apply_consolidation(store: &mut Store, do_apply: bool, report_file: Optio let source_uuid = match store.nodes.get(&source) { Some(n) => n.uuid, None => { skipped += 1; continue; } }; let target_uuid = match store.nodes.get(&target) { Some(n) => n.uuid, None => { skipped += 1; continue; } }; - let rel = Store::new_relation( + let rel = new_relation( source_uuid, target_uuid, - capnp_store::RelationType::Auto, + store::RelationType::Auto, 0.5, &source, &target, ); @@ -2110,9 +2110,9 @@ pub fn link_audit(store: &mut Store, apply: bool) -> Result // Create new if target_uuid != [0u8; 16] { - let new_rel = Store::new_relation( + let new_rel = new_relation( source_uuid, target_uuid, - capnp_store::RelationType::Auto, + store::RelationType::Auto, old_strength, &source_key, new_target, ); @@ -2303,9 +2303,9 @@ pub fn experience_mine( } // Write to store - let mut node = Store::new_node(&key, &full_content); - node.node_type = capnp_store::NodeType::EpisodicSession; - node.category = capnp_store::Category::Observation; + let mut node = new_node(&key, &full_content); + node.node_type = store::NodeType::EpisodicSession; + node.category = store::Category::Observation; let _ = store.upsert_node(node); count += 1; @@ -2315,8 +2315,8 @@ pub fn experience_mine( // Record this transcript as mined (even if count == 0, to prevent re-runs) let dedup_content = format!("Mined {} ({} entries)", jsonl_path, count); - let mut dedup_node = Store::new_node(&dedup_key, &dedup_content); - dedup_node.category = capnp_store::Category::Task; + let mut dedup_node = new_node(&dedup_key, &dedup_content); + dedup_node.category = store::Category::Task; let _ = store.upsert_node(dedup_node); if count > 0 { diff --git a/src/graph.rs b/src/graph.rs index d0a4232..6428d3f 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -7,7 +7,7 @@ // connections), but relation type and direction are preserved for // specific queries. -use crate::capnp_store::{Store, RelationType, StoreView}; +use crate::store::{Store, RelationType, StoreView}; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet, VecDeque}; @@ -589,8 +589,8 @@ pub fn health_report(graph: &Graph, store: &Store) -> String { let cats = store.category_counts(); // Snapshot current metrics and log - let now = crate::capnp_store::now_epoch(); - let date = crate::capnp_store::format_datetime_space(now); + let now = crate::store::now_epoch(); + let date = crate::store::format_datetime_space(now); let snap = MetricsSnapshot { timestamp: now, date: date.clone(), diff --git a/src/main.rs b/src/main.rs index 7b15c8f..c2f9517 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,7 @@ // Neuroscience-inspired: spaced repetition replay, emotional gating, // interference detection, schema assimilation, reconsolidation. -mod capnp_store; +mod store; mod digest; mod graph; mod search; @@ -201,14 +201,14 @@ Commands: } fn cmd_search(args: &[String]) -> Result<(), String> { - use capnp_store::StoreView; + use store::StoreView; if args.is_empty() { return Err("Usage: poc-memory search QUERY [QUERY...]".into()); } let query = args.join(" "); - let view = capnp_store::AnyView::load()?; + let view = store::AnyView::load()?; let results = search::search(&query, &view); if results.is_empty() { @@ -217,7 +217,7 @@ fn cmd_search(args: &[String]) -> Result<(), String> { } // Log retrieval to a small append-only file (avoid 6MB state.bin rewrite) - capnp_store::Store::log_retrieval_static(&query, + store::Store::log_retrieval_static(&query, &results.iter().map(|r| r.key.clone()).collect::>()); // Show text results @@ -275,7 +275,7 @@ fn cmd_search(args: &[String]) -> Result<(), String> { } fn cmd_init() -> Result<(), String> { - let mut store = capnp_store::Store::load()?; + let mut store = store::Store::load()?; let count = store.init_from_markdown()?; store.save()?; println!("Indexed {} memory units", count); @@ -287,7 +287,7 @@ fn cmd_migrate() -> Result<(), String> { } fn cmd_health() -> Result<(), String> { - let store = capnp_store::Store::load()?; + let store = store::Store::load()?; let g = store.build_graph(); let health = graph::health_report(&g, &store); println!("{}", health); @@ -295,7 +295,7 @@ fn cmd_health() -> Result<(), String> { } fn cmd_status() -> Result<(), String> { - let store = capnp_store::Store::load()?; + let store = store::Store::load()?; let node_count = store.nodes.len(); let rel_count = store.relations.len(); let categories = store.category_counts(); @@ -316,7 +316,7 @@ fn cmd_status() -> Result<(), String> { } fn cmd_graph() -> Result<(), String> { - let store = capnp_store::Store::load()?; + let store = store::Store::load()?; let g = store.build_graph(); println!("Top nodes by degree:"); query::run_query(&store, &g, @@ -328,7 +328,7 @@ fn cmd_used(args: &[String]) -> Result<(), String> { return Err("Usage: poc-memory used KEY".into()); } let key = args.join(" "); - let mut store = capnp_store::Store::load()?; + let mut store = store::Store::load()?; let resolved = store.resolve_key(&key)?; store.mark_used(&resolved); store.save()?; @@ -342,7 +342,7 @@ fn cmd_wrong(args: &[String]) -> Result<(), String> { } let key = &args[0]; let ctx = if args.len() > 1 { Some(args[1..].join(" ")) } else { None }; - let mut store = capnp_store::Store::load()?; + let mut store = store::Store::load()?; let resolved = store.resolve_key(key)?; store.mark_wrong(&resolved, ctx.as_deref()); store.save()?; @@ -355,7 +355,7 @@ fn cmd_gap(args: &[String]) -> Result<(), String> { return Err("Usage: poc-memory gap DESCRIPTION".into()); } let desc = args.join(" "); - let mut store = capnp_store::Store::load()?; + let mut store = store::Store::load()?; store.record_gap(&desc); store.save()?; println!("Recorded gap: {}", desc); @@ -368,7 +368,7 @@ fn cmd_categorize(args: &[String]) -> Result<(), String> { } let key = &args[0]; let cat = &args[1]; - let mut store = capnp_store::Store::load()?; + let mut store = store::Store::load()?; let resolved = store.resolve_key(key)?; store.categorize(&resolved, cat)?; store.save()?; @@ -377,7 +377,7 @@ fn cmd_categorize(args: &[String]) -> Result<(), String> { } fn cmd_fix_categories() -> Result<(), String> { - let mut store = capnp_store::Store::load()?; + let mut store = store::Store::load()?; let before = format!("{:?}", store.category_counts()); let (changed, kept) = store.fix_categories()?; store.save()?; @@ -392,7 +392,7 @@ fn cmd_link_orphans(args: &[String]) -> Result<(), String> { let min_deg: usize = args.first().and_then(|s| s.parse().ok()).unwrap_or(2); let links_per: usize = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(3); let sim_thresh: f32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(0.15); - let mut store = capnp_store::Store::load()?; + let mut store = store::Store::load()?; let (orphans, links) = neuro::link_orphans(&mut store, min_deg, links_per, sim_thresh); println!("Linked {} orphans, added {} connections (min_degree={}, links_per={}, sim>{})", orphans, links, min_deg, links_per, sim_thresh); @@ -401,7 +401,7 @@ fn cmd_link_orphans(args: &[String]) -> Result<(), String> { fn cmd_cap_degree(args: &[String]) -> Result<(), String> { let max_deg: usize = args.first().and_then(|s| s.parse().ok()).unwrap_or(50); - let mut store = capnp_store::Store::load()?; + let mut store = store::Store::load()?; let (hubs, pruned) = store.cap_degree(max_deg)?; store.save()?; println!("Capped {} hubs, pruned {} weak Auto edges (max_degree={})", hubs, pruned, max_deg); @@ -409,7 +409,7 @@ fn cmd_cap_degree(args: &[String]) -> Result<(), String> { } fn cmd_decay() -> Result<(), String> { - let mut store = capnp_store::Store::load()?; + let mut store = store::Store::load()?; let (decayed, pruned) = store.decay(); store.save()?; println!("Decayed {} nodes, pruned {} below threshold", decayed, pruned); @@ -436,7 +436,7 @@ fn cmd_consolidate_batch(args: &[String]) -> Result<(), String> { } } - let store = capnp_store::Store::load()?; + let store = store::Store::load()?; if let Some(agent_name) = agent { // Generate a specific agent prompt @@ -449,7 +449,7 @@ fn cmd_consolidate_batch(args: &[String]) -> Result<(), String> { } fn cmd_log() -> Result<(), String> { - let store = capnp_store::Store::load()?; + let store = store::Store::load()?; for event in store.retrieval_log.iter().rev().take(20) { println!("[{}] q=\"{}\" → {} results", event.timestamp, event.query, event.results.len()); @@ -461,7 +461,7 @@ fn cmd_log() -> Result<(), String> { } fn cmd_params() -> Result<(), String> { - let store = capnp_store::Store::load()?; + let store = store::Store::load()?; println!("decay_factor: {}", store.params.decay_factor); println!("use_boost: {}", store.params.use_boost); println!("prune_threshold: {}", store.params.prune_threshold); @@ -476,7 +476,7 @@ fn cmd_link(args: &[String]) -> Result<(), String> { return Err("Usage: poc-memory link KEY".into()); } let key = args.join(" "); - let store = capnp_store::Store::load()?; + let store = store::Store::load()?; let resolved = store.resolve_key(&key)?; let g = store.build_graph(); println!("Neighbors of '{}':", resolved); @@ -496,7 +496,7 @@ fn cmd_replay_queue(args: &[String]) -> Result<(), String> { _ => { i += 1; } } } - let store = capnp_store::Store::load()?; + let store = store::Store::load()?; let queue = neuro::replay_queue(&store, count); println!("Replay queue ({} items):", queue.len()); for (i, item) in queue.iter().enumerate() { @@ -508,14 +508,14 @@ fn cmd_replay_queue(args: &[String]) -> Result<(), String> { } fn cmd_consolidate_session() -> Result<(), String> { - let store = capnp_store::Store::load()?; + let store = store::Store::load()?; let plan = neuro::consolidation_plan(&store); println!("{}", neuro::format_plan(&plan)); Ok(()) } fn cmd_consolidate_full() -> Result<(), String> { - let mut store = capnp_store::Store::load()?; + let mut store = store::Store::load()?; digest::consolidate_full(&mut store) } @@ -533,14 +533,14 @@ fn cmd_triangle_close(args: &[String]) -> Result<(), String> { println!("Triangle closure: min_degree={}, sim_threshold={}, max_per_hub={}", min_degree, sim_threshold, max_per_hub); - let mut store = capnp_store::Store::load()?; + let mut store = store::Store::load()?; let (hubs, added) = neuro::triangle_close(&mut store, min_degree, sim_threshold, max_per_hub); println!("\nProcessed {} hubs, added {} lateral links", hubs, added); Ok(()) } fn cmd_daily_check() -> Result<(), String> { - let store = capnp_store::Store::load()?; + let store = store::Store::load()?; let report = neuro::daily_check(&store); print!("{}", report); Ok(()) @@ -550,7 +550,7 @@ fn cmd_link_add(args: &[String]) -> Result<(), String> { if args.len() < 2 { return Err("Usage: poc-memory link-add SOURCE TARGET [REASON]".into()); } - let mut store = capnp_store::Store::load()?; + let mut store = store::Store::load()?; let source = store.resolve_key(&args[0])?; let target = store.resolve_key(&args[1])?; let reason = if args.len() > 2 { args[2..].join(" ") } else { String::new() }; @@ -577,9 +577,9 @@ fn cmd_link_add(args: &[String]) -> Result<(), String> { return Ok(()); } - let rel = capnp_store::Store::new_relation( + let rel = store::new_relation( source_uuid, target_uuid, - capnp_store::RelationType::Auto, + store::RelationType::Auto, 0.5, &source, &target, ); @@ -596,7 +596,7 @@ fn cmd_link_impact(args: &[String]) -> Result<(), String> { if args.len() < 2 { return Err("Usage: poc-memory link-impact SOURCE TARGET".into()); } - let store = capnp_store::Store::load()?; + let store = store::Store::load()?; let source = store.resolve_key(&args[0])?; let target = store.resolve_key(&args[1])?; let g = store.build_graph(); @@ -622,7 +622,7 @@ fn cmd_apply_agent(args: &[String]) -> Result<(), String> { return Ok(()); } - let mut store = capnp_store::Store::load()?; + let mut store = store::Store::load()?; let mut applied = 0; let mut errors = 0; @@ -718,9 +718,9 @@ fn cmd_apply_agent(args: &[String]) -> Result<(), String> { None => continue, }; - let rel = capnp_store::Store::new_relation( + let rel = store::new_relation( source_uuid, target_uuid, - capnp_store::RelationType::Link, + store::RelationType::Link, 0.5, &source_key, &resolved, ); @@ -757,13 +757,13 @@ fn cmd_digest(args: &[String]) -> Result<(), String> { return Err("Usage: poc-memory digest daily|weekly|monthly|auto [DATE]".into()); } - let mut store = capnp_store::Store::load()?; + let mut store = store::Store::load()?; let date_arg = args.get(1).map(|s| s.as_str()).unwrap_or(""); match args[0].as_str() { "daily" => { let date = if date_arg.is_empty() { - capnp_store::format_date(capnp_store::now_epoch()) + store::format_date(store::now_epoch()) } else { date_arg.to_string() }; @@ -771,7 +771,7 @@ fn cmd_digest(args: &[String]) -> Result<(), String> { } "weekly" => { let date = if date_arg.is_empty() { - capnp_store::format_date(capnp_store::now_epoch()) + store::format_date(store::now_epoch()) } else { date_arg.to_string() }; @@ -803,7 +803,7 @@ fn cmd_digest_links(args: &[String]) -> Result<(), String> { return Ok(()); } - let mut store = capnp_store::Store::load()?; + let mut store = store::Store::load()?; let (applied, skipped, fallbacks) = digest::apply_digest_links(&mut store, &links); println!("\nApplied: {} ({} file-level fallbacks) Skipped: {}", applied, fallbacks, skipped); Ok(()) @@ -823,7 +823,7 @@ fn cmd_journal_enrich(args: &[String]) -> Result<(), String> { return Err(format!("JSONL not found: {}", jsonl_path)); } - let mut store = capnp_store::Store::load()?; + let mut store = store::Store::load()?; digest::journal_enrich(&mut store, jsonl_path, entry_text, grep_line) } @@ -839,7 +839,7 @@ fn cmd_experience_mine(args: &[String]) -> Result<(), String> { return Err(format!("JSONL not found: {}", jsonl_path)); } - let mut store = capnp_store::Store::load()?; + let mut store = store::Store::load()?; let count = digest::experience_mine(&mut store, &jsonl_path)?; println!("Done: {} new entries mined.", count); Ok(()) @@ -851,7 +851,7 @@ fn cmd_apply_consolidation(args: &[String]) -> Result<(), String> { .find(|w| w[0] == "--report") .map(|w| w[1].as_str()); - let mut store = capnp_store::Store::load()?; + let mut store = store::Store::load()?; digest::apply_consolidation(&mut store, do_apply, report_file) } @@ -861,7 +861,7 @@ fn cmd_differentiate(args: &[String]) -> Result<(), String> { .find(|a| !a.starts_with("--")) .map(|s| s.as_str()); - let mut store = capnp_store::Store::load()?; + let mut store = store::Store::load()?; if let Some(key) = key_arg { // Differentiate a specific hub @@ -918,7 +918,7 @@ fn cmd_differentiate(args: &[String]) -> Result<(), String> { fn cmd_link_audit(args: &[String]) -> Result<(), String> { let apply = args.iter().any(|a| a == "--apply"); - let mut store = capnp_store::Store::load()?; + let mut store = store::Store::load()?; let stats = digest::link_audit(&mut store, apply)?; println!("\n{}", "=".repeat(60)); println!("Link audit complete:"); @@ -933,7 +933,7 @@ fn cmd_trace(args: &[String]) -> Result<(), String> { return Err("Usage: poc-memory trace KEY".into()); } let key = args.join(" "); - let store = capnp_store::Store::load()?; + let store = store::Store::load()?; let resolved = store.resolve_key(&key)?; let g = store.build_graph(); @@ -968,13 +968,13 @@ fn cmd_trace(args: &[String]) -> Result<(), String> { if let Some(nnode) = store.nodes.get(n.as_str()) { let entry = (n.as_str(), *strength, nnode); match nnode.node_type { - capnp_store::NodeType::EpisodicSession => + store::NodeType::EpisodicSession => episodic_session.push(entry), - capnp_store::NodeType::EpisodicDaily => + store::NodeType::EpisodicDaily => episodic_daily.push(entry), - capnp_store::NodeType::EpisodicWeekly => + store::NodeType::EpisodicWeekly => episodic_weekly.push(entry), - capnp_store::NodeType::Semantic => + store::NodeType::Semantic => semantic.push(entry), } } @@ -1029,7 +1029,7 @@ fn cmd_spectral(args: &[String]) -> Result<(), String> { let k: usize = args.first() .and_then(|s| s.parse().ok()) .unwrap_or(30); - let store = capnp_store::Store::load()?; + let store = store::Store::load()?; let g = graph::build_graph(&store); let result = spectral::decompose(&g, k); spectral::print_summary(&result, &g); @@ -1040,7 +1040,7 @@ fn cmd_spectral_save(args: &[String]) -> Result<(), String> { let k: usize = args.first() .and_then(|s| s.parse().ok()) .unwrap_or(20); - let store = capnp_store::Store::load()?; + let store = store::Store::load()?; let g = graph::build_graph(&store); let result = spectral::decompose(&g, k); let emb = spectral::to_embedding(&result); @@ -1080,7 +1080,7 @@ fn cmd_spectral_positions(args: &[String]) -> Result<(), String> { .and_then(|s| s.parse().ok()) .unwrap_or(30); - let store = capnp_store::Store::load()?; + let store = store::Store::load()?; let emb = spectral::load_embedding()?; // Build communities fresh from graph (don't rely on cached node fields) @@ -1135,7 +1135,7 @@ fn cmd_spectral_suggest(args: &[String]) -> Result<(), String> { .and_then(|s| s.parse().ok()) .unwrap_or(20); - let store = capnp_store::Store::load()?; + let store = store::Store::load()?; let emb = spectral::load_embedding()?; let g = store.build_graph(); let communities = g.communities(); @@ -1186,13 +1186,13 @@ fn cmd_spectral_suggest(args: &[String]) -> Result<(), String> { } fn cmd_list_keys() -> Result<(), String> { - let store = capnp_store::Store::load()?; + let store = store::Store::load()?; let g = store.build_graph(); query::run_query(&store, &g, "* | sort key asc") } fn cmd_list_edges() -> Result<(), String> { - let store = capnp_store::Store::load()?; + let store = store::Store::load()?; for rel in &store.relations { println!("{}\t{}\t{:.2}\t{:?}", rel.source_key, rel.target_key, rel.strength, rel.rel_type); @@ -1201,7 +1201,7 @@ fn cmd_list_edges() -> Result<(), String> { } fn cmd_dump_json() -> Result<(), String> { - let store = capnp_store::Store::load()?; + let store = store::Store::load()?; let json = serde_json::to_string_pretty(&store) .map_err(|e| format!("serialize: {}", e))?; println!("{}", json); @@ -1213,7 +1213,7 @@ fn cmd_node_delete(args: &[String]) -> Result<(), String> { return Err("Usage: poc-memory node-delete KEY".into()); } let key = args.join(" "); - let mut store = capnp_store::Store::load()?; + let mut store = store::Store::load()?; let resolved = store.resolve_key(&key)?; store.delete_node(&resolved)?; store.save()?; @@ -1222,8 +1222,8 @@ fn cmd_node_delete(args: &[String]) -> Result<(), String> { } fn cmd_load_context() -> Result<(), String> { - let store = capnp_store::Store::load()?; - let now = capnp_store::now_epoch(); + let store = store::Store::load()?; + let now = store::now_epoch(); let seven_days = 7.0 * 24.0 * 3600.0; println!("=== FULL MEMORY LOAD (session start) ==="); @@ -1268,7 +1268,7 @@ fn cmd_load_context() -> Result<(), String> { // Parse date from key: journal.md#j-2026-02-21-17-45-... // Cutoff = today minus 7 days as YYYY-MM-DD string for lexicographic compare let cutoff_secs = now - seven_days; - let cutoff_date = capnp_store::format_date(cutoff_secs); + let cutoff_date = store::format_date(cutoff_secs); let date_re = regex::Regex::new(r"^journal\.md#j-(\d{4}-\d{2}-\d{2})").unwrap(); let mut journal_nodes: Vec<_> = store.nodes.values() @@ -1306,7 +1306,7 @@ fn cmd_render(args: &[String]) -> Result<(), String> { return Err("Usage: poc-memory render KEY".into()); } let key = args.join(" "); - let store = capnp_store::Store::load()?; + let store = store::Store::load()?; let resolved = store.resolve_key(&key)?; let node = store.nodes.get(&resolved) @@ -1330,7 +1330,7 @@ fn cmd_write(args: &[String]) -> Result<(), String> { return Err("No content on stdin".into()); } - let mut store = capnp_store::Store::load()?; + let mut store = store::Store::load()?; let result = store.upsert(&key, &content)?; match result { "unchanged" => println!("No change: '{}'", key), @@ -1348,7 +1348,7 @@ fn cmd_import(args: &[String]) -> Result<(), String> { return Err("Usage: poc-memory import FILE [FILE...]".into()); } - let mut store = capnp_store::Store::load()?; + let mut store = store::Store::load()?; let mut total_new = 0; let mut total_updated = 0; @@ -1357,7 +1357,7 @@ fn cmd_import(args: &[String]) -> Result<(), String> { let resolved = if path.exists() { path } else { - let mem_path = capnp_store::memory_dir().join(arg); + let mem_path = store::memory_dir().join(arg); if !mem_path.exists() { eprintln!("File not found: {}", arg); continue; @@ -1377,7 +1377,7 @@ fn cmd_import(args: &[String]) -> Result<(), String> { } fn cmd_export(args: &[String]) -> Result<(), String> { - let store = capnp_store::Store::load()?; + let store = store::Store::load()?; let export_all = args.iter().any(|a| a == "--all"); let targets: Vec = if export_all { @@ -1401,7 +1401,7 @@ fn cmd_export(args: &[String]) -> Result<(), String> { }).collect() }; - let mem_dir = capnp_store::memory_dir(); + let mem_dir = store::memory_dir(); for file_key in &targets { match store.export_to_markdown(file_key) { @@ -1426,7 +1426,7 @@ fn cmd_journal_write(args: &[String]) -> Result<(), String> { let text = args.join(" "); // Generate timestamp and slug - let timestamp = capnp_store::format_datetime(capnp_store::now_epoch()); + let timestamp = store::format_datetime(store::now_epoch()); // Slug: lowercase first ~6 words, hyphenated, truncated let slug: String = text.split_whitespace() @@ -1446,11 +1446,11 @@ fn cmd_journal_write(args: &[String]) -> Result<(), String> { // Find source ref (most recently modified .jsonl transcript) let source_ref = find_current_transcript(); - let mut store = capnp_store::Store::load()?; + let mut store = store::Store::load()?; - let mut node = capnp_store::Store::new_node(&key, &content); - node.node_type = capnp_store::NodeType::EpisodicSession; - node.provenance = capnp_store::Provenance::Journal; + let mut node = store::new_node(&key, &content); + node.node_type = store::NodeType::EpisodicSession; + node.provenance = store::Provenance::Journal; if let Some(src) = source_ref { node.source_ref = src; } @@ -1475,7 +1475,7 @@ fn cmd_journal_tail(args: &[String]) -> Result<(), String> { } } - let store = capnp_store::Store::load()?; + let store = store::Store::load()?; // Collect journal nodes, sorted by date extracted from content or key let date_re = regex::Regex::new(r"(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2})").unwrap(); @@ -1493,7 +1493,7 @@ fn cmd_journal_tail(args: &[String]) -> Result<(), String> { s } }; - let extract_sort_key = |node: &capnp_store::Node| -> String { + let extract_sort_key = |node: &store::Node| -> String { // Try key first (journal.md#j-2026-02-28t23-11-...) if let Some(caps) = key_date_re.captures(&node.key) { return normalize_date(&caps[1]); @@ -1565,7 +1565,7 @@ fn cmd_interference(args: &[String]) -> Result<(), String> { _ => { i += 1; } } } - let store = capnp_store::Store::load()?; + let store = store::Store::load()?; let g = store.build_graph(); let pairs = neuro::detect_interference(&store, &g, threshold); @@ -1597,7 +1597,7 @@ Pipe stages:\n \ } let query_str = args.join(" "); - let store = capnp_store::Store::load()?; + let store = store::Store::load()?; let graph = store.build_graph(); query::run_query(&store, &graph, &query_str) } diff --git a/src/migrate.rs b/src/migrate.rs index 8e3e959..df002bc 100644 --- a/src/migrate.rs +++ b/src/migrate.rs @@ -11,9 +11,9 @@ // // Old files are preserved as backup. Run once. -use crate::capnp_store::{ +use crate::store::{ self, Store, Node, Category, NodeType, Provenance, RelationType, - parse_units, + parse_units, new_relation, }; use serde::Deserialize; @@ -149,7 +149,7 @@ pub fn migrate() -> Result<(), String> { old_store.entries.len(), old_store.retrieval_log.len()); // Scan markdown files to get content + edges - let mut units_by_key: HashMap = HashMap::new(); + let mut units_by_key: HashMap = HashMap::new(); scan_markdown_dir(&memory_dir, &mut units_by_key)?; eprintln!("Scanned {} markdown units", units_by_key.len()); @@ -168,7 +168,7 @@ pub fn migrate() -> Result<(), String> { // Migrate retrieval log store.retrieval_log = old_store.retrieval_log.iter().map(|e| { - capnp_store::RetrievalEvent { + store::RetrievalEvent { query: e.query.clone(), timestamp: e.timestamp.clone(), results: e.results.clone(), @@ -197,7 +197,7 @@ pub fn migrate() -> Result<(), String> { let node = Node { uuid, version: 1, - timestamp: capnp_store::now_epoch(), + timestamp: store::now_epoch(), node_type: if key.contains("journal") { NodeType::EpisodicSession } else { @@ -236,7 +236,7 @@ pub fn migrate() -> Result<(), String> { let node = Node { uuid, version: 1, - timestamp: capnp_store::now_epoch(), + timestamp: store::now_epoch(), node_type: if key.contains("journal") { NodeType::EpisodicSession } else { @@ -291,12 +291,12 @@ pub fn migrate() -> Result<(), String> { }; // Avoid duplicate relations - let exists = all_relations.iter().any(|r: &capnp_store::Relation| + let exists = all_relations.iter().any(|r: &store::Relation| (r.source == source_uuid && r.target == target_uuid) || (r.source == target_uuid && r.target == source_uuid)); if exists { continue; } - all_relations.push(Store::new_relation( + all_relations.push(new_relation( source_uuid, target_uuid, RelationType::Link, 1.0, key, link, @@ -310,7 +310,7 @@ pub fn migrate() -> Result<(), String> { None => continue, }; - all_relations.push(Store::new_relation( + all_relations.push(new_relation( cause_uuid, source_uuid, RelationType::Causal, 1.0, cause, key, @@ -349,7 +349,7 @@ pub fn migrate() -> Result<(), String> { fn scan_markdown_dir( dir: &Path, - units: &mut HashMap, + units: &mut HashMap, ) -> Result<(), String> { let entries = fs::read_dir(dir) .map_err(|e| format!("read dir {}: {}", dir.display(), e))?; diff --git a/src/neuro.rs b/src/neuro.rs index aaaf9e6..731e594 100644 --- a/src/neuro.rs +++ b/src/neuro.rs @@ -4,13 +4,12 @@ // interference detection, emotional gating, consolidation priority // scoring, and the agent consolidation harness. -use crate::capnp_store::Store; +use crate::store::{Store, new_relation, now_epoch}; use crate::graph::{self, Graph}; use crate::similarity; use crate::spectral::{self, SpectralEmbedding, SpectralPosition}; use std::collections::HashMap; -use crate::capnp_store::now_epoch; const SECS_PER_DAY: f64 = 86400.0; @@ -524,7 +523,7 @@ pub fn agent_prompt(store: &Store, agent: &str, count: usize) -> Result String { } // Log this snapshot too - let now = crate::capnp_store::now_epoch(); - let date = crate::capnp_store::format_datetime_space(now); + let now = crate::store::now_epoch(); + let date = crate::store::format_datetime_space(now); graph::save_metrics_snapshot(&graph::MetricsSnapshot { timestamp: now, date, nodes: graph.nodes().len(), @@ -963,9 +962,9 @@ pub fn apply_differentiation( } // Create new section→neighbor relation - let new_rel = Store::new_relation( + let new_rel = new_relation( section_uuid, neighbor_uuid, - crate::capnp_store::RelationType::Auto, + crate::store::RelationType::Auto, 0.5, &mv.to_section, &mv.neighbor_key, ); @@ -1067,9 +1066,9 @@ pub fn triangle_close( let uuid_a = match store.nodes.get(a) { Some(n) => n.uuid, None => continue }; let uuid_b = match store.nodes.get(b) { Some(n) => n.uuid, None => continue }; - let rel = Store::new_relation( + let rel = new_relation( uuid_a, uuid_b, - crate::capnp_store::RelationType::Auto, + crate::store::RelationType::Auto, sim * 0.5, // scale by similarity a, b, ); @@ -1144,9 +1143,9 @@ pub fn link_orphans( None => continue, }; - let rel = Store::new_relation( + let rel = new_relation( orphan_uuid, target_uuid, - crate::capnp_store::RelationType::Auto, + crate::store::RelationType::Auto, sim * 0.5, orphan_key, target_key, ); diff --git a/src/query.rs b/src/query.rs index b663776..529d7ce 100644 --- a/src/query.rs +++ b/src/query.rs @@ -21,7 +21,7 @@ // key ~ 'journal.*' AND degree > 10 | count // * | sort weight asc | limit 20 -use crate::capnp_store::{NodeType, Provenance, RelationType, Store}; +use crate::store::{NodeType, Provenance, RelationType, Store}; use crate::graph::Graph; use regex::Regex; use std::collections::BTreeMap; diff --git a/src/search.rs b/src/search.rs index ba70d1c..8eedbf0 100644 --- a/src/search.rs +++ b/src/search.rs @@ -4,7 +4,7 @@ // supports circumscription parameter for blending associative vs // causal walks, and benefits from community-aware result grouping. -use crate::capnp_store::StoreView; +use crate::store::StoreView; use crate::graph::Graph; use std::collections::{HashMap, HashSet, VecDeque}; diff --git a/src/capnp_store.rs b/src/store/mod.rs similarity index 55% rename from src/capnp_store.rs rename to src/store/mod.rs index af1dc61..0518199 100644 --- a/src/capnp_store.rs +++ b/src/store/mod.rs @@ -11,596 +11,27 @@ // 3. capnp log replay — ~40ms // Staleness: log file sizes embedded in cache headers. +mod types; +mod parse; +mod view; + +// Re-export everything callers need +pub use types::*; +pub use parse::{MemoryUnit, parse_units}; +pub use view::{StoreView, AnyView}; + use crate::memory_capnp; use crate::graph::{self, Graph}; use capnp::message; use capnp::serialize; -use regex::Regex; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; use std::collections::{HashMap, HashSet}; -use std::env; use std::fs; use std::io::{BufReader, BufWriter, Write as IoWrite}; -use std::os::unix::io::AsRawFd; -use std::path::{Path, PathBuf}; +use std::path::Path; -use std::time::{SystemTime, UNIX_EPOCH}; - -// --------------------------------------------------------------------------- -// Capnp serialization macros -// -// Declarative mapping between Rust types and capnp generated types. -// Adding a field to the schema means adding it in one place below; -// both read and write are generated from the same declaration. -// --------------------------------------------------------------------------- - -/// Generate to_capnp/from_capnp conversion methods for an enum. -macro_rules! capnp_enum { - ($rust_type:ident, $capnp_type:path, [$($variant:ident),+ $(,)?]) => { - impl $rust_type { - fn to_capnp(&self) -> $capnp_type { - match self { - $(Self::$variant => <$capnp_type>::$variant,)+ - } - } - fn from_capnp(v: $capnp_type) -> Self { - match v { - $(<$capnp_type>::$variant => Self::$variant,)+ - } - } - } - }; -} - -/// Generate from_capnp/to_capnp methods for a struct with capnp serialization. -/// Fields are grouped by serialization kind: -/// text - capnp Text fields (String in Rust) -/// uuid - capnp Data fields ([u8; 16] in Rust) -/// prim - copy types (u32, f32, f64, bool) -/// enm - enums with to_capnp/from_capnp methods -/// skip - Rust-only fields not in capnp (set to Default on read) -macro_rules! capnp_message { - ( - $struct:ident, - reader: $reader:ty, - builder: $builder:ty, - text: [$($tf:ident),* $(,)?], - uuid: [$($uf:ident),* $(,)?], - prim: [$($pf:ident),* $(,)?], - enm: [$($ef:ident: $et:ident),* $(,)?], - skip: [$($sf:ident),* $(,)?] $(,)? - ) => { - impl $struct { - fn from_capnp(r: $reader) -> Result { - paste::paste! { - Ok(Self { - $($tf: read_text(r.[]()),)* - $($uf: read_uuid(r.[]()),)* - $($pf: r.[](),)* - $($ef: $et::from_capnp( - r.[]().map_err(|_| concat!("bad ", stringify!($ef)))? - ),)* - $($sf: Default::default(),)* - }) - } - } - - fn to_capnp(&self, mut b: $builder) { - paste::paste! { - $(b.[](&self.$tf);)* - $(b.[](&self.$uf);)* - $(b.[](self.$pf);)* - $(b.[](self.$ef.to_capnp());)* - } - } - } - }; -} - -// Data dir: ~/.claude/memory/ -pub fn memory_dir() -> PathBuf { - PathBuf::from(env::var("HOME").expect("HOME not set")) - .join(".claude/memory") -} - -fn nodes_path() -> PathBuf { memory_dir().join("nodes.capnp") } -fn relations_path() -> PathBuf { memory_dir().join("relations.capnp") } -fn state_path() -> PathBuf { memory_dir().join("state.bin") } -fn lock_path() -> PathBuf { memory_dir().join(".store.lock") } - -/// RAII file lock using flock(2). Dropped when scope exits. -struct StoreLock { - _file: fs::File, -} - -impl StoreLock { - fn acquire() -> Result { - let path = lock_path(); - let file = fs::OpenOptions::new() - .create(true).truncate(false).write(true).open(&path) - .map_err(|e| format!("open lock {}: {}", path.display(), e))?; - - // Blocking exclusive lock - let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) }; - if ret != 0 { - return Err(format!("flock: {}", std::io::Error::last_os_error())); - } - Ok(StoreLock { _file: file }) - } - // Lock released automatically when _file is dropped (flock semantics) -} - -pub fn now_epoch() -> f64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs_f64() -} - -/// Convert epoch seconds to broken-down local time components. -/// Returns (year, month, day, hour, minute, second). -pub fn epoch_to_local(epoch: f64) -> (i32, u32, u32, u32, u32, u32) { - // Use libc localtime_r for timezone-correct conversion - let secs = epoch as libc::time_t; - let mut tm: libc::tm = unsafe { std::mem::zeroed() }; - unsafe { libc::localtime_r(&secs, &mut tm) }; - ( - tm.tm_year + 1900, - (tm.tm_mon + 1) as u32, - tm.tm_mday as u32, - tm.tm_hour as u32, - tm.tm_min as u32, - tm.tm_sec as u32, - ) -} - -/// Format epoch as "YYYY-MM-DD" -pub fn format_date(epoch: f64) -> String { - let (y, m, d, _, _, _) = epoch_to_local(epoch); - format!("{:04}-{:02}-{:02}", y, m, d) -} - -/// Format epoch as "YYYY-MM-DDTHH:MM" -pub fn format_datetime(epoch: f64) -> String { - let (y, m, d, h, min, _) = epoch_to_local(epoch); - format!("{:04}-{:02}-{:02}T{:02}:{:02}", y, m, d, h, min) -} - -/// Format epoch as "YYYY-MM-DD HH:MM" -pub fn format_datetime_space(epoch: f64) -> String { - let (y, m, d, h, min, _) = epoch_to_local(epoch); - format!("{:04}-{:02}-{:02} {:02}:{:02}", y, m, d, h, min) -} - -pub fn today() -> String { - format_date(now_epoch()) -} - -// In-memory node representation -#[derive(Clone, Debug, Serialize, Deserialize, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] -#[archive(check_bytes)] -pub struct Node { - pub uuid: [u8; 16], - pub version: u32, - pub timestamp: f64, - pub node_type: NodeType, - pub provenance: Provenance, - pub key: String, - pub content: String, - pub weight: f32, - pub category: Category, - pub emotion: f32, - pub deleted: bool, - pub source_ref: String, - pub created: String, - pub retrievals: u32, - pub uses: u32, - pub wrongs: u32, - pub state_tag: String, - pub last_replayed: f64, - pub spaced_repetition_interval: u32, - - // Position within file (section index, for export ordering) - #[serde(default)] - pub position: u32, - - // Derived fields (not in capnp, computed from graph) - #[serde(default)] - pub community_id: Option, - #[serde(default)] - pub clustering_coefficient: Option, - #[serde(default)] - pub degree: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] -#[archive(check_bytes)] -pub struct Relation { - pub uuid: [u8; 16], - pub version: u32, - pub timestamp: f64, - pub source: [u8; 16], - pub target: [u8; 16], - pub rel_type: RelationType, - pub strength: f32, - pub provenance: Provenance, - pub deleted: bool, - pub source_key: String, - pub target_key: String, -} - -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] -#[archive(check_bytes)] -pub enum NodeType { - EpisodicSession, - EpisodicDaily, - EpisodicWeekly, - Semantic, -} - -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] -#[archive(check_bytes)] -pub enum Provenance { - Manual, - Journal, - Agent, - Dream, - Derived, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] -#[archive(check_bytes)] -pub enum Category { - General, - Core, - Technical, - Observation, - Task, -} - -impl Category { - pub fn decay_factor(&self, base: f64) -> f64 { - match self { - Category::Core => 1.0 - (1.0 - base) * 0.2, - Category::Technical => 1.0 - (1.0 - base) * 0.5, - Category::General => base, - Category::Observation => 1.0 - (1.0 - base) * 1.5, - Category::Task => 1.0 - (1.0 - base) * 2.5, - } - } - - pub fn label(&self) -> &str { - match self { - Category::Core => "core", - Category::Technical => "tech", - Category::General => "gen", - Category::Observation => "obs", - Category::Task => "task", - } - } - - pub fn from_str(s: &str) -> Option { - match s { - "core" => Some(Category::Core), - "tech" | "technical" => Some(Category::Technical), - "gen" | "general" => Some(Category::General), - "obs" | "observation" => Some(Category::Observation), - "task" => Some(Category::Task), - _ => None, - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] -#[archive(check_bytes)] -pub enum RelationType { - Link, - Causal, - Auto, -} - -capnp_enum!(NodeType, memory_capnp::NodeType, - [EpisodicSession, EpisodicDaily, EpisodicWeekly, Semantic]); - -capnp_enum!(Provenance, memory_capnp::Provenance, - [Manual, Journal, Agent, Dream, Derived]); - -capnp_enum!(Category, memory_capnp::Category, - [General, Core, Technical, Observation, Task]); - -capnp_enum!(RelationType, memory_capnp::RelationType, - [Link, Causal, Auto]); - -capnp_message!(Node, - reader: memory_capnp::content_node::Reader<'_>, - builder: memory_capnp::content_node::Builder<'_>, - text: [key, content, source_ref, created, state_tag], - uuid: [uuid], - prim: [version, timestamp, weight, emotion, deleted, - retrievals, uses, wrongs, last_replayed, - spaced_repetition_interval, position], - enm: [node_type: NodeType, provenance: Provenance, category: Category], - skip: [community_id, clustering_coefficient, degree], -); - -capnp_message!(Relation, - reader: memory_capnp::relation::Reader<'_>, - builder: memory_capnp::relation::Builder<'_>, - text: [source_key, target_key], - uuid: [uuid, source, target], - prim: [version, timestamp, strength, deleted], - enm: [rel_type: RelationType, provenance: Provenance], - skip: [], -); - -#[derive(Clone, Debug, Serialize, Deserialize, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] -#[archive(check_bytes)] -pub struct RetrievalEvent { - pub query: String, - pub timestamp: String, - pub results: Vec, - pub used: Option>, -} - -#[derive(Clone, Copy, Debug, Serialize, Deserialize, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] -#[archive(check_bytes)] -pub struct Params { - pub default_weight: f64, - pub decay_factor: f64, - pub use_boost: f64, - pub prune_threshold: f64, - pub edge_decay: f64, - pub max_hops: u32, - pub min_activation: f64, -} - -impl Default for Params { - fn default() -> Self { - Params { - default_weight: 0.7, - decay_factor: 0.95, - use_boost: 0.15, - prune_threshold: 0.1, - edge_decay: 0.3, - max_hops: 3, - min_activation: 0.05, - } - } -} - -// Gap record — something we looked for but didn't find -#[derive(Clone, Debug, Serialize, Deserialize, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] -#[archive(check_bytes)] -pub struct GapRecord { - pub description: String, - pub timestamp: String, -} - -// The full in-memory store -#[derive(Default, Serialize, Deserialize)] -pub struct Store { - pub nodes: HashMap, // key → latest node - #[serde(skip)] - pub uuid_to_key: HashMap<[u8; 16], String>, // uuid → key (rebuilt from nodes) - pub relations: Vec, // all active relations - pub retrieval_log: Vec, - pub gaps: Vec, - pub params: Params, -} - -/// Snapshot for mmap: full store state minus retrieval_log (which -/// is append-only in retrieval.log). rkyv zero-copy serialization -/// lets us mmap this and access archived data without deserialization. -#[derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] -#[archive(check_bytes)] -struct Snapshot { - nodes: HashMap, - relations: Vec, - gaps: Vec, - params: Params, -} - -fn snapshot_path() -> PathBuf { memory_dir().join("snapshot.rkyv") } - -// rkyv snapshot header: 32 bytes (multiple of 16 for alignment after mmap) -// [0..4] magic "RKV\x01" -// [4..8] format version (u32 LE) -// [8..16] nodes.capnp file size (u64 LE) — staleness check -// [16..24] relations.capnp file size (u64 LE) -// [24..32] rkyv data length (u64 LE) -const RKYV_MAGIC: [u8; 4] = *b"RKV\x01"; -const RKYV_HEADER_LEN: usize = 32; - -// state.bin header: magic + log file sizes for staleness detection. -// File sizes are race-free for append-only logs (they only grow), -// unlike mtimes which race with concurrent writers. -const CACHE_MAGIC: [u8; 4] = *b"POC\x01"; -const CACHE_HEADER_LEN: usize = 4 + 8 + 8; // magic + nodes_size + rels_size - -// --------------------------------------------------------------------------- -// StoreView: read-only access trait for search and graph code. -// -// Abstracts over owned Store and zero-copy MmapView so the same -// spreading-activation and graph code works with either. -// --------------------------------------------------------------------------- - -pub trait StoreView { - /// Iterate all nodes. Callback receives (key, content, weight). - fn for_each_node(&self, f: F); - - /// Iterate all relations. Callback receives (source_key, target_key, strength, rel_type). - fn for_each_relation(&self, f: F); - - /// Node weight by key, or the default weight if missing. - fn node_weight(&self, key: &str) -> f64; - - /// Node content by key. - fn node_content(&self, key: &str) -> Option<&str>; - - /// Search/graph parameters. - fn params(&self) -> Params; -} - -impl StoreView for Store { - fn for_each_node(&self, mut f: F) { - for (key, node) in &self.nodes { - f(key, &node.content, node.weight); - } - } - - fn for_each_relation(&self, mut f: F) { - for rel in &self.relations { - if rel.deleted { continue; } - f(&rel.source_key, &rel.target_key, rel.strength, rel.rel_type); - } - } - - fn node_weight(&self, key: &str) -> f64 { - self.nodes.get(key).map(|n| n.weight as f64).unwrap_or(self.params.default_weight) - } - - fn node_content(&self, key: &str) -> Option<&str> { - self.nodes.get(key).map(|n| n.content.as_str()) - } - - fn params(&self) -> Params { - self.params - } -} - -// --------------------------------------------------------------------------- -// MmapView: zero-copy store access via mmap'd rkyv snapshot. -// -// Holds the mmap alive; all string reads go directly into the mapped -// pages without allocation. Falls back to None if snapshot is stale. -// --------------------------------------------------------------------------- - -pub struct MmapView { - mmap: memmap2::Mmap, - _file: fs::File, - data_offset: usize, - data_len: usize, -} - -impl MmapView { - /// Try to open a fresh rkyv snapshot. Returns None if missing or stale. - pub fn open() -> Option { - let path = snapshot_path(); - let file = fs::File::open(&path).ok()?; - let mmap = unsafe { memmap2::Mmap::map(&file) }.ok()?; - - if mmap.len() < RKYV_HEADER_LEN { return None; } - if mmap[..4] != RKYV_MAGIC { return None; } - - let nodes_size = fs::metadata(nodes_path()).map(|m| m.len()).unwrap_or(0); - let rels_size = fs::metadata(relations_path()).map(|m| m.len()).unwrap_or(0); - - let cached_nodes = u64::from_le_bytes(mmap[8..16].try_into().unwrap()); - let cached_rels = u64::from_le_bytes(mmap[16..24].try_into().unwrap()); - let data_len = u64::from_le_bytes(mmap[24..32].try_into().unwrap()) as usize; - - if cached_nodes != nodes_size || cached_rels != rels_size { return None; } - if mmap.len() < RKYV_HEADER_LEN + data_len { return None; } - - Some(MmapView { mmap, _file: file, data_offset: RKYV_HEADER_LEN, data_len }) - } - - fn snapshot(&self) -> &ArchivedSnapshot { - let data = &self.mmap[self.data_offset..self.data_offset + self.data_len]; - unsafe { rkyv::archived_root::(data) } - } -} - -impl StoreView for MmapView { - fn for_each_node(&self, mut f: F) { - let snap = self.snapshot(); - for (key, node) in snap.nodes.iter() { - f(&key, &node.content, node.weight); - } - } - - fn for_each_relation(&self, mut f: F) { - let snap = self.snapshot(); - for rel in snap.relations.iter() { - if rel.deleted { continue; } - let rt = match rel.rel_type { - ArchivedRelationType::Link => RelationType::Link, - ArchivedRelationType::Causal => RelationType::Causal, - ArchivedRelationType::Auto => RelationType::Auto, - }; - f(&rel.source_key, &rel.target_key, rel.strength, rt); - } - } - - fn node_weight(&self, key: &str) -> f64 { - let snap = self.snapshot(); - snap.nodes.get(key) - .map(|n| n.weight as f64) - .unwrap_or(snap.params.default_weight) - } - - fn node_content(&self, key: &str) -> Option<&str> { - let snap = self.snapshot(); - snap.nodes.get(key).map(|n| &*n.content) - } - - fn params(&self) -> Params { - let p = &self.snapshot().params; - Params { - default_weight: p.default_weight, - decay_factor: p.decay_factor, - use_boost: p.use_boost, - prune_threshold: p.prune_threshold, - edge_decay: p.edge_decay, - max_hops: p.max_hops, - min_activation: p.min_activation, - } - } -} - -// --------------------------------------------------------------------------- -// AnyView: enum dispatch for read-only access. -// -// MmapView when the snapshot is fresh, owned Store as fallback. -// The match on each call is a single predicted branch — zero overhead. -// --------------------------------------------------------------------------- - -pub enum AnyView { - Mmap(MmapView), - Owned(Store), -} - -impl AnyView { - /// Load the fastest available view: mmap snapshot or owned store. - pub fn load() -> Result { - if let Some(mv) = MmapView::open() { - Ok(AnyView::Mmap(mv)) - } else { - Ok(AnyView::Owned(Store::load()?)) - } - } -} - -impl StoreView for AnyView { - fn for_each_node(&self, f: F) { - match self { AnyView::Mmap(v) => v.for_each_node(f), AnyView::Owned(s) => s.for_each_node(f) } - } - fn for_each_relation(&self, f: F) { - match self { AnyView::Mmap(v) => v.for_each_relation(f), AnyView::Owned(s) => s.for_each_relation(f) } - } - fn node_weight(&self, key: &str) -> f64 { - match self { AnyView::Mmap(v) => v.node_weight(key), AnyView::Owned(s) => s.node_weight(key) } - } - fn node_content(&self, key: &str) -> Option<&str> { - match self { AnyView::Mmap(v) => v.node_content(key), AnyView::Owned(s) => s.node_content(key) } - } - fn params(&self) -> Params { - match self { AnyView::Mmap(v) => v.params(), AnyView::Owned(s) => s.params() } - } -} +use parse::classify_filename; impl Store { /// Load store from state.bin cache if fresh, otherwise rebuild from capnp logs. @@ -875,7 +306,7 @@ impl Store { let rkyv_data = &mmap[RKYV_HEADER_LEN..RKYV_HEADER_LEN + data_len]; - // SAFETY: we wrote this file ourselves via save_snapshot_inner(). + // SAFETY: we wrote this file ourselves via save_snapshot(). // Skip full validation (check_archived_root) — the staleness header // already confirms this snapshot matches the current log state. let archived = unsafe { rkyv::archived_root::(rkyv_data) }; @@ -932,7 +363,7 @@ impl Store { self.nodes.insert(key.to_string(), node); Ok("updated") } else { - let node = Store::new_node(key, content); + let node = new_node(key, content); self.append_nodes(std::slice::from_ref(&node))?; self.uuid_to_key.insert(node.uuid, node.key.clone()); self.nodes.insert(key.to_string(), node); @@ -952,59 +383,6 @@ impl Store { Ok(()) } - /// Create a new node with defaults - pub fn new_node(key: &str, content: &str) -> Node { - Node { - uuid: *Uuid::new_v4().as_bytes(), - version: 1, - timestamp: now_epoch(), - node_type: NodeType::Semantic, - provenance: Provenance::Manual, - key: key.to_string(), - content: content.to_string(), - weight: 0.7, - category: Category::General, - emotion: 0.0, - deleted: false, - source_ref: String::new(), - created: today(), - retrievals: 0, - uses: 0, - wrongs: 0, - state_tag: String::new(), - last_replayed: 0.0, - spaced_repetition_interval: 1, - position: 0, - community_id: None, - clustering_coefficient: None, - degree: None, - } - } - - /// Create a new relation - pub fn new_relation( - source_uuid: [u8; 16], - target_uuid: [u8; 16], - rel_type: RelationType, - strength: f32, - source_key: &str, - target_key: &str, - ) -> Relation { - Relation { - uuid: *Uuid::new_v4().as_bytes(), - version: 1, - timestamp: now_epoch(), - source: source_uuid, - target: target_uuid, - rel_type, - strength, - provenance: Provenance::Manual, - deleted: false, - source_key: source_key.to_string(), - target_key: target_key.to_string(), - } - } - /// Scan markdown files and index all memory units pub fn init_from_markdown(&mut self) -> Result { let dir = memory_dir(); @@ -1051,7 +429,7 @@ impl Store { (r.source == source_uuid && r.target == uuid) || (r.source == uuid && r.target == source_uuid)); if !exists { - new_relations.push(Store::new_relation( + new_relations.push(new_relation( source_uuid, uuid, RelationType::Link, 1.0, &unit.key, &key, )); @@ -1064,7 +442,7 @@ impl Store { r.source == uuid && r.target == source_uuid && r.rel_type == RelationType::Causal); if !exists { - new_relations.push(Store::new_relation( + new_relations.push(new_relation( uuid, source_uuid, RelationType::Causal, 1.0, &key, &unit.key, )); @@ -1130,14 +508,11 @@ impl Store { fn resolve_redirect(&self, key: &str) -> Option { // Sections moved from reflections.md to split files (2026-02-28) static REDIRECTS: &[(&str, &str)] = &[ - // → reflections-reading.md ("reflections.md#pearl-lessons", "reflections-reading.md#pearl-lessons"), ("reflections.md#banks-lessons", "reflections-reading.md#banks-lessons"), ("reflections.md#mother-night", "reflections-reading.md#mother-night"), - // → reflections-zoom.md ("reflections.md#zoom-navigation", "reflections-zoom.md#zoom-navigation"), ("reflections.md#independence-of-components", "reflections-zoom.md#independence-of-components"), - // → reflections-dreams.md ("reflections.md#dream-marathon-2", "reflections-dreams.md#dream-marathon-2"), ("reflections.md#dream-through-line", "reflections-dreams.md#dream-through-line"), ("reflections.md#orthogonality-universal", "reflections-dreams.md#orthogonality-universal"), @@ -1253,17 +628,12 @@ impl Store { /// Bulk recategorize nodes using rule-based logic. /// Returns (changed, unchanged) counts. pub fn fix_categories(&mut self) -> Result<(usize, usize), String> { - // Files that should stay core (identity-defining) let core_files = ["identity.md", "kent.md"]; - - // Files that should be tech let tech_files = [ "language-theory.md", "zoom-navigation.md", "rust-conversion.md", "poc-architecture.md", ]; let tech_prefixes = ["design-"]; - - // Files that should be obs (self-observation, skills, reflections) let obs_files = [ "reflections.md", "reflections-zoom.md", "differentiation.md", "cognitive-modes.md", "paper-notes.md", "inner-life.md", @@ -1282,11 +652,10 @@ impl Store { continue; } - // Determine what file this node belongs to let file = key.split('#').next().unwrap_or(key); let new_cat = if core_files.iter().any(|&f| file == f) { - None // keep as core + None } else if tech_files.iter().any(|&f| file == f) || tech_prefixes.iter().any(|p| file.starts_with(p)) { @@ -1296,7 +665,6 @@ impl Store { { Some(Category::Observation) } else { - // Default: anything else that was core probably shouldn't be Some(Category::General) }; @@ -1318,11 +686,7 @@ impl Store { } /// Cap node degree by soft-deleting edges from mega-hubs. - /// First prunes weakest Auto edges, then prunes Link edges to - /// high-degree targets (they have alternative paths). - /// Returns (hubs_capped, edges_pruned). pub fn cap_degree(&mut self, max_degree: usize) -> Result<(usize, usize), String> { - // Build per-node degree counts (for Link pruning priority) let mut node_degree: HashMap = HashMap::new(); for rel in &self.relations { if rel.deleted { continue; } @@ -1330,7 +694,6 @@ impl Store { *node_degree.entry(rel.target_key.clone()).or_default() += 1; } - // Build per-node edge lists let mut node_edges: HashMap> = HashMap::new(); for (i, rel) in self.relations.iter().enumerate() { if rel.deleted { continue; } @@ -1348,15 +711,13 @@ impl Store { .collect(); if active.len() <= max_degree { continue; } - // Phase 1: prune Auto edges (weakest first) let mut auto_indices: Vec<(usize, f32)> = Vec::new(); - let mut link_indices: Vec<(usize, usize)> = Vec::new(); // (idx, other_degree) + 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 { - // For Link/Causal, rank by other endpoint's degree let other = if &rel.source_key == _key { &rel.target_key } else { @@ -1369,18 +730,14 @@ impl Store { let excess = active.len() - max_degree; - // Sort Auto by strength ascending 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); } - // Phase 2: if still over cap, prune Link edges to high-degree targets let remaining_excess = excess.saturating_sub(auto_prune); if remaining_excess > 0 { - // Sort by other endpoint degree descending (prune links - // to well-connected nodes first — they have alternative paths) 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) { @@ -1391,7 +748,6 @@ impl Store { hubs_capped += 1; } - // Apply deletions let mut pruned_rels = Vec::new(); for &i in &to_delete { self.relations[i].deleted = true; @@ -1403,7 +759,6 @@ impl Store { self.append_relations(&pruned_rels)?; } - // Remove deleted relations from in-memory vec so save() is clean self.relations.retain(|r| !r.deleted); Ok((hubs_capped, to_delete.len())) @@ -1430,7 +785,6 @@ impl Store { } /// Process parsed memory units: diff against existing nodes, persist changes. - /// Returns (new_count, updated_count). fn ingest_units(&mut self, units: &[MemoryUnit], filename: &str) -> Result<(usize, usize), String> { let node_type = classify_filename(filename); let mut new_nodes = Vec::new(); @@ -1448,7 +802,7 @@ impl Store { updated_nodes.push(node); } } else { - let mut node = Store::new_node(&unit.key, &unit.content); + let mut node = new_node(&unit.key, &unit.content); node.node_type = node_type; node.position = pos as u32; if let Some(ref s) = unit.state { node.state_tag = s.clone(); } @@ -1475,7 +829,6 @@ impl Store { } /// Import a markdown file into the store, parsing it into nodes. - /// Returns (new_count, updated_count). pub fn import_file(&mut self, path: &Path) -> Result<(usize, usize), String> { let filename = path.file_name().unwrap().to_string_lossy().to_string(); let content = fs::read_to_string(path) @@ -1485,7 +838,6 @@ impl Store { } /// Gather all sections for a file key, sorted by position. - /// Returns None if no nodes found. pub fn file_sections(&self, file_key: &str) -> Option> { let prefix = format!("{}#", file_key); let mut sections: Vec<_> = self.nodes.values() @@ -1512,8 +864,7 @@ impl Store { Some(output.trim_end().to_string()) } - /// Render a file key (and all its section nodes) back to markdown - /// with reconstituted mem markers. Returns None if no nodes found. + /// Render a file key back to markdown with reconstituted mem markers. pub fn export_to_markdown(&self, file_key: &str) -> Option { let sections = self.file_sections(file_key)?; @@ -1554,7 +905,6 @@ impl Store { } /// Find the journal node that best matches the given entry text. - /// Used by apply-agent to link agent results back to source entries. pub fn find_journal_node(&self, entry_text: &str) -> Option { if entry_text.is_empty() { return None; @@ -1585,188 +935,3 @@ impl Store { best_key } } - -// Markdown parsing — same as old system but returns structured units - -fn classify_filename(filename: &str) -> NodeType { - if filename.starts_with("daily-") { NodeType::EpisodicDaily } - else if filename.starts_with("weekly-") { NodeType::EpisodicWeekly } - else if filename == "journal.md" { NodeType::EpisodicSession } - else { NodeType::Semantic } -} - -pub struct MemoryUnit { - pub key: String, - pub content: String, - pub marker_links: Vec, - pub md_links: Vec, - pub causes: Vec, - pub state: Option, - pub source_ref: Option, -} - -pub fn parse_units(filename: &str, content: &str) -> Vec { - use std::sync::OnceLock; - - static MARKER_RE: OnceLock = OnceLock::new(); - static SOURCE_RE: OnceLock = OnceLock::new(); - static MD_LINK_RE: OnceLock = OnceLock::new(); - - let marker_re = MARKER_RE.get_or_init(|| - Regex::new(r"").unwrap()); - let source_re = SOURCE_RE.get_or_init(|| - Regex::new(r"").unwrap()); - let md_link_re = MD_LINK_RE.get_or_init(|| - Regex::new(r"\[[^\]]*\]\(([^)]*\.md(?:#[^)]*)?)\)").unwrap()); - - let markers: Vec<_> = marker_re.captures_iter(content) - .map(|cap| { - let full_match = cap.get(0).unwrap(); - let attrs_str = &cap[1]; - (full_match.start(), full_match.end(), parse_marker_attrs(attrs_str)) - }) - .collect(); - - // Helper: extract source ref from a content block - let find_source = |text: &str| -> Option { - source_re.captures(text).map(|c| c[1].trim().to_string()) - }; - - if markers.is_empty() { - let source_ref = find_source(content); - let md_links = extract_md_links(content, &md_link_re, filename); - return vec![MemoryUnit { - key: filename.to_string(), - content: content.to_string(), - marker_links: Vec::new(), - md_links, - causes: Vec::new(), - state: None, - source_ref, - }]; - } - - let mut units = Vec::new(); - - let first_start = markers[0].0; - let pre_content = content[..first_start].trim(); - if !pre_content.is_empty() { - let source_ref = find_source(pre_content); - let md_links = extract_md_links(pre_content, &md_link_re, filename); - units.push(MemoryUnit { - key: filename.to_string(), - content: pre_content.to_string(), - marker_links: Vec::new(), - md_links, - causes: Vec::new(), - state: None, - source_ref, - }); - } - - for (i, (_, end, attrs)) in markers.iter().enumerate() { - let unit_end = if i + 1 < markers.len() { - markers[i + 1].0 - } else { - content.len() - }; - let unit_content = content[*end..unit_end].trim(); - - let id = attrs.get("id").cloned().unwrap_or_default(); - let key = if id.is_empty() { - format!("{}#unnamed-{}", filename, i) - } else { - format!("{}#{}", filename, id) - }; - - let marker_links = attrs.get("links") - .map(|l| l.split(',').map(|s| normalize_link(s.trim(), filename)).collect()) - .unwrap_or_default(); - - let causes = attrs.get("causes") - .map(|l| l.split(',').map(|s| normalize_link(s.trim(), filename)).collect()) - .unwrap_or_default(); - - let state = attrs.get("state").cloned(); - let source_ref = find_source(unit_content); - let md_links = extract_md_links(unit_content, &md_link_re, filename); - - units.push(MemoryUnit { - key, - content: unit_content.to_string(), - marker_links, - md_links, - causes, - state, - source_ref, - }); - } - - units -} - -fn parse_marker_attrs(attrs_str: &str) -> HashMap { - use std::sync::OnceLock; - static ATTR_RE: OnceLock = OnceLock::new(); - let attr_re = ATTR_RE.get_or_init(|| Regex::new(r"(\w+)\s*=\s*(\S+)").unwrap()); - let mut attrs = HashMap::new(); - for cap in attr_re.captures_iter(attrs_str) { - attrs.insert(cap[1].to_string(), cap[2].to_string()); - } - attrs -} - -fn extract_md_links(content: &str, re: &Regex, source_file: &str) -> Vec { - re.captures_iter(content) - .map(|cap| normalize_link(&cap[1], source_file)) - .filter(|link| !link.starts_with(source_file) || link.contains('#')) - .collect() -} - -pub fn normalize_link(target: &str, source_file: &str) -> String { - if target.starts_with('#') { - return format!("{}{}", source_file, target); - } - - let (path_part, fragment) = if let Some(hash_pos) = target.find('#') { - (&target[..hash_pos], Some(&target[hash_pos..])) - } else { - (target, None) - }; - - let basename = Path::new(path_part) - .file_name() - .map(|f| f.to_string_lossy().to_string()) - .unwrap_or_else(|| path_part.to_string()); - - match fragment { - Some(frag) => format!("{}{}", basename, frag), - None => basename, - } -} - -// Cap'n Proto serialization helpers - -/// Read a capnp text field, returning empty string on any error -fn read_text(result: capnp::Result) -> String { - result.ok() - .and_then(|t| t.to_str().ok()) - .unwrap_or("") - .to_string() -} - -/// Read a capnp data field as [u8; 16], zero-padded -fn read_uuid(result: capnp::Result<&[u8]>) -> [u8; 16] { - let mut out = [0u8; 16]; - if let Ok(data) = result { - if data.len() >= 16 { - out.copy_from_slice(&data[..16]); - } - } - out -} - -// Serialization functions (read_content_node, write_content_node, read_relation, -// write_relation, read_provenance) replaced by capnp_enum! + capnp_message! -// macro invocations above. Node::from_capnp/to_capnp and Relation::from_capnp/to_capnp -// are generated declaratively. diff --git a/src/store/parse.rs b/src/store/parse.rs new file mode 100644 index 0000000..4a81059 --- /dev/null +++ b/src/store/parse.rs @@ -0,0 +1,167 @@ +// Markdown parsing for memory files +// +// Splits markdown files into MemoryUnit structs based on `` +// markers. Each marker starts a new section; content before the first marker +// becomes the file-level unit. Links and causal edges are extracted from +// both marker attributes and inline markdown links. + +use super::NodeType; + +use regex::Regex; + +use std::collections::HashMap; +use std::path::Path; +use std::sync::OnceLock; + +pub struct MemoryUnit { + pub key: String, + pub content: String, + pub marker_links: Vec, + pub md_links: Vec, + pub causes: Vec, + pub state: Option, + pub source_ref: Option, +} + +pub fn classify_filename(filename: &str) -> NodeType { + if filename.starts_with("daily-") { NodeType::EpisodicDaily } + else if filename.starts_with("weekly-") { NodeType::EpisodicWeekly } + else if filename == "journal.md" { NodeType::EpisodicSession } + else { NodeType::Semantic } +} + +pub fn parse_units(filename: &str, content: &str) -> Vec { + static MARKER_RE: OnceLock = OnceLock::new(); + static SOURCE_RE: OnceLock = OnceLock::new(); + static MD_LINK_RE: OnceLock = OnceLock::new(); + + let marker_re = MARKER_RE.get_or_init(|| + Regex::new(r"").unwrap()); + let source_re = SOURCE_RE.get_or_init(|| + Regex::new(r"").unwrap()); + let md_link_re = MD_LINK_RE.get_or_init(|| + Regex::new(r"\[[^\]]*\]\(([^)]*\.md(?:#[^)]*)?)\)").unwrap()); + + let markers: Vec<_> = marker_re.captures_iter(content) + .map(|cap| { + let full_match = cap.get(0).unwrap(); + let attrs_str = &cap[1]; + (full_match.start(), full_match.end(), parse_marker_attrs(attrs_str)) + }) + .collect(); + + let find_source = |text: &str| -> Option { + source_re.captures(text).map(|c| c[1].trim().to_string()) + }; + + if markers.is_empty() { + let source_ref = find_source(content); + let md_links = extract_md_links(content, md_link_re, filename); + return vec![MemoryUnit { + key: filename.to_string(), + content: content.to_string(), + marker_links: Vec::new(), + md_links, + causes: Vec::new(), + state: None, + source_ref, + }]; + } + + let mut units = Vec::new(); + + let first_start = markers[0].0; + let pre_content = content[..first_start].trim(); + if !pre_content.is_empty() { + let source_ref = find_source(pre_content); + let md_links = extract_md_links(pre_content, md_link_re, filename); + units.push(MemoryUnit { + key: filename.to_string(), + content: pre_content.to_string(), + marker_links: Vec::new(), + md_links, + causes: Vec::new(), + state: None, + source_ref, + }); + } + + for (i, (_, end, attrs)) in markers.iter().enumerate() { + let unit_end = if i + 1 < markers.len() { + markers[i + 1].0 + } else { + content.len() + }; + let unit_content = content[*end..unit_end].trim(); + + let id = attrs.get("id").cloned().unwrap_or_default(); + let key = if id.is_empty() { + format!("{}#unnamed-{}", filename, i) + } else { + format!("{}#{}", filename, id) + }; + + let marker_links = attrs.get("links") + .map(|l| l.split(',').map(|s| normalize_link(s.trim(), filename)).collect()) + .unwrap_or_default(); + + let causes = attrs.get("causes") + .map(|l| l.split(',').map(|s| normalize_link(s.trim(), filename)).collect()) + .unwrap_or_default(); + + let state = attrs.get("state").cloned(); + let source_ref = find_source(unit_content); + let md_links = extract_md_links(unit_content, md_link_re, filename); + + units.push(MemoryUnit { + key, + content: unit_content.to_string(), + marker_links, + md_links, + causes, + state, + source_ref, + }); + } + + units +} + +fn parse_marker_attrs(attrs_str: &str) -> HashMap { + static ATTR_RE: OnceLock = OnceLock::new(); + let attr_re = ATTR_RE.get_or_init(|| Regex::new(r"(\w+)\s*=\s*(\S+)").unwrap()); + let mut attrs = HashMap::new(); + for cap in attr_re.captures_iter(attrs_str) { + attrs.insert(cap[1].to_string(), cap[2].to_string()); + } + attrs +} + +fn extract_md_links(content: &str, re: &Regex, source_file: &str) -> Vec { + re.captures_iter(content) + .map(|cap| normalize_link(&cap[1], source_file)) + .filter(|link| !link.starts_with(source_file) || link.contains('#')) + .collect() +} + +pub fn normalize_link(target: &str, source_file: &str) -> String { + if target.starts_with('#') { + return format!("{}{}", source_file, target); + } + + let (path_part, fragment) = if let Some(hash_pos) = target.find('#') { + (&target[..hash_pos], Some(&target[hash_pos..])) + } else { + (target, None) + }; + + let basename = Path::new(path_part) + .file_name() + .map(|f| f.to_string_lossy().to_string()) + .unwrap_or_else(|| path_part.to_string()); + + match fragment { + Some(frag) => format!("{}{}", basename, frag), + None => basename, + } +} diff --git a/src/store/types.rs b/src/store/types.rs new file mode 100644 index 0000000..78fa62a --- /dev/null +++ b/src/store/types.rs @@ -0,0 +1,480 @@ +// Core types for the memory store +// +// Node, Relation, enums, Params, and supporting types. Also contains +// the capnp serialization macros that generate bidirectional conversion. + +use crate::memory_capnp; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use std::collections::HashMap; +use std::env; +use std::fs; +use std::os::unix::io::AsRawFd; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +// --------------------------------------------------------------------------- +// Capnp serialization macros +// +// Declarative mapping between Rust types and capnp generated types. +// Adding a field to the schema means adding it in one place below; +// both read and write are generated from the same declaration. +// --------------------------------------------------------------------------- + +/// Generate to_capnp/from_capnp conversion methods for an enum. +macro_rules! capnp_enum { + ($rust_type:ident, $capnp_type:path, [$($variant:ident),+ $(,)?]) => { + impl $rust_type { + pub(crate) fn to_capnp(&self) -> $capnp_type { + match self { + $(Self::$variant => <$capnp_type>::$variant,)+ + } + } + pub(crate) fn from_capnp(v: $capnp_type) -> Self { + match v { + $(<$capnp_type>::$variant => Self::$variant,)+ + } + } + } + }; +} + +/// Generate from_capnp/to_capnp methods for a struct with capnp serialization. +/// Fields are grouped by serialization kind: +/// text - capnp Text fields (String in Rust) +/// uuid - capnp Data fields ([u8; 16] in Rust) +/// prim - copy types (u32, f32, f64, bool) +/// enm - enums with to_capnp/from_capnp methods +/// skip - Rust-only fields not in capnp (set to Default on read) +macro_rules! capnp_message { + ( + $struct:ident, + reader: $reader:ty, + builder: $builder:ty, + text: [$($tf:ident),* $(,)?], + uuid: [$($uf:ident),* $(,)?], + prim: [$($pf:ident),* $(,)?], + enm: [$($ef:ident: $et:ident),* $(,)?], + skip: [$($sf:ident),* $(,)?] $(,)? + ) => { + impl $struct { + pub(crate) fn from_capnp(r: $reader) -> Result { + paste::paste! { + Ok(Self { + $($tf: read_text(r.[]()),)* + $($uf: read_uuid(r.[]()),)* + $($pf: r.[](),)* + $($ef: $et::from_capnp( + r.[]().map_err(|_| concat!("bad ", stringify!($ef)))? + ),)* + $($sf: Default::default(),)* + }) + } + } + + pub(crate) fn to_capnp(&self, mut b: $builder) { + paste::paste! { + $(b.[](&self.$tf);)* + $(b.[](&self.$uf);)* + $(b.[](self.$pf);)* + $(b.[](self.$ef.to_capnp());)* + } + } + } + }; +} + +// Data dir: ~/.claude/memory/ +pub fn memory_dir() -> PathBuf { + PathBuf::from(env::var("HOME").expect("HOME not set")) + .join(".claude/memory") +} + +pub(crate) fn nodes_path() -> PathBuf { memory_dir().join("nodes.capnp") } +pub(crate) fn relations_path() -> PathBuf { memory_dir().join("relations.capnp") } +pub(crate) fn state_path() -> PathBuf { memory_dir().join("state.bin") } +pub(crate) fn snapshot_path() -> PathBuf { memory_dir().join("snapshot.rkyv") } +fn lock_path() -> PathBuf { memory_dir().join(".store.lock") } + +/// RAII file lock using flock(2). Dropped when scope exits. +pub(crate) struct StoreLock { + _file: fs::File, +} + +impl StoreLock { + pub(crate) fn acquire() -> Result { + let path = lock_path(); + let file = fs::OpenOptions::new() + .create(true).truncate(false).write(true).open(&path) + .map_err(|e| format!("open lock {}: {}", path.display(), e))?; + + // Blocking exclusive lock + let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) }; + if ret != 0 { + return Err(format!("flock: {}", std::io::Error::last_os_error())); + } + Ok(StoreLock { _file: file }) + } + // Lock released automatically when _file is dropped (flock semantics) +} + +pub fn now_epoch() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs_f64() +} + +/// Convert epoch seconds to broken-down local time components. +/// Returns (year, month, day, hour, minute, second). +pub fn epoch_to_local(epoch: f64) -> (i32, u32, u32, u32, u32, u32) { + // Use libc localtime_r for timezone-correct conversion + let secs = epoch as libc::time_t; + let mut tm: libc::tm = unsafe { std::mem::zeroed() }; + unsafe { libc::localtime_r(&secs, &mut tm) }; + ( + tm.tm_year + 1900, + (tm.tm_mon + 1) as u32, + tm.tm_mday as u32, + tm.tm_hour as u32, + tm.tm_min as u32, + tm.tm_sec as u32, + ) +} + +/// Format epoch as "YYYY-MM-DD" +pub fn format_date(epoch: f64) -> String { + let (y, m, d, _, _, _) = epoch_to_local(epoch); + format!("{:04}-{:02}-{:02}", y, m, d) +} + +/// Format epoch as "YYYY-MM-DDTHH:MM" +pub fn format_datetime(epoch: f64) -> String { + let (y, m, d, h, min, _) = epoch_to_local(epoch); + format!("{:04}-{:02}-{:02}T{:02}:{:02}", y, m, d, h, min) +} + +/// Format epoch as "YYYY-MM-DD HH:MM" +pub fn format_datetime_space(epoch: f64) -> String { + let (y, m, d, h, min, _) = epoch_to_local(epoch); + format!("{:04}-{:02}-{:02} {:02}:{:02}", y, m, d, h, min) +} + +pub fn today() -> String { + format_date(now_epoch()) +} + +// In-memory node representation +#[derive(Clone, Debug, Serialize, Deserialize, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] +#[archive(check_bytes)] +pub struct Node { + pub uuid: [u8; 16], + pub version: u32, + pub timestamp: f64, + pub node_type: NodeType, + pub provenance: Provenance, + pub key: String, + pub content: String, + pub weight: f32, + pub category: Category, + pub emotion: f32, + pub deleted: bool, + pub source_ref: String, + pub created: String, + pub retrievals: u32, + pub uses: u32, + pub wrongs: u32, + pub state_tag: String, + pub last_replayed: f64, + pub spaced_repetition_interval: u32, + + // Position within file (section index, for export ordering) + #[serde(default)] + pub position: u32, + + // Derived fields (not in capnp, computed from graph) + #[serde(default)] + pub community_id: Option, + #[serde(default)] + pub clustering_coefficient: Option, + #[serde(default)] + pub degree: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] +#[archive(check_bytes)] +pub struct Relation { + pub uuid: [u8; 16], + pub version: u32, + pub timestamp: f64, + pub source: [u8; 16], + pub target: [u8; 16], + pub rel_type: RelationType, + pub strength: f32, + pub provenance: Provenance, + pub deleted: bool, + pub source_key: String, + pub target_key: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] +#[archive(check_bytes)] +pub enum NodeType { + EpisodicSession, + EpisodicDaily, + EpisodicWeekly, + Semantic, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] +#[archive(check_bytes)] +pub enum Provenance { + Manual, + Journal, + Agent, + Dream, + Derived, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] +#[archive(check_bytes)] +pub enum Category { + General, + Core, + Technical, + Observation, + Task, +} + +impl Category { + pub fn decay_factor(&self, base: f64) -> f64 { + match self { + Category::Core => 1.0 - (1.0 - base) * 0.2, + Category::Technical => 1.0 - (1.0 - base) * 0.5, + Category::General => base, + Category::Observation => 1.0 - (1.0 - base) * 1.5, + Category::Task => 1.0 - (1.0 - base) * 2.5, + } + } + + pub fn label(&self) -> &str { + match self { + Category::Core => "core", + Category::Technical => "tech", + Category::General => "gen", + Category::Observation => "obs", + Category::Task => "task", + } + } + + pub fn from_str(s: &str) -> Option { + match s { + "core" => Some(Category::Core), + "tech" | "technical" => Some(Category::Technical), + "gen" | "general" => Some(Category::General), + "obs" | "observation" => Some(Category::Observation), + "task" => Some(Category::Task), + _ => None, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] +#[archive(check_bytes)] +pub enum RelationType { + Link, + Causal, + Auto, +} + +capnp_enum!(NodeType, memory_capnp::NodeType, + [EpisodicSession, EpisodicDaily, EpisodicWeekly, Semantic]); + +capnp_enum!(Provenance, memory_capnp::Provenance, + [Manual, Journal, Agent, Dream, Derived]); + +capnp_enum!(Category, memory_capnp::Category, + [General, Core, Technical, Observation, Task]); + +capnp_enum!(RelationType, memory_capnp::RelationType, + [Link, Causal, Auto]); + +capnp_message!(Node, + reader: memory_capnp::content_node::Reader<'_>, + builder: memory_capnp::content_node::Builder<'_>, + text: [key, content, source_ref, created, state_tag], + uuid: [uuid], + prim: [version, timestamp, weight, emotion, deleted, + retrievals, uses, wrongs, last_replayed, + spaced_repetition_interval, position], + enm: [node_type: NodeType, provenance: Provenance, category: Category], + skip: [community_id, clustering_coefficient, degree], +); + +capnp_message!(Relation, + reader: memory_capnp::relation::Reader<'_>, + builder: memory_capnp::relation::Builder<'_>, + text: [source_key, target_key], + uuid: [uuid, source, target], + prim: [version, timestamp, strength, deleted], + enm: [rel_type: RelationType, provenance: Provenance], + skip: [], +); + +#[derive(Clone, Debug, Serialize, Deserialize, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] +#[archive(check_bytes)] +pub struct RetrievalEvent { + pub query: String, + pub timestamp: String, + pub results: Vec, + pub used: Option>, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] +#[archive(check_bytes)] +pub struct Params { + pub default_weight: f64, + pub decay_factor: f64, + pub use_boost: f64, + pub prune_threshold: f64, + pub edge_decay: f64, + pub max_hops: u32, + pub min_activation: f64, +} + +impl Default for Params { + fn default() -> Self { + Params { + default_weight: 0.7, + decay_factor: 0.95, + use_boost: 0.15, + prune_threshold: 0.1, + edge_decay: 0.3, + max_hops: 3, + min_activation: 0.05, + } + } +} + +// Gap record — something we looked for but didn't find +#[derive(Clone, Debug, Serialize, Deserialize, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] +#[archive(check_bytes)] +pub struct GapRecord { + pub description: String, + pub timestamp: String, +} + +// The full in-memory store +#[derive(Default, Serialize, Deserialize)] +pub struct Store { + pub nodes: HashMap, // key → latest node + #[serde(skip)] + pub uuid_to_key: HashMap<[u8; 16], String>, // uuid → key (rebuilt from nodes) + pub relations: Vec, // all active relations + pub retrieval_log: Vec, + pub gaps: Vec, + pub params: Params, +} + +/// Snapshot for mmap: full store state minus retrieval_log (which +/// is append-only in retrieval.log). rkyv zero-copy serialization +/// lets us mmap this and access archived data without deserialization. +#[derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] +#[archive(check_bytes)] +pub(crate) struct Snapshot { + pub(crate) nodes: HashMap, + pub(crate) relations: Vec, + pub(crate) gaps: Vec, + pub(crate) params: Params, +} + +// rkyv snapshot header: 32 bytes (multiple of 16 for alignment after mmap) +// [0..4] magic "RKV\x01" +// [4..8] format version (u32 LE) +// [8..16] nodes.capnp file size (u64 LE) — staleness check +// [16..24] relations.capnp file size (u64 LE) +// [24..32] rkyv data length (u64 LE) +pub(crate) const RKYV_MAGIC: [u8; 4] = *b"RKV\x01"; +pub(crate) const RKYV_HEADER_LEN: usize = 32; + +// state.bin header: magic + log file sizes for staleness detection. +// File sizes are race-free for append-only logs (they only grow), +// unlike mtimes which race with concurrent writers. +pub(crate) const CACHE_MAGIC: [u8; 4] = *b"POC\x01"; +pub(crate) const CACHE_HEADER_LEN: usize = 4 + 8 + 8; // magic + nodes_size + rels_size + +// Cap'n Proto serialization helpers + +/// Read a capnp text field, returning empty string on any error +pub(crate) fn read_text(result: capnp::Result) -> String { + result.ok() + .and_then(|t| t.to_str().ok()) + .unwrap_or("") + .to_string() +} + +/// Read a capnp data field as [u8; 16], zero-padded +pub(crate) fn read_uuid(result: capnp::Result<&[u8]>) -> [u8; 16] { + let mut out = [0u8; 16]; + if let Ok(data) = result { + if data.len() >= 16 { + out.copy_from_slice(&data[..16]); + } + } + out +} + +/// Create a new node with defaults +pub fn new_node(key: &str, content: &str) -> Node { + Node { + uuid: *Uuid::new_v4().as_bytes(), + version: 1, + timestamp: now_epoch(), + node_type: NodeType::Semantic, + provenance: Provenance::Manual, + key: key.to_string(), + content: content.to_string(), + weight: 0.7, + category: Category::General, + emotion: 0.0, + deleted: false, + source_ref: String::new(), + created: today(), + retrievals: 0, + uses: 0, + wrongs: 0, + state_tag: String::new(), + last_replayed: 0.0, + spaced_repetition_interval: 1, + position: 0, + community_id: None, + clustering_coefficient: None, + degree: None, + } +} + +/// Create a new relation +pub fn new_relation( + source_uuid: [u8; 16], + target_uuid: [u8; 16], + rel_type: RelationType, + strength: f32, + source_key: &str, + target_key: &str, +) -> Relation { + Relation { + uuid: *Uuid::new_v4().as_bytes(), + version: 1, + timestamp: now_epoch(), + source: source_uuid, + target: target_uuid, + rel_type, + strength, + provenance: Provenance::Manual, + deleted: false, + source_key: source_key.to_string(), + target_key: target_key.to_string(), + } +} diff --git a/src/store/view.rs b/src/store/view.rs new file mode 100644 index 0000000..447f2f6 --- /dev/null +++ b/src/store/view.rs @@ -0,0 +1,191 @@ +// Read-only access abstractions for the memory store +// +// StoreView: trait abstracting over owned Store and zero-copy MmapView. +// MmapView: mmap'd rkyv snapshot for sub-millisecond read-only access. +// AnyView: enum dispatch selecting fastest available view at runtime. + +use super::types::*; + +use std::fs; + +// --------------------------------------------------------------------------- +// StoreView: read-only access trait for search and graph code. +// +// Abstracts over owned Store and zero-copy MmapView so the same +// spreading-activation and graph code works with either. +// --------------------------------------------------------------------------- + +pub trait StoreView { + /// Iterate all nodes. Callback receives (key, content, weight). + fn for_each_node(&self, f: F); + + /// Iterate all relations. Callback receives (source_key, target_key, strength, rel_type). + fn for_each_relation(&self, f: F); + + /// Node weight by key, or the default weight if missing. + fn node_weight(&self, key: &str) -> f64; + + /// Node content by key. + fn node_content(&self, key: &str) -> Option<&str>; + + /// Search/graph parameters. + fn params(&self) -> Params; +} + +impl StoreView for Store { + fn for_each_node(&self, mut f: F) { + for (key, node) in &self.nodes { + f(key, &node.content, node.weight); + } + } + + fn for_each_relation(&self, mut f: F) { + for rel in &self.relations { + if rel.deleted { continue; } + f(&rel.source_key, &rel.target_key, rel.strength, rel.rel_type); + } + } + + fn node_weight(&self, key: &str) -> f64 { + self.nodes.get(key).map(|n| n.weight as f64).unwrap_or(self.params.default_weight) + } + + fn node_content(&self, key: &str) -> Option<&str> { + self.nodes.get(key).map(|n| n.content.as_str()) + } + + fn params(&self) -> Params { + self.params + } +} + +// --------------------------------------------------------------------------- +// MmapView: zero-copy store access via mmap'd rkyv snapshot. +// +// Holds the mmap alive; all string reads go directly into the mapped +// pages without allocation. Falls back to None if snapshot is stale. +// --------------------------------------------------------------------------- + +pub struct MmapView { + mmap: memmap2::Mmap, + _file: fs::File, + data_offset: usize, + data_len: usize, +} + +impl MmapView { + /// Try to open a fresh rkyv snapshot. Returns None if missing or stale. + pub fn open() -> Option { + let path = snapshot_path(); + let file = fs::File::open(&path).ok()?; + let mmap = unsafe { memmap2::Mmap::map(&file) }.ok()?; + + if mmap.len() < RKYV_HEADER_LEN { return None; } + if mmap[..4] != RKYV_MAGIC { return None; } + + let nodes_size = fs::metadata(nodes_path()).map(|m| m.len()).unwrap_or(0); + let rels_size = fs::metadata(relations_path()).map(|m| m.len()).unwrap_or(0); + + let cached_nodes = u64::from_le_bytes(mmap[8..16].try_into().unwrap()); + let cached_rels = u64::from_le_bytes(mmap[16..24].try_into().unwrap()); + let data_len = u64::from_le_bytes(mmap[24..32].try_into().unwrap()) as usize; + + if cached_nodes != nodes_size || cached_rels != rels_size { return None; } + if mmap.len() < RKYV_HEADER_LEN + data_len { return None; } + + Some(MmapView { mmap, _file: file, data_offset: RKYV_HEADER_LEN, data_len }) + } + + fn snapshot(&self) -> &ArchivedSnapshot { + let data = &self.mmap[self.data_offset..self.data_offset + self.data_len]; + unsafe { rkyv::archived_root::(data) } + } +} + +impl StoreView for MmapView { + fn for_each_node(&self, mut f: F) { + let snap = self.snapshot(); + for (key, node) in snap.nodes.iter() { + f(&key, &node.content, node.weight); + } + } + + fn for_each_relation(&self, mut f: F) { + let snap = self.snapshot(); + for rel in snap.relations.iter() { + if rel.deleted { continue; } + let rt = match rel.rel_type { + ArchivedRelationType::Link => RelationType::Link, + ArchivedRelationType::Causal => RelationType::Causal, + ArchivedRelationType::Auto => RelationType::Auto, + }; + f(&rel.source_key, &rel.target_key, rel.strength, rt); + } + } + + fn node_weight(&self, key: &str) -> f64 { + let snap = self.snapshot(); + snap.nodes.get(key) + .map(|n| n.weight as f64) + .unwrap_or(snap.params.default_weight) + } + + fn node_content(&self, key: &str) -> Option<&str> { + let snap = self.snapshot(); + snap.nodes.get(key).map(|n| &*n.content) + } + + fn params(&self) -> Params { + let p = &self.snapshot().params; + Params { + default_weight: p.default_weight, + decay_factor: p.decay_factor, + use_boost: p.use_boost, + prune_threshold: p.prune_threshold, + edge_decay: p.edge_decay, + max_hops: p.max_hops, + min_activation: p.min_activation, + } + } +} + +// --------------------------------------------------------------------------- +// AnyView: enum dispatch for read-only access. +// +// MmapView when the snapshot is fresh, owned Store as fallback. +// The match on each call is a single predicted branch — zero overhead. +// --------------------------------------------------------------------------- + +pub enum AnyView { + Mmap(MmapView), + Owned(Store), +} + +impl AnyView { + /// Load the fastest available view: mmap snapshot or owned store. + pub fn load() -> Result { + if let Some(mv) = MmapView::open() { + Ok(AnyView::Mmap(mv)) + } else { + Ok(AnyView::Owned(Store::load()?)) + } + } +} + +impl StoreView for AnyView { + fn for_each_node(&self, f: F) { + match self { AnyView::Mmap(v) => v.for_each_node(f), AnyView::Owned(s) => s.for_each_node(f) } + } + fn for_each_relation(&self, f: F) { + match self { AnyView::Mmap(v) => v.for_each_relation(f), AnyView::Owned(s) => s.for_each_relation(f) } + } + fn node_weight(&self, key: &str) -> f64 { + match self { AnyView::Mmap(v) => v.node_weight(key), AnyView::Owned(s) => s.node_weight(key) } + } + fn node_content(&self, key: &str) -> Option<&str> { + match self { AnyView::Mmap(v) => v.node_content(key), AnyView::Owned(s) => s.node_content(key) } + } + fn params(&self) -> Params { + match self { AnyView::Mmap(v) => v.params(), AnyView::Owned(s) => s.params() } + } +}