Skip to main content

microscope_memory/
federation.rs

1//! Multi-index federation for Microscope Memory.
2//!
3//! Query multiple microscope indices in parallel and merge results.
4//! Enables cross-project memory search.
5
6use crate::config::Config;
7use crate::reader::MicroscopeReader;
8use crate::{content_coords_blended, read_append_log, LAYER_NAMES};
9use std::path::Path;
10
11/// A single result from a federated search, tagged with its source index.
12#[derive(Clone)]
13pub struct FederatedResult {
14    pub text: String,
15    pub depth: u8,
16    pub layer: String,
17    pub score: f32,
18    pub source_index: String,
19    pub is_append: bool,
20}
21
22/// Federated search across multiple microscope indices.
23pub struct FederatedSearch {
24    /// (name, config, weight) for each index
25    indices: Vec<(String, Config, f32)>,
26}
27
28impl FederatedSearch {
29    /// Create from the main config's federation section.
30    pub fn from_config(config: &Config) -> Result<Self, String> {
31        let mut indices = Vec::new();
32
33        for entry in &config.federation.indices {
34            let idx_config = Config::load(&entry.config_path).map_err(|e| {
35                format!(
36                    "Failed to load federated index '{}' from '{}': {}",
37                    entry.name, entry.config_path, e
38                )
39            })?;
40            indices.push((entry.name.clone(), idx_config, entry.weight));
41        }
42
43        if indices.is_empty() {
44            return Err("No federated indices configured".to_string());
45        }
46
47        Ok(Self { indices })
48    }
49
50    /// Recall query across all federated indices in parallel.
51    pub fn recall(&self, query: &str, k: usize) -> Vec<FederatedResult> {
52        let results: Vec<Vec<FederatedResult>> = std::thread::scope(|s| {
53            let handles: Vec<_> = self
54                .indices
55                .iter()
56                .map(|(name, config, weight)| {
57                    let name = name.clone();
58                    let weight = *weight;
59                    s.spawn(move || recall_single(&name, config, query, k, weight))
60                })
61                .collect();
62
63            handles.into_iter().filter_map(|h| h.join().ok()).collect()
64        });
65
66        merge_results(results, k)
67    }
68
69    /// Text search across all federated indices in parallel.
70    pub fn find_text(&self, query: &str, k: usize) -> Vec<FederatedResult> {
71        let results: Vec<Vec<FederatedResult>> = std::thread::scope(|s| {
72            let handles: Vec<_> = self
73                .indices
74                .iter()
75                .map(|(name, config, weight)| {
76                    let name = name.clone();
77                    let weight = *weight;
78                    s.spawn(move || find_single(&name, config, query, k, weight))
79                })
80                .collect();
81
82            handles.into_iter().filter_map(|h| h.join().ok()).collect()
83        });
84
85        merge_results(results, k)
86    }
87
88    /// MQL query across all federated indices in parallel.
89    pub fn mql_query(&self, mql: &str, k: usize) -> Vec<FederatedResult> {
90        let results: Vec<Vec<FederatedResult>> = std::thread::scope(|s| {
91            let handles: Vec<_> = self
92                .indices
93                .iter()
94                .map(|(name, config, weight)| {
95                    let name = name.clone();
96                    let weight = *weight;
97                    s.spawn(move || mql_single(&name, config, mql, k, weight))
98                })
99                .collect();
100
101            handles.into_iter().filter_map(|h| h.join().ok()).collect()
102        });
103
104        merge_results(results, k)
105    }
106
107    /// Get names and status of all federated indices.
108    pub fn status(&self) -> Vec<(String, Result<usize, String>)> {
109        self.indices
110            .iter()
111            .map(|(name, config, _)| {
112                let result = MicroscopeReader::open(config).map(|r| r.block_count);
113                (name.clone(), result)
114            })
115            .collect()
116    }
117}
118
119/// Recall from a single index.
120fn recall_single(
121    name: &str,
122    config: &Config,
123    query: &str,
124    k: usize,
125    weight: f32,
126) -> Vec<FederatedResult> {
127    let reader = match MicroscopeReader::open(config) {
128        Ok(r) => r,
129        Err(_) => return Vec::new(),
130    };
131
132    let (qx, qy, qz) = content_coords_blended(query, "long_term", config.search.semantic_weight);
133    let relevance_query = crate::relevance::RelevanceQuery::new(query);
134
135    let (zoom_lo, zoom_hi) = match query.len() {
136        0..=10 => (0u8, 3u8),
137        11..=40 => (3, 6),
138        _ => (6, 8),
139    };
140
141    let mut results: Vec<(f32, FederatedResult)> = Vec::new();
142
143    for zoom in zoom_lo..=zoom_hi {
144        let (start, count) = reader.depth_ranges[zoom as usize];
145        let (start, count) = (start as usize, count as usize);
146        for i in start..(start + count) {
147            let text = reader.text(i);
148            let lexical = relevance_query.lexical_score(text);
149            if lexical > 0.0 {
150                let h = reader.header(i);
151                let dx = h.x - qx;
152                let dy = h.y - qy;
153                let dz = h.z - qz;
154                let dist = dx * dx + dy * dy + dz * dz;
155                let score = crate::relevance::rank_distance_from_score(
156                    lexical,
157                    dist,
158                    config.search.keyword_boost,
159                    h.importance,
160                ) / weight.max(0.01);
161                results.push((
162                    score,
163                    FederatedResult {
164                        text: text.to_string(),
165                        depth: h.depth,
166                        layer: LAYER_NAMES
167                            .get(h.layer_id as usize)
168                            .unwrap_or(&"?")
169                            .to_string(),
170                        score,
171                        source_index: name.to_string(),
172                        is_append: false,
173                    },
174                ));
175            }
176        }
177    }
178
179    // Also search append log
180    let append_path = Path::new(&config.paths.output_dir).join("append.bin");
181    let appended = read_append_log(&append_path);
182    for entry in &appended {
183        let dx = entry.x - qx;
184        let dy = entry.y - qy;
185        let dz = entry.z - qz;
186        let dist = dx * dx + dy * dy + dz * dz;
187        let lexical = relevance_query.lexical_score(&entry.text);
188        if dist < 0.1 || lexical > 0.0 {
189            let score = crate::relevance::rank_distance_from_score(
190                lexical,
191                dist,
192                config.search.keyword_boost,
193                entry.importance,
194            ) / weight.max(0.01);
195            results.push((
196                score,
197                FederatedResult {
198                    text: entry.text.clone(),
199                    depth: entry.depth,
200                    layer: LAYER_NAMES
201                        .get(entry.layer_id as usize)
202                        .unwrap_or(&"?")
203                        .to_string(),
204                    score,
205                    source_index: name.to_string(),
206                    is_append: true,
207                },
208            ));
209        }
210    }
211
212    results.sort_by(|a, b| a.0.total_cmp(&b.0));
213    results.truncate(k);
214    results.into_iter().map(|(_, r)| r).collect()
215}
216
217/// Text search from a single index.
218fn find_single(
219    name: &str,
220    config: &Config,
221    query: &str,
222    k: usize,
223    weight: f32,
224) -> Vec<FederatedResult> {
225    let reader = match MicroscopeReader::open(config) {
226        Ok(r) => r,
227        Err(_) => return Vec::new(),
228    };
229
230    let append_path = Path::new(&config.paths.output_dir).join("append.bin");
231    let appended = read_append_log(&append_path);
232    let results = reader.find_text_all(config, query, k);
233    results
234        .iter()
235        .enumerate()
236        .filter_map(|(rank, &(depth, idx, is_main))| {
237            let (text, layer) = if is_main {
238                let h = reader.header(idx);
239                (
240                    reader.text(idx).to_string(),
241                    LAYER_NAMES
242                        .get(h.layer_id as usize)
243                        .unwrap_or(&"?")
244                        .to_string(),
245                )
246            } else {
247                let entry = appended.get(idx.saturating_sub(1_000_000))?;
248                (
249                    entry.text.clone(),
250                    LAYER_NAMES
251                        .get(entry.layer_id as usize)
252                        .unwrap_or(&"?")
253                        .to_string(),
254                )
255            };
256            Some(FederatedResult {
257                text,
258                depth,
259                layer,
260                score: rank as f32 / weight,
261                source_index: name.to_string(),
262                is_append: !is_main,
263            })
264        })
265        .collect()
266}
267
268/// MQL query from a single index.
269fn mql_single(
270    name: &str,
271    config: &Config,
272    mql: &str,
273    k: usize,
274    weight: f32,
275) -> Vec<FederatedResult> {
276    let reader = match MicroscopeReader::open(config) {
277        Ok(r) => r,
278        Err(_) => return Vec::new(),
279    };
280
281    let append_path = Path::new(&config.paths.output_dir).join("append.bin");
282    let appended = read_append_log(&append_path);
283    let q = crate::query::parse(mql);
284    let mut results = crate::query::execute(&q, &reader, &appended);
285    results.truncate(k);
286
287    results
288        .iter()
289        .map(|r| {
290            if r.is_main {
291                let h = reader.header(r.block_idx);
292                FederatedResult {
293                    text: reader.text(r.block_idx).to_string(),
294                    depth: h.depth,
295                    layer: LAYER_NAMES
296                        .get(h.layer_id as usize)
297                        .unwrap_or(&"?")
298                        .to_string(),
299                    score: r.score / weight,
300                    source_index: name.to_string(),
301                    is_append: false,
302                }
303            } else {
304                let entry = &appended[r.block_idx];
305                FederatedResult {
306                    text: entry.text.clone(),
307                    depth: entry.depth,
308                    layer: LAYER_NAMES
309                        .get(entry.layer_id as usize)
310                        .unwrap_or(&"?")
311                        .to_string(),
312                    score: r.score / weight,
313                    source_index: name.to_string(),
314                    is_append: true,
315                }
316            }
317        })
318        .collect()
319}
320
321/// Merge results from multiple indices, sort by score, truncate to k.
322fn merge_results(all: Vec<Vec<FederatedResult>>, k: usize) -> Vec<FederatedResult> {
323    let mut merged: Vec<FederatedResult> = all.into_iter().flatten().collect();
324    merged.sort_by(|a, b| a.score.total_cmp(&b.score));
325    merged.truncate(k);
326    merged
327}
328
329// ─── Mirror Neuron Pulse Exchange ───────────────────
330
331/// Exchange resonance pulses across federated indices.
332/// Each index exports its outgoing pulses, and imports others' pulses.
333/// Returns total pulses exchanged.
334pub fn exchange_pulses(config: &Config) -> Result<usize, String> {
335    use crate::resonance::ResonanceState;
336
337    let output_dir = Path::new(&config.paths.output_dir);
338    let mut local = ResonanceState::load_or_init(output_dir);
339
340    // Export our outgoing pulses
341    let our_pulses = local.export_pulses();
342    let mut total_exchanged = 0usize;
343
344    // Read local headers for proximity matching
345    let reader = MicroscopeReader::open(config).map_err(|e| format!("open reader: {}", e))?;
346    let local_headers: Vec<(f32, f32, f32)> = (0..reader.block_count)
347        .map(|i| {
348            let h = reader.header(i);
349            (h.x, h.y, h.z)
350        })
351        .collect();
352
353    // For each federated index, exchange pulses
354    for idx_config in &config.federation.indices {
355        let idx_cfg =
356            Config::load(&idx_config.config_path).map_err(|e| format!("load config: {}", e))?;
357        let idx_dir = Path::new(&idx_cfg.paths.output_dir);
358
359        // Load the other index's resonance state
360        let mut other = ResonanceState::load_or_init(idx_dir);
361
362        // Send our pulses to them
363        let their_headers: Vec<(f32, f32, f32)> = {
364            if let Ok(r) = MicroscopeReader::open(&idx_cfg) {
365                (0..r.block_count)
366                    .map(|i| {
367                        let h = r.header(i);
368                        (h.x, h.y, h.z)
369                    })
370                    .collect()
371            } else {
372                continue;
373            }
374        };
375
376        let our_decoded = ResonanceState::import_pulses(&our_pulses);
377        for pulse in our_decoded {
378            other.receive_pulse(pulse, &their_headers, 0.05);
379            total_exchanged += 1;
380        }
381
382        // Receive their pulses
383        let their_pulses = other.export_pulses();
384        let their_decoded = ResonanceState::import_pulses(&their_pulses);
385        for pulse in their_decoded {
386            local.receive_pulse(pulse, &local_headers, 0.05);
387            total_exchanged += 1;
388        }
389
390        // Save the other index's updated state
391        let _ = other.save(idx_dir);
392    }
393
394    // Save our updated state
395    local
396        .save(output_dir)
397        .map_err(|e| format!("save resonance: {}", e))?;
398
399    Ok(total_exchanged)
400}
401
402// ─── Cross-Instance Learning ────────────────────────
403
404/// Exchange thought patterns and predictive cache stats across federated indices.
405/// Returns total patterns exchanged.
406pub fn exchange_patterns(config: &Config) -> Result<usize, String> {
407    use crate::predictive_cache::PredictiveCache;
408    use crate::thought_graph::ThoughtGraphState;
409
410    let output_dir = Path::new(&config.paths.output_dir);
411    let mut local_tg = ThoughtGraphState::load_or_init(output_dir);
412    let mut local_pc = PredictiveCache::load_or_init(output_dir);
413
414    let our_patterns: Vec<_> = local_tg.export_patterns().into_iter().cloned().collect();
415    let (_our_preds, _our_hits, _our_misses, our_hit_rate) = local_pc.export_stats();
416
417    let mut total_exchanged = 0usize;
418
419    for idx_config in &config.federation.indices {
420        let idx_cfg =
421            Config::load(&idx_config.config_path).map_err(|e| format!("load config: {}", e))?;
422        let idx_dir = Path::new(&idx_cfg.paths.output_dir);
423
424        let mut other_tg = ThoughtGraphState::load_or_init(idx_dir);
425        let mut other_pc = PredictiveCache::load_or_init(idx_dir);
426
427        let their_patterns: Vec<_> = other_tg.export_patterns().into_iter().cloned().collect();
428        let (their_preds, their_hits, their_misses, their_hit_rate) = other_pc.export_stats();
429
430        // Send our patterns to them (trust = our hit rate * their federation weight)
431        let trust_for_them = our_hit_rate * idx_config.weight;
432        other_tg.import_patterns(&our_patterns, trust_for_them);
433        total_exchanged += our_patterns.len();
434
435        // Receive their patterns (trust = their hit rate * their federation weight)
436        let trust_for_us = their_hit_rate * idx_config.weight;
437        local_tg.import_patterns(&their_patterns, trust_for_us);
438        total_exchanged += their_patterns.len();
439
440        // Merge stats
441        local_pc.merge_stats(their_preds, their_hits, their_misses);
442        other_pc.merge_stats(_our_preds, _our_hits, _our_misses);
443
444        let _ = other_tg.save(idx_dir);
445        let _ = other_pc.save(idx_dir);
446    }
447
448    local_tg
449        .save(output_dir)
450        .map_err(|e| format!("save thought_graph: {}", e))?;
451    local_pc
452        .save(output_dir)
453        .map_err(|e| format!("save predictive_cache: {}", e))?;
454
455    Ok(total_exchanged)
456}