Skip to main content

qualia_core_db/modalities/
graph_theory.rs

1//! Advanced graph theory analysis for NQuin graphs.
2//!
3//! This module exposes two graph-analysis tiers:
4//! - `analyze_graph_topology_bounded` is the preferred zero-heap path for 10D tensor
5//!   orchestration and edge inference. It uses fixed-capacity arrays only.
6//! - `analyze_graph_topology` is the quarantined compatibility path for bounded,
7//!   batch-style topology jobs. It still uses `HashMap`, `HashSet`, `Vec`, and
8//!   `VecDeque`, so callers must keep it off hot paths and within the input cap below.
9
10use crate::NQuin;
11use std::collections::{HashMap, HashSet};
12
13/// Heap-backed graph analysis is quarantined behind a fixed input cap so daemon
14/// callers do not accidentally fan out into unbounded topology jobs on edge nodes.
15pub const MAX_HEAP_GRAPH_ANALYSIS_QUINS: usize = 4_096;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum GraphAnalysisError {
19    InputTooLarge,
20    NodeCapacityExceeded,
21    OutputBufferFull,
22}
23
24/// Preferred zero-heap node cap for edge-safe topology analysis.
25pub const MAX_BOUNDED_GRAPH_ANALYSIS_NODES: usize = 128;
26
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub struct CommunitySpan {
29    pub start: u16,
30    pub len: u16,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq)]
34pub struct TopNodeScore {
35    pub node_id: u64,
36    pub centrality_score: f32,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq)]
40pub struct MotifRecord {
41    pub pattern: MotifPattern,
42    pub node_a: u64,
43    pub node_b: u64,
44    pub node_c: u64,
45    pub frequency: f32,
46}
47
48/// Maximum number of nodes in a subgraph-isomorphism query pattern. Bounding the
49/// pattern keeps the backtracking search depth (and therefore the stack) fixed.
50pub const MAX_SUBGRAPH_PATTERN_NODES: usize = 8;
51
52/// A directed query pattern for subgraph isomorphism. `adjacency[i][j] != 0`
53/// requires a data-graph edge from the node mapped to pattern node `i` to the
54/// node mapped to pattern node `j`. Only the first `node_count` rows/cols are read.
55#[derive(Debug, Clone, Copy)]
56pub struct SubgraphPattern {
57    pub adjacency: [[u8; MAX_SUBGRAPH_PATTERN_NODES]; MAX_SUBGRAPH_PATTERN_NODES],
58    pub node_count: usize,
59}
60
61impl SubgraphPattern {
62    /// Build an empty (edgeless) pattern of `node_count` nodes.
63    pub fn new(node_count: usize) -> Self {
64        Self {
65            adjacency: [[0; MAX_SUBGRAPH_PATTERN_NODES]; MAX_SUBGRAPH_PATTERN_NODES],
66            node_count: node_count.min(MAX_SUBGRAPH_PATTERN_NODES),
67        }
68    }
69
70    /// Add a required directed edge `from -> to` to the pattern.
71    pub fn with_edge(mut self, from: usize, to: usize) -> Self {
72        if from < self.node_count && to < self.node_count {
73            self.adjacency[from][to] = 1;
74        }
75        self
76    }
77}
78
79/// One subgraph-isomorphism match: the data-graph node ids assigned to pattern
80/// nodes `0..len`. `missing_edges` is 0 for an exact (induced-monomorphism)
81/// match and counts unsatisfied pattern edges for an approximate match.
82#[derive(Debug, Clone, Copy, PartialEq)]
83pub struct SubgraphMatch {
84    pub mapping: [u64; MAX_SUBGRAPH_PATTERN_NODES],
85    pub len: u16,
86    pub missing_edges: u8,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq)]
90pub struct BoundedGraphAnalysisSummary {
91    pub density: f32,
92    pub node_count: u16,
93    pub edge_count: u16,
94    pub community_count: u16,
95    pub motif_count: u16,
96    pub top_node_count: u16,
97    pub graph_quin_count: u16,
98}
99
100#[derive(Debug, Clone)]
101struct BoundedQualiaGraph {
102    node_ids: [u64; MAX_BOUNDED_GRAPH_ANALYSIS_NODES],
103    centrality_scores: [f32; MAX_BOUNDED_GRAPH_ANALYSIS_NODES],
104    degrees: [u16; MAX_BOUNDED_GRAPH_ANALYSIS_NODES],
105    adjacency: [[u8; MAX_BOUNDED_GRAPH_ANALYSIS_NODES]; MAX_BOUNDED_GRAPH_ANALYSIS_NODES],
106    node_count: usize,
107    edge_count: usize,
108}
109
110impl BoundedQualiaGraph {
111    fn from_quins(quins: &[NQuin]) -> Result<Self, GraphAnalysisError> {
112        let mut graph = Self {
113            node_ids: [0; MAX_BOUNDED_GRAPH_ANALYSIS_NODES],
114            centrality_scores: [0.0; MAX_BOUNDED_GRAPH_ANALYSIS_NODES],
115            degrees: [0; MAX_BOUNDED_GRAPH_ANALYSIS_NODES],
116            adjacency: [[0; MAX_BOUNDED_GRAPH_ANALYSIS_NODES]; MAX_BOUNDED_GRAPH_ANALYSIS_NODES],
117            node_count: 0,
118            edge_count: 0,
119        };
120
121        for quin in quins {
122            let source = graph.get_or_insert_node(quin.subject)?;
123            let target = graph.get_or_insert_node(quin.object)?;
124            if graph.adjacency[source][target] == 0 {
125                graph.adjacency[source][target] = 1;
126                graph.degrees[source] = graph.degrees[source].saturating_add(1);
127                graph.edge_count += 1;
128            }
129        }
130
131        Ok(graph)
132    }
133
134    fn get_or_insert_node(&mut self, node_id: u64) -> Result<usize, GraphAnalysisError> {
135        for index in 0..self.node_count {
136            if self.node_ids[index] == node_id {
137                return Ok(index);
138            }
139        }
140
141        if self.node_count >= MAX_BOUNDED_GRAPH_ANALYSIS_NODES {
142            return Err(GraphAnalysisError::NodeCapacityExceeded);
143        }
144
145        let index = self.node_count;
146        self.node_ids[index] = node_id;
147        self.node_count += 1;
148        Ok(index)
149    }
150
151    fn calculate_betweenness_centrality(&mut self) {
152        let n = self.node_count;
153        if n < 2 {
154            return;
155        }
156
157        let mut scores = [0f32; MAX_BOUNDED_GRAPH_ANALYSIS_NODES];
158        let mut sigma = [0f32; MAX_BOUNDED_GRAPH_ANALYSIS_NODES];
159        let mut dist = [-1i16; MAX_BOUNDED_GRAPH_ANALYSIS_NODES];
160        let mut delta = [0f32; MAX_BOUNDED_GRAPH_ANALYSIS_NODES];
161        let mut queue = [0usize; MAX_BOUNDED_GRAPH_ANALYSIS_NODES];
162        let mut stack = [0usize; MAX_BOUNDED_GRAPH_ANALYSIS_NODES];
163
164        for source in 0..n {
165            sigma[..n].fill(0.0);
166            dist[..n].fill(-1);
167            delta[..n].fill(0.0);
168
169            let mut queue_head = 0usize;
170            let mut queue_tail = 0usize;
171            let mut stack_len = 0usize;
172
173            sigma[source] = 1.0;
174            dist[source] = 0;
175            queue[queue_tail] = source;
176            queue_tail += 1;
177
178            while queue_head < queue_tail {
179                let v = queue[queue_head];
180                queue_head += 1;
181                stack[stack_len] = v;
182                stack_len += 1;
183
184                for w in 0..n {
185                    if self.adjacency[v][w] == 0 {
186                        continue;
187                    }
188                    if dist[w] < 0 {
189                        dist[w] = dist[v] + 1;
190                        queue[queue_tail] = w;
191                        queue_tail += 1;
192                    }
193                    if dist[w] == dist[v] + 1 {
194                        sigma[w] += sigma[v];
195                    }
196                }
197            }
198
199            while stack_len > 0 {
200                stack_len -= 1;
201                let w = stack[stack_len];
202                for v in 0..n {
203                    if self.adjacency[v][w] == 0 || dist[v] != dist[w] - 1 || sigma[w] == 0.0 {
204                        continue;
205                    }
206                    delta[v] += (sigma[v] / sigma[w]) * (1.0 + delta[w]);
207                }
208                if w != source {
209                    scores[w] += delta[w];
210                }
211            }
212        }
213
214        let norm = if n > 2 {
215            ((n - 1) * (n - 2)) as f32
216        } else {
217            1.0
218        };
219        for (index, score) in scores.iter().take(n).enumerate() {
220            self.centrality_scores[index] = if norm > 0.0 { *score / norm } else { *score };
221        }
222    }
223
224    fn density(&self) -> f32 {
225        if self.node_count < 2 {
226            return 0.0;
227        }
228        let possible_edges = (self.node_count * (self.node_count - 1)) as f32;
229        self.edge_count as f32 / possible_edges
230    }
231
232    fn write_communities(
233        &self,
234        community_nodes_out: &mut [u64],
235        community_spans_out: &mut [CommunitySpan],
236    ) -> Result<usize, GraphAnalysisError> {
237        let mut visited = [false; MAX_BOUNDED_GRAPH_ANALYSIS_NODES];
238        let mut stack = [0usize; MAX_BOUNDED_GRAPH_ANALYSIS_NODES];
239        let mut nodes_written = 0usize;
240        let mut communities_written = 0usize;
241
242        for start in 0..self.node_count {
243            if visited[start] {
244                continue;
245            }
246            if communities_written >= community_spans_out.len() {
247                return Err(GraphAnalysisError::OutputBufferFull);
248            }
249            let span_start = nodes_written;
250            let mut stack_len = 0usize;
251            stack[stack_len] = start;
252            stack_len += 1;
253            visited[start] = true;
254
255            while stack_len > 0 {
256                stack_len -= 1;
257                let node = stack[stack_len];
258                if nodes_written >= community_nodes_out.len() {
259                    return Err(GraphAnalysisError::OutputBufferFull);
260                }
261                community_nodes_out[nodes_written] = self.node_ids[node];
262                nodes_written += 1;
263
264                for neighbor in 0..self.node_count {
265                    let linked =
266                        self.adjacency[node][neighbor] != 0 || self.adjacency[neighbor][node] != 0;
267                    if linked && !visited[neighbor] {
268                        visited[neighbor] = true;
269                        stack[stack_len] = neighbor;
270                        stack_len += 1;
271                    }
272                }
273            }
274
275            community_spans_out[communities_written] = CommunitySpan {
276                start: span_start as u16,
277                len: (nodes_written - span_start) as u16,
278            };
279            communities_written += 1;
280        }
281
282        Ok(communities_written)
283    }
284
285    fn write_motifs(&self, motifs_out: &mut [MotifRecord]) -> Result<usize, GraphAnalysisError> {
286        let mut written = 0usize;
287
288        for a in 0..self.node_count {
289            for b in 0..self.node_count {
290                if self.adjacency[a][b] == 0 {
291                    continue;
292                }
293                for c in 0..self.node_count {
294                    if c == a || self.adjacency[b][c] == 0 || self.adjacency[c][a] == 0 {
295                        continue;
296                    }
297
298                    let mut canonical = [self.node_ids[a], self.node_ids[b], self.node_ids[c]];
299                    canonical.sort_unstable();
300                    if motifs_out[..written].iter().any(|m| {
301                        let mut existing = [m.node_a, m.node_b, m.node_c];
302                        existing.sort_unstable();
303                        existing == canonical
304                    }) {
305                        continue;
306                    }
307
308                    if written >= motifs_out.len() {
309                        return Err(GraphAnalysisError::OutputBufferFull);
310                    }
311                    motifs_out[written] = MotifRecord {
312                        pattern: MotifPattern::Triangle,
313                        node_a: self.node_ids[a],
314                        node_b: self.node_ids[b],
315                        node_c: self.node_ids[c],
316                        frequency: 1.0,
317                    };
318                    written += 1;
319                }
320            }
321        }
322
323        Ok(written)
324    }
325
326    fn write_top_nodes(&self, out: &mut [TopNodeScore]) -> usize {
327        let count = out.len().min(self.node_count);
328        if count == 0 {
329            return 0;
330        }
331
332        let mut scratch = [TopNodeScore {
333            node_id: 0,
334            centrality_score: f32::MIN,
335        }; MAX_BOUNDED_GRAPH_ANALYSIS_NODES];
336        for i in 0..self.node_count {
337            scratch[i] = TopNodeScore {
338                node_id: self.node_ids[i],
339                centrality_score: self.centrality_scores[i],
340            };
341        }
342        scratch[..self.node_count].sort_by(|a, b| {
343            b.centrality_score
344                .partial_cmp(&a.centrality_score)
345                .unwrap_or(std::cmp::Ordering::Equal)
346        });
347        out[..count].copy_from_slice(&scratch[..count]);
348        count
349    }
350
351    fn write_graph_quins(
352        &self,
353        context: u64,
354        out: &mut [NQuin],
355    ) -> Result<usize, GraphAnalysisError> {
356        if out.len() < self.node_count {
357            return Err(GraphAnalysisError::OutputBufferFull);
358        }
359
360        for i in 0..self.node_count {
361            let mut quin = NQuin {
362                subject: self.node_ids[i],
363                predicate: crate::q_hash("has_centrality_score"),
364                object: (self.centrality_scores[i] as f64 * 1000.0) as u64,
365                context,
366                metadata: self.degrees[i] as u64,
367                parity: 0,
368            };
369            quin.parity = quin.subject ^ quin.predicate ^ quin.object ^ quin.context;
370            out[i] = quin;
371        }
372
373        Ok(self.node_count)
374    }
375
376    /// Zero-heap PageRank via power iteration over the bounded adjacency matrix.
377    ///
378    /// Google PageRank: `PR(v) = (1-d)/N + d·Σ_{u→v} PR(u)/outdeg(u)`, with the
379    /// mass held by dangling (out-degree-0) nodes redistributed uniformly so the
380    /// vector stays a probability distribution. Iterates until the L1 delta drops
381    /// below `tolerance` or `max_iters` is reached. Writes `min(out.len(), n)`
382    /// scores aligned to `self.node_ids` order and returns that count.
383    fn calculate_pagerank(
384        &self,
385        damping: f32,
386        max_iters: u32,
387        tolerance: f32,
388        out: &mut [f32],
389    ) -> usize {
390        let n = self.node_count;
391        if n == 0 {
392            return 0;
393        }
394        let count = out.len().min(n);
395
396        let mut rank = [0f32; MAX_BOUNDED_GRAPH_ANALYSIS_NODES];
397        let mut next = [0f32; MAX_BOUNDED_GRAPH_ANALYSIS_NODES];
398        let mut outdeg = [0u16; MAX_BOUNDED_GRAPH_ANALYSIS_NODES];
399
400        let init = 1.0 / n as f32;
401        rank[..n].fill(init);
402        for v in 0..n {
403            let mut d = 0u16;
404            for w in 0..n {
405                if self.adjacency[v][w] != 0 {
406                    d += 1;
407                }
408            }
409            outdeg[v] = d;
410        }
411
412        let base = (1.0 - damping) / n as f32;
413        for _ in 0..max_iters {
414            let mut dangling = 0f32;
415            for v in 0..n {
416                if outdeg[v] == 0 {
417                    dangling += rank[v];
418                }
419            }
420            let dangling_share = damping * dangling / n as f32;
421            next[..n].fill(base + dangling_share);
422
423            for v in 0..n {
424                if outdeg[v] == 0 {
425                    continue;
426                }
427                let share = damping * rank[v] / outdeg[v] as f32;
428                for w in 0..n {
429                    if self.adjacency[v][w] != 0 {
430                        next[w] += share;
431                    }
432                }
433            }
434
435            let mut delta = 0f32;
436            for v in 0..n {
437                delta += (next[v] - rank[v]).abs();
438                rank[v] = next[v];
439            }
440            if delta < tolerance {
441                break;
442            }
443        }
444
445        out[..count].copy_from_slice(&rank[..count]);
446        count
447    }
448
449    /// Exact / approximate subgraph isomorphism by bounded backtracking search.
450    ///
451    /// Finds injective mappings of the query `pattern` onto the data graph such
452    /// that every required pattern edge maps to a data edge (a directed
453    /// monomorphism). With `max_missing_edges > 0` the search also reports
454    /// approximate matches that miss up to that many required edges (recorded in
455    /// `SubgraphMatch::missing_edges`). Recursion depth is bounded by the pattern
456    /// size, so the call uses only fixed-size stack frames — no heap.
457    fn find_subgraph_isomorphisms(
458        &self,
459        pattern: &SubgraphPattern,
460        max_missing_edges: u8,
461        out: &mut [SubgraphMatch],
462    ) -> usize {
463        if pattern.node_count == 0
464            || pattern.node_count > MAX_SUBGRAPH_PATTERN_NODES
465            || pattern.node_count > self.node_count
466            || out.is_empty()
467        {
468            return 0;
469        }
470        let mut assign = [0usize; MAX_SUBGRAPH_PATTERN_NODES];
471        let mut used = [false; MAX_BOUNDED_GRAPH_ANALYSIS_NODES];
472        let mut written = 0usize;
473        self.match_subgraph(
474            pattern,
475            0,
476            0,
477            max_missing_edges,
478            &mut assign,
479            &mut used,
480            out,
481            &mut written,
482        );
483        written
484    }
485
486    #[allow(clippy::too_many_arguments)]
487    fn match_subgraph(
488        &self,
489        pattern: &SubgraphPattern,
490        depth: usize,
491        missing: u8,
492        max_missing_edges: u8,
493        assign: &mut [usize; MAX_SUBGRAPH_PATTERN_NODES],
494        used: &mut [bool; MAX_BOUNDED_GRAPH_ANALYSIS_NODES],
495        out: &mut [SubgraphMatch],
496        written: &mut usize,
497    ) {
498        if *written >= out.len() {
499            return;
500        }
501        if depth == pattern.node_count {
502            let mut m = SubgraphMatch {
503                mapping: [0; MAX_SUBGRAPH_PATTERN_NODES],
504                len: pattern.node_count as u16,
505                missing_edges: missing,
506            };
507            for (p, slot) in m.mapping.iter_mut().take(pattern.node_count).enumerate() {
508                *slot = self.node_ids[assign[p]];
509            }
510            out[*written] = m;
511            *written += 1;
512            return;
513        }
514
515        for candidate in 0..self.node_count {
516            if used[candidate] {
517                continue;
518            }
519            // Edge-consistency against already-assigned pattern nodes.
520            let mut local_missing = 0u8;
521            for prev in 0..depth {
522                let pd = assign[prev];
523                if pattern.adjacency[prev][depth] != 0 && self.adjacency[pd][candidate] == 0 {
524                    local_missing += 1;
525                }
526                if pattern.adjacency[depth][prev] != 0 && self.adjacency[candidate][pd] == 0 {
527                    local_missing += 1;
528                }
529            }
530            if missing + local_missing > max_missing_edges {
531                continue;
532            }
533            assign[depth] = candidate;
534            used[candidate] = true;
535            self.match_subgraph(
536                pattern,
537                depth + 1,
538                missing + local_missing,
539                max_missing_edges,
540                assign,
541                used,
542                out,
543                written,
544            );
545            used[candidate] = false;
546            if *written >= out.len() {
547                return;
548            }
549        }
550    }
551}
552
553/// Zero-heap topology analysis aligned with the 10D tensor hot-path constraints.
554pub fn analyze_graph_topology_bounded(
555    quins: &[NQuin],
556    context: u64,
557    graph_quins_out: &mut [NQuin],
558    community_nodes_out: &mut [u64],
559    community_spans_out: &mut [CommunitySpan],
560    motifs_out: &mut [MotifRecord],
561    top_nodes_out: &mut [TopNodeScore],
562) -> Result<BoundedGraphAnalysisSummary, GraphAnalysisError> {
563    if quins.len() > MAX_HEAP_GRAPH_ANALYSIS_QUINS {
564        return Err(GraphAnalysisError::InputTooLarge);
565    }
566
567    let mut graph = BoundedQualiaGraph::from_quins(quins)?;
568    graph.calculate_betweenness_centrality();
569
570    let graph_quin_count = graph.write_graph_quins(context, graph_quins_out)?;
571    let community_count = graph.write_communities(community_nodes_out, community_spans_out)?;
572    let motif_count = graph.write_motifs(motifs_out)?;
573    let top_node_count = graph.write_top_nodes(top_nodes_out);
574
575    Ok(BoundedGraphAnalysisSummary {
576        density: graph.density(),
577        node_count: graph.node_count as u16,
578        edge_count: graph.edge_count as u16,
579        community_count: community_count as u16,
580        motif_count: motif_count as u16,
581        top_node_count: top_node_count as u16,
582        graph_quin_count: graph_quin_count as u16,
583    })
584}
585
586/// Zero-heap PageRank over an NQuin relation set (bounded path).
587///
588/// Builds the bounded adjacency from `quins`, runs power iteration with the given
589/// `damping` (typically 0.85), and writes node scores into `scores_out` aligned
590/// with first-seen subject/object order. Returns the number of scores written.
591/// `max_iters`/`tolerance` bound the iteration (e.g. 100 / 1e-6).
592pub fn pagerank_bounded(
593    quins: &[NQuin],
594    damping: f32,
595    max_iters: u32,
596    tolerance: f32,
597    node_ids_out: &mut [u64],
598    scores_out: &mut [f32],
599) -> Result<usize, GraphAnalysisError> {
600    if quins.len() > MAX_HEAP_GRAPH_ANALYSIS_QUINS {
601        return Err(GraphAnalysisError::InputTooLarge);
602    }
603    let graph = BoundedQualiaGraph::from_quins(quins)?;
604    let count = graph.calculate_pagerank(damping, max_iters, tolerance, scores_out);
605    if node_ids_out.len() < count {
606        return Err(GraphAnalysisError::OutputBufferFull);
607    }
608    node_ids_out[..count].copy_from_slice(&graph.node_ids[..count]);
609    Ok(count)
610}
611
612/// Zero-heap exact/approximate subgraph isomorphism over an NQuin relation set.
613///
614/// `max_missing_edges == 0` requires an exact directed monomorphism; a positive
615/// value also returns approximate matches missing up to that many pattern edges.
616/// Matches are written into `matches_out` (search stops when it fills). Returns
617/// the number of matches found.
618pub fn find_subgraph_isomorphisms_bounded(
619    quins: &[NQuin],
620    pattern: &SubgraphPattern,
621    max_missing_edges: u8,
622    matches_out: &mut [SubgraphMatch],
623) -> Result<usize, GraphAnalysisError> {
624    if quins.len() > MAX_HEAP_GRAPH_ANALYSIS_QUINS {
625        return Err(GraphAnalysisError::InputTooLarge);
626    }
627    let graph = BoundedQualiaGraph::from_quins(quins)?;
628    Ok(graph.find_subgraph_isomorphisms(pattern, max_missing_edges, matches_out))
629}
630
631/// Graph structure built from NQuin relations
632#[derive(Debug, Clone)]
633pub struct QualiaGraph {
634    pub nodes: HashMap<u64, GraphNode>,
635    pub edges: HashMap<(u64, u64), GraphEdge>,
636    pub adjacency_list: HashMap<u64, Vec<u64>>,
637}
638
639#[derive(Debug, Clone)]
640pub struct GraphNode {
641    pub id: u64,
642    pub degree: usize,
643    pub centrality_score: f64,
644    pub community_id: Option<usize>,
645}
646
647#[derive(Debug, Clone)]
648pub struct GraphEdge {
649    pub source: u64,
650    pub target: u64,
651    pub weight: f64,
652}
653
654impl QualiaGraph {
655    /// Create a new graph from NQuin relations
656    pub fn from_quins(quins: &[NQuin]) -> Self {
657        let mut nodes = HashMap::new();
658        let mut edges = HashMap::new();
659        let mut adjacency_list = HashMap::new();
660
661        // Build graph structure
662        for quin in quins {
663            // Add source node
664            nodes.entry(quin.subject).or_insert_with(|| GraphNode {
665                id: quin.subject,
666                degree: 0,
667                centrality_score: 0.0,
668                community_id: None,
669            });
670
671            // Add target node
672            nodes.entry(quin.object).or_insert_with(|| GraphNode {
673                id: quin.object,
674                degree: 0,
675                centrality_score: 0.0,
676                community_id: None,
677            });
678
679            // Add edge
680            let edge = GraphEdge {
681                source: quin.subject,
682                target: quin.object,
683                weight: 1.0, // Default weight
684            };
685            edges.insert((quin.subject, quin.object), edge);
686
687            // Update adjacency list
688            adjacency_list
689                .entry(quin.subject)
690                .or_insert_with(Vec::new)
691                .push(quin.object);
692
693            // Update degrees
694            if let Some(node) = nodes.get_mut(&quin.subject) {
695                node.degree += 1;
696            }
697        }
698
699        Self {
700            nodes,
701            edges,
702            adjacency_list,
703        }
704    }
705
706    /// Calculate betweenness centrality for all nodes
707    pub fn calculate_betweenness_centrality(&mut self) {
708        // Brandes' algorithm for betweenness centrality (directed graph)
709        // sigma[v] = number of shortest paths from source to v
710        // dist[v]  = BFS distance from source to v (-1 = unvisited)
711        // delta[v] = dependency of source on v
712        let node_ids: Vec<u64> = self.nodes.keys().cloned().collect();
713        let mut scores: HashMap<u64, f64> = node_ids.iter().map(|&id| (id, 0.0)).collect();
714
715        for &source in &node_ids {
716            let mut sigma: HashMap<u64, f64> = node_ids.iter().map(|&id| (id, 0.0)).collect();
717            let mut dist: HashMap<u64, i64> = node_ids.iter().map(|&id| (id, -1)).collect();
718            // predecessors[v] = list of nodes w on a shortest path to v
719            let mut pred: HashMap<u64, Vec<u64>> =
720                node_ids.iter().map(|&id| (id, Vec::new())).collect();
721            let mut stack: Vec<u64> = Vec::new();
722            // FIFO queue for BFS
723            let mut queue: std::collections::VecDeque<u64> = std::collections::VecDeque::new();
724
725            sigma.insert(source, 1.0);
726            dist.insert(source, 0);
727            queue.push_back(source);
728
729            // BFS phase
730            while let Some(v) = queue.pop_front() {
731                stack.push(v);
732                let v_dist = dist[&v];
733                if let Some(neighbors) = self.adjacency_list.get(&v) {
734                    for &w in neighbors {
735                        // First time visiting w?
736                        if dist[&w] < 0 {
737                            queue.push_back(w);
738                            dist.insert(w, v_dist + 1);
739                        }
740                        // Is v on a shortest path to w?
741                        if dist[&w] == v_dist + 1 {
742                            *sigma.get_mut(&w).unwrap() += sigma[&v];
743                            pred.get_mut(&w).unwrap().push(v);
744                        }
745                    }
746                }
747            }
748
749            // Accumulation phase (back-propagation)
750            let mut delta: HashMap<u64, f64> = node_ids.iter().map(|&id| (id, 0.0)).collect();
751            while let Some(w) = stack.pop() {
752                for &v in &pred[&w] {
753                    let coeff = (sigma[&v] / sigma[&w]) * (1.0 + delta[&w]);
754                    *delta.get_mut(&v).unwrap() += coeff;
755                }
756                if w != source {
757                    *scores.get_mut(&w).unwrap() += delta[&w];
758                }
759            }
760        }
761
762        // Normalize scores (directed graph: divide by (n-1)(n-2))
763        let n = self.nodes.len() as f64;
764        if n > 2.0 {
765            let norm = (n - 1.0) * (n - 2.0);
766            for score in scores.values_mut() {
767                *score /= norm;
768            }
769        }
770
771        // Update node centrality scores
772        for (node_id, score) in scores {
773            if let Some(node) = self.nodes.get_mut(&node_id) {
774                node.centrality_score = score;
775            }
776        }
777    }
778
779    /// Detect communities using simple modularity optimization
780    pub fn detect_communities(&mut self) -> Vec<Vec<u64>> {
781        let mut communities: Vec<Vec<u64>> =
782            self.nodes.keys().cloned().map(|id| vec![id]).collect();
783        let mut improved = true;
784
785        while improved {
786            improved = false;
787
788            for i in 0..communities.len() {
789                if i >= communities.len() {
790                    break;
791                }
792
793                let current_community = communities[i].clone();
794                let best_move = self.find_best_community_move(&current_community, &communities, i);
795
796                if let Some((target_community, modularity_gain)) = best_move {
797                    if modularity_gain > 0.0 {
798                        // Move nodes to target community
799                        communities[target_community].extend(&current_community);
800                        communities.remove(i);
801                        improved = true;
802                        break;
803                    }
804                }
805            }
806        }
807
808        // Update community IDs in nodes
809        for (community_id, community) in communities.iter().enumerate() {
810            for &node_id in community {
811                if let Some(node) = self.nodes.get_mut(&node_id) {
812                    node.community_id = Some(community_id);
813                }
814            }
815        }
816
817        communities
818    }
819
820    /// Find best community move for modularity optimization
821    fn find_best_community_move(
822        &self,
823        community: &[u64],
824        all_communities: &[Vec<u64>],
825        current_index: usize,
826    ) -> Option<(usize, f64)> {
827        let mut best_move = None;
828        let mut best_gain = 0.0;
829
830        for (i, other_community) in all_communities.iter().enumerate() {
831            if i == current_index {
832                continue;
833            }
834
835            let gain = self.calculate_modularity_gain(community, other_community);
836            if gain > best_gain {
837                best_gain = gain;
838                best_move = Some((i, gain));
839            }
840        }
841
842        best_move
843    }
844
845    /// Calculate modularity gain for merging two communities (Louvain delta-Q).
846    ///
847    /// ΔQ = e_ij/m  −  (a_i × a_j) / (2m²)
848    /// where e_ij = edges crossing between comm1 and comm2,
849    ///       a_x  = sum of degrees of nodes in community x,
850    ///       m    = total number of edges in the graph.
851    fn calculate_modularity_gain(&self, comm1: &[u64], comm2: &[u64]) -> f64 {
852        let m = self.edges.len() as f64;
853        if m == 0.0 {
854            return 0.0;
855        }
856
857        let set1: HashSet<u64> = comm1.iter().cloned().collect();
858        let set2: HashSet<u64> = comm2.iter().cloned().collect();
859
860        // Count edges crossing from comm1 to comm2 or comm2 to comm1
861        let mut e_ij = 0.0;
862        for &(source, target) in self.edges.keys() {
863            if (set1.contains(&source) && set2.contains(&target))
864                || (set2.contains(&source) && set1.contains(&target))
865            {
866                e_ij += 1.0;
867            }
868        }
869
870        // Degree sums
871        let a1: f64 = comm1
872            .iter()
873            .filter_map(|id| self.nodes.get(id))
874            .map(|n| n.degree as f64)
875            .sum();
876        let a2: f64 = comm2
877            .iter()
878            .filter_map(|id| self.nodes.get(id))
879            .map(|n| n.degree as f64)
880            .sum();
881
882        (e_ij / m) - (a1 * a2) / (2.0 * m * m)
883    }
884
885    /// Find common motifs (3-node patterns)
886    pub fn find_motifs(&self) -> Vec<Motif> {
887        // Deduplicate triangles by canonical sorted triple so that each
888        // undirected triangle (regardless of orientation in the directed graph)
889        // is reported exactly once.
890        let mut seen: HashSet<[u64; 3]> = HashSet::new();
891        let mut motifs = Vec::new();
892
893        for &node_a in self.nodes.keys() {
894            if let Some(neighbors_a) = self.adjacency_list.get(&node_a) {
895                for &node_b in neighbors_a {
896                    if let Some(neighbors_b) = self.adjacency_list.get(&node_b) {
897                        for &node_c in neighbors_b {
898                            if node_c != node_a {
899                                // Check if this forms a triangle motif (directed cycle a→b→c→a)
900                                if let Some(neighbors_c) = self.adjacency_list.get(&node_c) {
901                                    if neighbors_c.contains(&node_a) {
902                                        // Build canonical key: sorted triple
903                                        let mut key = [node_a, node_b, node_c];
904                                        key.sort_unstable();
905                                        if seen.insert(key) {
906                                            motifs.push(Motif {
907                                                pattern: MotifPattern::Triangle,
908                                                nodes: vec![node_a, node_b, node_c],
909                                                frequency: 1.0,
910                                            });
911                                        }
912                                    }
913                                }
914                            }
915                        }
916                    }
917                }
918            }
919        }
920
921        motifs
922    }
923
924    /// Get top nodes by centrality score
925    pub fn get_top_central_nodes(&self, top_n: usize) -> Vec<(u64, f64)> {
926        let mut nodes: Vec<(u64, f64)> = self
927            .nodes
928            .iter()
929            .map(|(id, node)| (*id, node.centrality_score))
930            .collect();
931
932        nodes.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
933        nodes.truncate(top_n);
934        nodes
935    }
936
937    /// Calculate graph density
938    pub fn density(&self) -> f64 {
939        let n = self.nodes.len();
940        if n < 2 {
941            return 0.0;
942        }
943
944        let possible_edges = n * (n - 1);
945        self.edges.len() as f64 / possible_edges as f64
946    }
947
948    /// Convert graph state to NQuin for storage
949    pub fn to_quins(&self, context: u64) -> Vec<NQuin> {
950        let mut quins = Vec::new();
951
952        // Store node centrality scores
953        for (node_id, node) in &self.nodes {
954            let mut quin = NQuin {
955                subject: *node_id,
956                predicate: crate::q_hash("has_centrality_score"),
957                object: (node.centrality_score * 1000.0) as u64, // Store as scaled integer
958                context,
959                metadata: 0,
960                parity: 0,
961            };
962
963            // Store degree in metadata
964            quin.metadata = node.degree as u64;
965            quin.parity = quin.subject ^ quin.predicate ^ quin.object ^ quin.context;
966            quins.push(quin);
967        }
968
969        quins
970    }
971}
972
973/// Motif pattern types
974#[derive(Debug, Clone, Copy, PartialEq, Eq)]
975pub enum MotifPattern {
976    Triangle,
977    Chain,
978    Star,
979    Fork,
980}
981
982/// Graph motif representation
983#[derive(Debug, Clone)]
984pub struct Motif {
985    pub pattern: MotifPattern,
986    pub nodes: Vec<u64>,
987    pub frequency: f64,
988}
989
990/// Analyze graph topology in a bounded, heap-backed batch.
991pub fn analyze_graph_topology(
992    quins: &[NQuin],
993    context: u64,
994) -> Result<GraphAnalysisResult, GraphAnalysisError> {
995    if quins.len() > MAX_HEAP_GRAPH_ANALYSIS_QUINS {
996        return Err(GraphAnalysisError::InputTooLarge);
997    }
998
999    let mut graph = QualiaGraph::from_quins(quins);
1000
1001    // Calculate centrality
1002    graph.calculate_betweenness_centrality();
1003
1004    // Detect communities
1005    let communities = graph.detect_communities();
1006
1007    // Find motifs
1008    let motifs = graph.find_motifs();
1009
1010    // Get top central nodes
1011    let top_nodes = graph.get_top_central_nodes(10);
1012
1013    // Calculate density
1014    let density = graph.density();
1015
1016    // Convert to quins for storage
1017    let graph_quins = graph.to_quins(context);
1018
1019    Ok(GraphAnalysisResult {
1020        graph_quins,
1021        communities,
1022        motifs,
1023        top_nodes,
1024        density,
1025        node_count: graph.nodes.len(),
1026        edge_count: graph.edges.len(),
1027    })
1028}
1029
1030/// Result of graph analysis
1031#[derive(Debug, Clone)]
1032pub struct GraphAnalysisResult {
1033    pub graph_quins: Vec<NQuin>,
1034    pub communities: Vec<Vec<u64>>,
1035    pub motifs: Vec<Motif>,
1036    pub top_nodes: Vec<(u64, f64)>,
1037    pub density: f64,
1038    pub node_count: usize,
1039    pub edge_count: usize,
1040}
1041
1042#[cfg(test)]
1043mod tests {
1044    use super::*;
1045
1046    #[test]
1047    fn test_graph_creation() {
1048        let quins = vec![
1049            NQuin {
1050                subject: 1,
1051                predicate: crate::q_hash("connects_to"),
1052                object: 2,
1053                context: 100,
1054                metadata: 0,
1055                parity: 0,
1056            },
1057            NQuin {
1058                subject: 2,
1059                predicate: crate::q_hash("connects_to"),
1060                object: 3,
1061                context: 100,
1062                metadata: 0,
1063                parity: 0,
1064            },
1065            NQuin {
1066                subject: 1,
1067                predicate: crate::q_hash("connects_to"),
1068                object: 3,
1069                context: 100,
1070                metadata: 0,
1071                parity: 0,
1072            },
1073        ];
1074
1075        let graph = QualiaGraph::from_quins(&quins);
1076
1077        assert_eq!(graph.nodes.len(), 3);
1078        assert_eq!(graph.edges.len(), 3);
1079        assert_eq!(graph.adjacency_list.get(&1).unwrap().len(), 2);
1080    }
1081
1082    #[test]
1083    fn test_centrality_calculation() {
1084        let quins = vec![
1085            NQuin {
1086                subject: 1,
1087                predicate: 1,
1088                object: 2,
1089                context: 100,
1090                metadata: 0,
1091                parity: 0,
1092            },
1093            NQuin {
1094                subject: 2,
1095                predicate: 1,
1096                object: 3,
1097                context: 100,
1098                metadata: 0,
1099                parity: 0,
1100            },
1101        ];
1102
1103        let mut graph = QualiaGraph::from_quins(&quins);
1104        graph.calculate_betweenness_centrality();
1105
1106        // Node 2 should have highest betweenness because it is the only bridge from 1 to 3.
1107        let node2_centrality = graph.nodes.get(&2).unwrap().centrality_score;
1108        let node1_centrality = graph.nodes.get(&1).unwrap().centrality_score;
1109
1110        assert!(node2_centrality > node1_centrality);
1111    }
1112
1113    #[test]
1114    fn test_community_detection() {
1115        let quins = vec![
1116            NQuin {
1117                subject: 1,
1118                predicate: 1,
1119                object: 2,
1120                context: 100,
1121                metadata: 0,
1122                parity: 0,
1123            },
1124            NQuin {
1125                subject: 2,
1126                predicate: 1,
1127                object: 1,
1128                context: 100,
1129                metadata: 0,
1130                parity: 0,
1131            },
1132            NQuin {
1133                subject: 3,
1134                predicate: 1,
1135                object: 4,
1136                context: 100,
1137                metadata: 0,
1138                parity: 0,
1139            },
1140            NQuin {
1141                subject: 4,
1142                predicate: 1,
1143                object: 3,
1144                context: 100,
1145                metadata: 0,
1146                parity: 0,
1147            },
1148        ];
1149
1150        let mut graph = QualiaGraph::from_quins(&quins);
1151        let communities = graph.detect_communities();
1152
1153        // Should detect two separate communities
1154        assert_eq!(communities.len(), 2);
1155    }
1156
1157    #[test]
1158    fn test_motif_detection() {
1159        let quins = vec![
1160            NQuin {
1161                subject: 1,
1162                predicate: 1,
1163                object: 2,
1164                context: 100,
1165                metadata: 0,
1166                parity: 0,
1167            },
1168            NQuin {
1169                subject: 2,
1170                predicate: 1,
1171                object: 3,
1172                context: 100,
1173                metadata: 0,
1174                parity: 0,
1175            },
1176            NQuin {
1177                subject: 3,
1178                predicate: 1,
1179                object: 1,
1180                context: 100,
1181                metadata: 0,
1182                parity: 0,
1183            },
1184        ];
1185
1186        let graph = QualiaGraph::from_quins(&quins);
1187        let motifs = graph.find_motifs();
1188
1189        // Should detect one triangle motif
1190        assert_eq!(motifs.len(), 1);
1191        assert_eq!(motifs[0].pattern, MotifPattern::Triangle);
1192    }
1193
1194    #[test]
1195    fn test_graph_analysis() {
1196        let quins = vec![
1197            NQuin {
1198                subject: 1,
1199                predicate: 1,
1200                object: 2,
1201                context: 100,
1202                metadata: 0,
1203                parity: 0,
1204            },
1205            NQuin {
1206                subject: 2,
1207                predicate: 1,
1208                object: 3,
1209                context: 100,
1210                metadata: 0,
1211                parity: 0,
1212            },
1213            NQuin {
1214                subject: 3,
1215                predicate: 1,
1216                object: 1,
1217                context: 100,
1218                metadata: 0,
1219                parity: 0,
1220            },
1221        ];
1222
1223        let result = analyze_graph_topology(&quins, 100).unwrap();
1224
1225        assert_eq!(result.node_count, 3);
1226        assert_eq!(result.edge_count, 3);
1227        assert!(result.density > 0.0);
1228        assert!(!result.communities.is_empty());
1229    }
1230
1231    #[test]
1232    fn test_graph_analysis_rejects_oversized_batches() {
1233        let quins = vec![
1234            NQuin {
1235                subject: 1,
1236                predicate: 1,
1237                object: 2,
1238                context: 100,
1239                metadata: 0,
1240                parity: 0,
1241            };
1242            MAX_HEAP_GRAPH_ANALYSIS_QUINS + 1
1243        ];
1244
1245        let result = analyze_graph_topology(&quins, 100);
1246
1247        assert!(matches!(result, Err(GraphAnalysisError::InputTooLarge)));
1248    }
1249
1250    #[test]
1251    fn test_bounded_graph_analysis_zero_heap_path() {
1252        let quins = vec![
1253            NQuin {
1254                subject: 1,
1255                predicate: 1,
1256                object: 2,
1257                context: 100,
1258                metadata: 0,
1259                parity: 0,
1260            },
1261            NQuin {
1262                subject: 2,
1263                predicate: 1,
1264                object: 3,
1265                context: 100,
1266                metadata: 0,
1267                parity: 0,
1268            },
1269            NQuin {
1270                subject: 3,
1271                predicate: 1,
1272                object: 1,
1273                context: 100,
1274                metadata: 0,
1275                parity: 0,
1276            },
1277        ];
1278        let mut graph_quins = [NQuin::default(); 16];
1279        let mut community_nodes = [0u64; 16];
1280        let mut community_spans = [CommunitySpan { start: 0, len: 0 }; 8];
1281        let mut motifs = [MotifRecord {
1282            pattern: MotifPattern::Triangle,
1283            node_a: 0,
1284            node_b: 0,
1285            node_c: 0,
1286            frequency: 0.0,
1287        }; 8];
1288        let mut top_nodes = [TopNodeScore {
1289            node_id: 0,
1290            centrality_score: 0.0,
1291        }; 8];
1292
1293        let summary = analyze_graph_topology_bounded(
1294            &quins,
1295            100,
1296            &mut graph_quins,
1297            &mut community_nodes,
1298            &mut community_spans,
1299            &mut motifs,
1300            &mut top_nodes,
1301        )
1302        .unwrap();
1303
1304        assert_eq!(summary.node_count, 3);
1305        assert_eq!(summary.edge_count, 3);
1306        assert_eq!(summary.community_count, 1);
1307        assert_eq!(summary.motif_count, 1);
1308        assert!(summary.top_node_count >= 1);
1309        assert!(summary.density > 0.0);
1310    }
1311
1312    #[test]
1313    fn test_pagerank_sums_to_one_and_ranks_hub() {
1314        // Star: 1→3, 2→3, 4→3 (node 3 is the sink hub) plus 3→1 to avoid a pure dangling.
1315        let quins = vec![
1316            NQuin {
1317                subject: 1,
1318                predicate: 1,
1319                object: 3,
1320                context: 0,
1321                metadata: 0,
1322                parity: 0,
1323            },
1324            NQuin {
1325                subject: 2,
1326                predicate: 1,
1327                object: 3,
1328                context: 0,
1329                metadata: 0,
1330                parity: 0,
1331            },
1332            NQuin {
1333                subject: 4,
1334                predicate: 1,
1335                object: 3,
1336                context: 0,
1337                metadata: 0,
1338                parity: 0,
1339            },
1340            NQuin {
1341                subject: 3,
1342                predicate: 1,
1343                object: 1,
1344                context: 0,
1345                metadata: 0,
1346                parity: 0,
1347            },
1348        ];
1349        let mut ids = [0u64; 16];
1350        let mut scores = [0f32; 16];
1351        let count = pagerank_bounded(&quins, 0.85, 100, 1e-6, &mut ids, &mut scores).unwrap();
1352        assert_eq!(count, 4);
1353
1354        // Probability distribution: scores sum to ~1.
1355        let total: f32 = scores[..count].iter().sum();
1356        assert!(
1357            (total - 1.0).abs() < 1e-3,
1358            "pagerank should sum to 1, got {total}"
1359        );
1360
1361        // Node 3 (the hub everyone points at) must rank highest.
1362        let hub_pos = ids[..count].iter().position(|&id| id == 3).unwrap();
1363        let hub_score = scores[hub_pos];
1364        for i in 0..count {
1365            if i != hub_pos {
1366                assert!(
1367                    hub_score > scores[i],
1368                    "hub node 3 should outrank node {}",
1369                    ids[i]
1370                );
1371            }
1372        }
1373    }
1374
1375    #[test]
1376    fn test_subgraph_isomorphism_exact_triangle() {
1377        // Data graph contains a directed triangle 1→2→3→1 plus an extra edge.
1378        let quins = vec![
1379            NQuin {
1380                subject: 1,
1381                predicate: 1,
1382                object: 2,
1383                context: 0,
1384                metadata: 0,
1385                parity: 0,
1386            },
1387            NQuin {
1388                subject: 2,
1389                predicate: 1,
1390                object: 3,
1391                context: 0,
1392                metadata: 0,
1393                parity: 0,
1394            },
1395            NQuin {
1396                subject: 3,
1397                predicate: 1,
1398                object: 1,
1399                context: 0,
1400                metadata: 0,
1401                parity: 0,
1402            },
1403            NQuin {
1404                subject: 3,
1405                predicate: 1,
1406                object: 4,
1407                context: 0,
1408                metadata: 0,
1409                parity: 0,
1410            },
1411        ];
1412        // Pattern: directed 3-cycle a→b→c→a.
1413        let pattern = SubgraphPattern::new(3)
1414            .with_edge(0, 1)
1415            .with_edge(1, 2)
1416            .with_edge(2, 0);
1417        let mut matches = [SubgraphMatch {
1418            mapping: [0; MAX_SUBGRAPH_PATTERN_NODES],
1419            len: 0,
1420            missing_edges: 0,
1421        }; 16];
1422        let n = find_subgraph_isomorphisms_bounded(&quins, &pattern, 0, &mut matches).unwrap();
1423
1424        // The directed 3-cycle has exactly 3 rotations of the same triangle.
1425        assert_eq!(n, 3, "expected 3 rotations of the directed triangle");
1426        for m in &matches[..n] {
1427            assert_eq!(m.missing_edges, 0);
1428            assert_eq!(m.len, 3);
1429            // every matched node id is one of {1,2,3}, never the extra node 4
1430            for &id in &m.mapping[..3] {
1431                assert!(
1432                    id == 1 || id == 2 || id == 3,
1433                    "match included non-triangle node {id}"
1434                );
1435            }
1436        }
1437    }
1438
1439    #[test]
1440    fn test_subgraph_isomorphism_approximate_allows_missing_edge() {
1441        // Data graph has a path 1→2→3 but NOT the closing edge 3→1.
1442        let quins = vec![
1443            NQuin {
1444                subject: 1,
1445                predicate: 1,
1446                object: 2,
1447                context: 0,
1448                metadata: 0,
1449                parity: 0,
1450            },
1451            NQuin {
1452                subject: 2,
1453                predicate: 1,
1454                object: 3,
1455                context: 0,
1456                metadata: 0,
1457                parity: 0,
1458            },
1459        ];
1460        let triangle = SubgraphPattern::new(3)
1461            .with_edge(0, 1)
1462            .with_edge(1, 2)
1463            .with_edge(2, 0);
1464        let mut matches = [SubgraphMatch {
1465            mapping: [0; MAX_SUBGRAPH_PATTERN_NODES],
1466            len: 0,
1467            missing_edges: 0,
1468        }; 16];
1469
1470        // Exact: no triangle exists.
1471        let exact = find_subgraph_isomorphisms_bounded(&quins, &triangle, 0, &mut matches).unwrap();
1472        assert_eq!(exact, 0);
1473
1474        // Approximate (allow 1 missing edge): the open path 1→2→3 matches with the
1475        // 3→1 edge absent.
1476        let approx =
1477            find_subgraph_isomorphisms_bounded(&quins, &triangle, 1, &mut matches).unwrap();
1478        assert!(approx >= 1, "approximate match should find the open path");
1479        assert!(matches[..approx].iter().any(|m| {
1480            m.missing_edges == 1 && m.mapping[0] == 1 && m.mapping[1] == 2 && m.mapping[2] == 3
1481        }));
1482    }
1483}