Skip to main content

vibe_graph_core/
lib.rs

1//! Core domain types shared across the entire Vibe-Graph workspace.
2
3use petgraph::stable_graph::{NodeIndex, StableDiGraph};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::time::{Duration, SystemTime};
9
10// Use web-time for WASM (std::time::Instant panics in WASM)
11#[cfg(not(target_arch = "wasm32"))]
12use std::time::Instant;
13#[cfg(target_arch = "wasm32")]
14use web_time::Instant;
15
16// =============================================================================
17// Git Change Tracking Types
18// =============================================================================
19
20/// Type of change detected for a file in git.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
22pub enum GitChangeKind {
23    /// File was modified (content changed)
24    Modified,
25    /// File was newly added (staged new file)
26    Added,
27    /// File was deleted
28    Deleted,
29    /// File was renamed (old path)
30    RenamedFrom,
31    /// File was renamed (new path)
32    RenamedTo,
33    /// File is untracked (new file not yet staged)
34    Untracked,
35}
36
37impl GitChangeKind {
38    /// Get a display label for the change kind.
39    pub fn label(&self) -> &'static str {
40        match self {
41            GitChangeKind::Modified => "Modified",
42            GitChangeKind::Added => "Added",
43            GitChangeKind::Deleted => "Deleted",
44            GitChangeKind::RenamedFrom => "Renamed (from)",
45            GitChangeKind::RenamedTo => "Renamed (to)",
46            GitChangeKind::Untracked => "Untracked",
47        }
48    }
49
50    /// Get a short symbol for the change kind.
51    pub fn symbol(&self) -> &'static str {
52        match self {
53            GitChangeKind::Modified => "M",
54            GitChangeKind::Added => "+",
55            GitChangeKind::Deleted => "-",
56            GitChangeKind::RenamedFrom => "R←",
57            GitChangeKind::RenamedTo => "R→",
58            GitChangeKind::Untracked => "?",
59        }
60    }
61}
62
63/// Represents a single file change detected in git.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct GitFileChange {
66    /// Relative path of the changed file.
67    pub path: PathBuf,
68    /// Kind of change.
69    pub kind: GitChangeKind,
70    /// Whether this is a staged change (vs working directory).
71    pub staged: bool,
72}
73
74/// Snapshot of git changes for an entire repository.
75#[derive(Debug, Clone, Default, Serialize, Deserialize)]
76pub struct GitChangeSnapshot {
77    /// All detected changes.
78    pub changes: Vec<GitFileChange>,
79    /// Timestamp when this snapshot was taken.
80    #[serde(skip)]
81    pub captured_at: Option<Instant>,
82}
83
84impl GitChangeSnapshot {
85    /// Create a new empty snapshot.
86    pub fn new() -> Self {
87        Self {
88            changes: Vec::new(),
89            captured_at: Some(Instant::now()),
90        }
91    }
92
93    /// Check if a path has any changes.
94    pub fn has_changes(&self, path: &Path) -> bool {
95        self.changes.iter().any(|c| c.path == path)
96    }
97
98    /// Get the change kind for a path, if any.
99    pub fn get_change(&self, path: &Path) -> Option<&GitFileChange> {
100        self.changes.iter().find(|c| c.path == path)
101    }
102
103    /// Get all paths that have changes.
104    pub fn changed_paths(&self) -> impl Iterator<Item = &Path> {
105        self.changes.iter().map(|c| c.path.as_path())
106    }
107
108    /// Count changes by kind.
109    pub fn count_by_kind(&self, kind: GitChangeKind) -> usize {
110        self.changes.iter().filter(|c| c.kind == kind).count()
111    }
112
113    /// Check if snapshot is stale (older than given duration).
114    pub fn is_stale(&self, max_age: Duration) -> bool {
115        match self.captured_at {
116            Some(at) => at.elapsed() > max_age,
117            None => true,
118        }
119    }
120
121    /// Get age of this snapshot.
122    pub fn age(&self) -> Option<Duration> {
123        self.captured_at.map(|at| at.elapsed())
124    }
125}
126
127/// State for animating change indicators.
128#[derive(Debug, Clone)]
129pub struct ChangeIndicatorState {
130    /// Animation phase (0.0 to 1.0, loops).
131    pub phase: f32,
132    /// Animation speed multiplier.
133    pub speed: f32,
134    /// Whether animation is enabled.
135    pub enabled: bool,
136}
137
138impl Default for ChangeIndicatorState {
139    fn default() -> Self {
140        Self {
141            phase: 0.0,
142            speed: 1.0,
143            enabled: true,
144        }
145    }
146}
147
148impl ChangeIndicatorState {
149    /// Advance the animation by delta time.
150    pub fn tick(&mut self, dt: f32) {
151        if self.enabled {
152            self.phase = (self.phase + dt * self.speed) % 1.0;
153        }
154    }
155
156    /// Get the current pulse scale (1.0 to 1.3).
157    pub fn pulse_scale(&self) -> f32 {
158        // Smooth sine-based pulse
159        let t = self.phase * std::f32::consts::TAU;
160        1.0 + 0.15 * (t.sin() * 0.5 + 0.5)
161    }
162
163    /// Get the current alpha for outer ring (fades in/out).
164    pub fn ring_alpha(&self) -> f32 {
165        let t = self.phase * std::f32::consts::TAU;
166        0.3 + 0.4 * (t.sin() * 0.5 + 0.5)
167    }
168}
169
170/// Identifier for nodes within the `SourceCodeGraph`.
171#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
172pub struct NodeId(pub u64);
173
174/// Identifier for edges within the `SourceCodeGraph`.
175#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
176pub struct EdgeId(pub u64);
177
178/// Enumerates the kinds of nodes that can populate the `SourceCodeGraph`.
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
180pub enum GraphNodeKind {
181    /// A logical module, typically aligned to a crate or package.
182    Module,
183    /// A discrete file in the repository.
184    File,
185    /// Directory or folder that contains additional nodes.
186    Directory,
187    /// A long-running service entry point.
188    Service,
189    /// Automated test suites or harnesses.
190    Test,
191    /// Any other kind that does not fit the curated list.
192    #[default]
193    Other,
194}
195
196/// Captures metadata for a node in the graph.
197#[derive(Debug, Default, Clone, Serialize, Deserialize)]
198pub struct GraphNode {
199    /// Unique identifier for this node.
200    pub id: NodeId,
201    /// Human readable name.
202    pub name: String,
203    /// Category associated with the node.
204    pub kind: GraphNodeKind,
205    /// Arbitrary metadata, e.g. language, path, ownership.
206    pub metadata: HashMap<String, String>,
207}
208
209/// Represents connections between graph nodes.
210#[derive(Debug, Default, Clone, Serialize, Deserialize)]
211pub struct GraphEdge {
212    /// Unique identifier for this edge.
213    pub id: EdgeId,
214    /// Originating node identifier.
215    pub from: NodeId,
216    /// Destination node identifier.
217    pub to: NodeId,
218    /// Description of the relationship ("imports", "calls", etc.).
219    pub relationship: String,
220    /// Arbitrary metadata for the relationship.
221    pub metadata: HashMap<String, String>,
222}
223
224/// Aggregate graph describing the full software project topology.
225#[derive(Debug, Default, Clone, Serialize, Deserialize)]
226pub struct SourceCodeGraph {
227    /// All nodes that make up the graph.
228    pub nodes: Vec<GraphNode>,
229    /// All edges that connect nodes in the graph.
230    pub edges: Vec<GraphEdge>,
231    /// Arbitrary metadata about the entire graph snapshot.
232    pub metadata: HashMap<String, String>,
233}
234
235impl SourceCodeGraph {
236    /// Creates an empty graph with no nodes or edges.
237    pub fn empty() -> Self {
238        Self::default()
239    }
240
241    /// Returns the number of nodes currently tracked.
242    pub fn node_count(&self) -> usize {
243        self.nodes.len()
244    }
245
246    /// Returns the number of edges currently tracked.
247    pub fn edge_count(&self) -> usize {
248        self.edges.len()
249    }
250
251    /// Convert to petgraph StableDiGraph for visualization/analysis.
252    /// Returns the graph and a mapping from NodeIndex to NodeId.
253    pub fn to_petgraph(&self) -> (StableDiGraph<GraphNode, String>, HashMap<NodeId, NodeIndex>) {
254        let mut graph = StableDiGraph::new();
255        let mut id_to_index = HashMap::new();
256
257        // Add all nodes
258        for node in &self.nodes {
259            let idx = graph.add_node(node.clone());
260            id_to_index.insert(node.id, idx);
261        }
262
263        // Add all edges
264        for edge in &self.edges {
265            if let (Some(&from_idx), Some(&to_idx)) =
266                (id_to_index.get(&edge.from), id_to_index.get(&edge.to))
267            {
268                graph.add_edge(from_idx, to_idx, edge.relationship.clone());
269            }
270        }
271
272        (graph, id_to_index)
273    }
274}
275
276/// Types of references detected between source files.
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
278pub enum ReferenceKind {
279    /// Rust `use` statement
280    Uses,
281    /// Python/JS/TS `import` statement
282    Imports,
283    /// Trait or interface implementation
284    Implements,
285    /// Filesystem hierarchy (parent->child)
286    Contains,
287}
288
289impl std::fmt::Display for ReferenceKind {
290    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
291        match self {
292            ReferenceKind::Uses => write!(f, "uses"),
293            ReferenceKind::Imports => write!(f, "imports"),
294            ReferenceKind::Implements => write!(f, "implements"),
295            ReferenceKind::Contains => write!(f, "contains"),
296        }
297    }
298}
299
300/// A detected reference from one source to another.
301#[derive(Debug, Clone)]
302pub struct SourceReference {
303    /// Path of the source file containing the reference
304    pub source_path: PathBuf,
305    /// Type of reference
306    pub kind: ReferenceKind,
307    /// Target path (may be partial, resolved later)
308    pub target_route: PathBuf,
309}
310
311/// Builder for constructing a `SourceCodeGraph` from project data.
312#[derive(Debug, Default)]
313pub struct SourceCodeGraphBuilder {
314    nodes: Vec<GraphNode>,
315    edges: Vec<GraphEdge>,
316    path_to_node: HashMap<PathBuf, NodeId>,
317    next_node_id: u64,
318    next_edge_id: u64,
319    metadata: HashMap<String, String>,
320}
321
322impl SourceCodeGraphBuilder {
323    /// Create a new builder.
324    pub fn new() -> Self {
325        Self::default()
326    }
327
328    /// Set metadata for the graph.
329    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
330        self.metadata.insert(key.into(), value.into());
331        self
332    }
333
334    /// Add a directory node.
335    pub fn add_directory(&mut self, path: &Path) -> NodeId {
336        if let Some(&id) = self.path_to_node.get(path) {
337            return id;
338        }
339
340        let id = NodeId(self.next_node_id);
341        self.next_node_id += 1;
342
343        let name = path
344            .file_name()
345            .and_then(|n| n.to_str())
346            .unwrap_or_else(|| path.to_str().unwrap_or("."))
347            .to_string();
348
349        let mut metadata = HashMap::new();
350        metadata.insert("path".to_string(), path.to_string_lossy().to_string());
351
352        self.nodes.push(GraphNode {
353            id,
354            name,
355            kind: GraphNodeKind::Directory,
356            metadata,
357        });
358
359        self.path_to_node.insert(path.to_path_buf(), id);
360        id
361    }
362
363    /// Add a file node with optional language detection.
364    pub fn add_file(&mut self, path: &Path, relative_path: &str) -> NodeId {
365        if let Some(&id) = self.path_to_node.get(path) {
366            return id;
367        }
368
369        let id = NodeId(self.next_node_id);
370        self.next_node_id += 1;
371
372        let name = path
373            .file_name()
374            .and_then(|n| n.to_str())
375            .unwrap_or_else(|| path.to_str().unwrap_or("unknown"))
376            .to_string();
377
378        // Determine kind based on extension
379        let kind = match path.extension().and_then(|e| e.to_str()) {
380            Some("rs") | Some("py") | Some("js") | Some("ts") | Some("tsx") | Some("jsx")
381            | Some("go") | Some("java") | Some("c") | Some("cpp") | Some("h") | Some("hpp") => {
382                if relative_path.contains("test") || name.starts_with("test_") {
383                    GraphNodeKind::Test
384                } else if name == "mod.rs"
385                    || name == "__init__.py"
386                    || name == "index.ts"
387                    || name == "index.js"
388                {
389                    GraphNodeKind::Module
390                } else {
391                    GraphNodeKind::File
392                }
393            }
394            _ => GraphNodeKind::File,
395        };
396
397        let mut metadata = HashMap::new();
398        metadata.insert("path".to_string(), path.to_string_lossy().to_string());
399        metadata.insert("relative_path".to_string(), relative_path.to_string());
400
401        if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
402            metadata.insert("extension".to_string(), ext.to_string());
403            metadata.insert(
404                "language".to_string(),
405                extension_to_language(ext).to_string(),
406            );
407        }
408
409        self.nodes.push(GraphNode {
410            id,
411            name,
412            kind,
413            metadata,
414        });
415
416        self.path_to_node.insert(path.to_path_buf(), id);
417        id
418    }
419
420    /// Add a hierarchy edge (parent contains child).
421    pub fn add_hierarchy_edge(&mut self, parent_path: &Path, child_path: &Path) {
422        if let (Some(&parent_id), Some(&child_id)) = (
423            self.path_to_node.get(parent_path),
424            self.path_to_node.get(child_path),
425        ) {
426            if parent_id != child_id {
427                self.add_edge(parent_id, child_id, ReferenceKind::Contains);
428            }
429        }
430    }
431
432    /// Add an edge between two nodes.
433    pub fn add_edge(&mut self, from: NodeId, to: NodeId, kind: ReferenceKind) {
434        let id = EdgeId(self.next_edge_id);
435        self.next_edge_id += 1;
436
437        self.edges.push(GraphEdge {
438            id,
439            from,
440            to,
441            relationship: kind.to_string(),
442            metadata: HashMap::new(),
443        });
444    }
445
446    /// Get NodeId for a path if it exists.
447    pub fn get_node_id(&self, path: &Path) -> Option<NodeId> {
448        self.path_to_node.get(path).copied()
449    }
450
451    /// Find a node by matching path suffix (for reference resolution).
452    pub fn find_node_by_path_suffix(&self, route: &Path) -> Option<NodeId> {
453        let route_str = route.to_string_lossy();
454
455        for (path, &node_id) in &self.path_to_node {
456            let path_str = path.to_string_lossy();
457
458            // Strategy 1: Direct suffix match
459            if path_str.ends_with(route_str.as_ref()) {
460                return Some(node_id);
461            }
462
463            // Strategy 2: Normalized comparison
464            let normalized_path: String = path_str.trim_start_matches("./").replace('\\', "/");
465            let normalized_route: String = route_str.trim_start_matches("./").replace('\\', "/");
466            if normalized_path.ends_with(&normalized_route) {
467                return Some(node_id);
468            }
469
470            // Strategy 3: Module path matching (e.g., core/models.rs -> src/core/models.rs)
471            let route_parts: Vec<&str> = normalized_route.split('/').collect();
472            let path_parts: Vec<&str> = normalized_path.split('/').collect();
473            if route_parts.len() <= path_parts.len() {
474                for window in path_parts.windows(route_parts.len()) {
475                    if window == route_parts.as_slice() {
476                        return Some(node_id);
477                    }
478                }
479            }
480
481            // Strategy 4: Filename match
482            if let (Some(file_name), Some(route_name)) = (
483                path.file_name().and_then(|n| n.to_str()),
484                route.file_name().and_then(|n| n.to_str()),
485            ) {
486                if file_name == route_name {
487                    return Some(node_id);
488                }
489            }
490        }
491
492        None
493    }
494
495    /// Set a metadata key on an existing node.
496    pub fn set_node_metadata(&mut self, node_id: NodeId, key: impl Into<String>, value: impl Into<String>) {
497        if let Some(node) = self.nodes.iter_mut().find(|n| n.id == node_id) {
498            node.metadata.insert(key.into(), value.into());
499        }
500    }
501
502    /// Get the current node count.
503    pub fn node_count(&self) -> usize {
504        self.nodes.len()
505    }
506
507    /// Get the current edge count.
508    pub fn edge_count(&self) -> usize {
509        self.edges.len()
510    }
511
512    /// Build the final `SourceCodeGraph`.
513    pub fn build(self) -> SourceCodeGraph {
514        SourceCodeGraph {
515            nodes: self.nodes,
516            edges: self.edges,
517            metadata: self.metadata,
518        }
519    }
520}
521
522/// Detect references in Rust source code.
523pub fn detect_rust_references(content: &str, source_path: &Path) -> Vec<SourceReference> {
524    let mut refs = Vec::new();
525
526    for line in content.lines() {
527        let trimmed = line.trim();
528
529        // Handle 'mod' declarations
530        if trimmed.starts_with("pub mod ") || trimmed.starts_with("mod ") {
531            let mod_part = trimmed
532                .strip_prefix("pub mod ")
533                .or_else(|| trimmed.strip_prefix("mod "))
534                .unwrap_or("")
535                .split(';')
536                .next()
537                .unwrap_or("")
538                .trim();
539
540            if !mod_part.is_empty() && !mod_part.contains('{') {
541                refs.push(SourceReference {
542                    source_path: source_path.to_path_buf(),
543                    kind: ReferenceKind::Contains, // Treating modules as containment for graph connectivity
544                    target_route: PathBuf::from(format!("{}.rs", mod_part)),
545                });
546                // Also try module directory structure
547                refs.push(SourceReference {
548                    source_path: source_path.to_path_buf(),
549                    kind: ReferenceKind::Contains,
550                    target_route: PathBuf::from(format!("{}/mod.rs", mod_part)),
551                });
552            }
553        }
554
555        if !trimmed.starts_with("use ") {
556            continue;
557        }
558
559        // Extract module path from use statement
560        let use_part = trimmed
561            .strip_prefix("use ")
562            .unwrap_or("")
563            .split(';')
564            .next()
565            .unwrap_or("")
566            .split('{')
567            .next()
568            .unwrap_or("")
569            .trim();
570
571        if use_part.is_empty() {
572            continue;
573        }
574
575        // Propose everything that looks like a path.
576        // The graph builder will filter out references that don't resolve to actual nodes.
577        let module_path = use_part
578            .strip_prefix("crate::")
579            .or_else(|| use_part.strip_prefix("self::"))
580            .or_else(|| use_part.strip_prefix("super::"))
581            .unwrap_or(use_part);
582
583        // Convert module path to file path
584        let path_str = module_path
585            .replace("::", "/")
586            .trim_end_matches(|c: char| !c.is_alphanumeric() && c != '_')
587            .to_string();
588
589        refs.push(SourceReference {
590            source_path: source_path.to_path_buf(),
591            kind: ReferenceKind::Uses,
592            target_route: PathBuf::from(format!("{}.rs", path_str)),
593        });
594    }
595
596    refs
597}
598
599/// Detect references in Python source code.
600pub fn detect_python_references(content: &str, source_path: &Path) -> Vec<SourceReference> {
601    let mut refs = Vec::new();
602
603    for line in content.lines() {
604        let trimmed = line.trim();
605
606        // Match "import module" statements
607        if trimmed.starts_with("import ") && !trimmed.starts_with("import(") {
608            let import_part = trimmed
609                .strip_prefix("import ")
610                .unwrap_or("")
611                .split_whitespace()
612                .next()
613                .unwrap_or("")
614                .split(',')
615                .next()
616                .unwrap_or("")
617                .trim();
618
619            if !import_part.is_empty() {
620                let path_str = import_part.replace('.', "/");
621                refs.push(SourceReference {
622                    source_path: source_path.to_path_buf(),
623                    kind: ReferenceKind::Imports,
624                    target_route: PathBuf::from(format!("{}.py", path_str)),
625                });
626            }
627        }
628
629        // Match "from module import something" statements
630        if let Some(module_part) = trimmed
631            .strip_prefix("from ")
632            .and_then(|s| s.split(" import ").next())
633        {
634            let module = module_part.trim();
635            if !module.is_empty() && module != "." && !module.starts_with("..") {
636                let path_str = module.replace('.', "/");
637                refs.push(SourceReference {
638                    source_path: source_path.to_path_buf(),
639                    kind: ReferenceKind::Imports,
640                    target_route: PathBuf::from(format!("{}.py", path_str)),
641                });
642            }
643        }
644    }
645
646    refs
647}
648
649/// Detect references in TypeScript/JavaScript source code.
650pub fn detect_ts_references(content: &str, source_path: &Path) -> Vec<SourceReference> {
651    let mut refs = Vec::new();
652
653    for line in content.lines() {
654        let trimmed = line.trim();
655
656        // Match import statements: import X from 'path' or import 'path'
657        if trimmed.starts_with("import ") {
658            // Extract path from quotes
659            if let Some(path_start) = trimmed.find(['\'', '"']) {
660                let quote_char = trimmed.chars().nth(path_start).unwrap();
661                let rest = &trimmed[path_start + 1..];
662                if let Some(path_end) = rest.find(quote_char) {
663                    let import_path = &rest[..path_end];
664                    // Only track relative imports
665                    if import_path.starts_with('.') {
666                        refs.push(SourceReference {
667                            source_path: source_path.to_path_buf(),
668                            kind: ReferenceKind::Imports,
669                            target_route: PathBuf::from(import_path),
670                        });
671                    }
672                }
673            }
674        }
675    }
676
677    refs
678}
679
680/// Detect references based on file extension.
681pub fn detect_references(content: &str, source_path: &Path) -> Vec<SourceReference> {
682    match source_path.extension().and_then(|e| e.to_str()) {
683        Some("rs") => detect_rust_references(content, source_path),
684        Some("py") => detect_python_references(content, source_path),
685        Some("ts") | Some("tsx") | Some("js") | Some("jsx") => {
686            detect_ts_references(content, source_path)
687        }
688        _ => Vec::new(),
689    }
690}
691
692/// Map file extension to language name.
693fn extension_to_language(ext: &str) -> &'static str {
694    match ext {
695        "rs" => "rust",
696        "py" => "python",
697        "js" => "javascript",
698        "ts" => "typescript",
699        "tsx" => "typescript",
700        "jsx" => "javascript",
701        "go" => "go",
702        "java" => "java",
703        "c" | "h" => "c",
704        "cpp" | "hpp" | "cc" | "cxx" => "cpp",
705        "md" => "markdown",
706        "json" => "json",
707        "yaml" | "yml" => "yaml",
708        "toml" => "toml",
709        _ => "unknown",
710    }
711}
712
713/// Represents an explicitly declared vibe (intent/spec/decision) attached to graph regions.
714#[derive(Debug, Clone, Serialize, Deserialize)]
715pub struct Vibe {
716    /// Stable identifier for referencing the vibe.
717    pub id: String,
718    /// Short title summarizing the intent.
719    pub title: String,
720    /// Richer description with context.
721    pub description: String,
722    /// Target nodes within the graph impacted by the vibe.
723    pub targets: Vec<NodeId>,
724    /// Actor (human or machine) responsible for the vibe.
725    pub created_by: String,
726    /// Timestamp when the vibe entered the system.
727    pub created_at: SystemTime,
728    /// Optional tags or attributes.
729    pub metadata: HashMap<String, String>,
730}
731
732/// Canonical definition of governing rules in effect for a graph.
733#[derive(Debug, Default, Clone, Serialize, Deserialize)]
734pub struct Constitution {
735    /// Unique name of the constitution.
736    pub name: String,
737    /// Semantic version or revision identifier.
738    pub version: String,
739    /// Human-readable description of the guardrails.
740    pub description: String,
741    /// Simple list of policies; future versions may embed richer data.
742    pub policies: Vec<String>,
743}
744
745/// Generic payload that cells in the automaton can store.
746pub type StatePayload = Value;
747
748/// Captures the state of an individual cell in the LLM cellular automaton.
749#[derive(Debug, Clone, Serialize, Deserialize)]
750pub struct CellState {
751    /// Node the cell is associated with.
752    pub node_id: NodeId,
753    /// Arbitrary structured state payload.
754    pub payload: StatePayload,
755    /// Tracking field for energy, confidence, or strength indicators.
756    pub activation: f32,
757    /// Timestamp for the most recent update.
758    pub last_updated: SystemTime,
759    /// Free-form annotations (signals, metrics, citations, etc.).
760    pub annotations: HashMap<String, String>,
761}
762
763impl CellState {
764    /// Creates a fresh `CellState` wrapping the provided payload.
765    pub fn new(node_id: NodeId, payload: StatePayload) -> Self {
766        Self {
767            node_id,
768            payload,
769            activation: 0.0,
770            last_updated: SystemTime::now(),
771            annotations: HashMap::new(),
772        }
773    }
774}
775
776/// Represents a snapshot of the entire system ready to be fossilized in Git.
777#[derive(Debug, Clone, Serialize, Deserialize)]
778pub struct Snapshot {
779    /// Identifier suitable for referencing the snapshot in storage.
780    pub id: String,
781    /// Captured graph at the time of snapshot.
782    pub graph: SourceCodeGraph,
783    /// All vibes considered part of the snapshot.
784    pub vibes: Vec<Vibe>,
785    /// Cell states for the automaton corresponding to the snapshot.
786    pub cell_states: Vec<CellState>,
787    /// Constitution in effect when the snapshot was created.
788    pub constitution: Constitution,
789    /// Timestamp for when the snapshot was recorded.
790    pub created_at: SystemTime,
791}
792
793/// Strategies for mapping a logical graph to a filesystem hierarchy.
794#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
795pub enum LayoutStrategy {
796    /// Everything in one directory (flat).
797    #[default]
798    Flat,
799    /// Spatial organization for lattice-like graphs (rows/cols).
800    Lattice { 
801        width: usize, 
802        group_by_row: bool 
803    },
804    /// Direct mapping (trusts existing paths or uses heuristics).
805    Direct,
806    /// Preserves existing directory structure (Identity).
807    Preserve,
808    /// Modular clustering (auto-detected modules).
809    Modular,
810}