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    /// Get the current node count.
496    pub fn node_count(&self) -> usize {
497        self.nodes.len()
498    }
499
500    /// Get the current edge count.
501    pub fn edge_count(&self) -> usize {
502        self.edges.len()
503    }
504
505    /// Build the final `SourceCodeGraph`.
506    pub fn build(self) -> SourceCodeGraph {
507        SourceCodeGraph {
508            nodes: self.nodes,
509            edges: self.edges,
510            metadata: self.metadata,
511        }
512    }
513}
514
515/// Detect references in Rust source code.
516pub fn detect_rust_references(content: &str, source_path: &Path) -> Vec<SourceReference> {
517    let mut refs = Vec::new();
518
519    for line in content.lines() {
520        let trimmed = line.trim();
521
522        // Handle 'mod' declarations
523        if trimmed.starts_with("pub mod ") || trimmed.starts_with("mod ") {
524            let mod_part = trimmed
525                .strip_prefix("pub mod ")
526                .or_else(|| trimmed.strip_prefix("mod "))
527                .unwrap_or("")
528                .split(';')
529                .next()
530                .unwrap_or("")
531                .trim();
532
533            if !mod_part.is_empty() && !mod_part.contains('{') {
534                refs.push(SourceReference {
535                    source_path: source_path.to_path_buf(),
536                    kind: ReferenceKind::Contains, // Treating modules as containment for graph connectivity
537                    target_route: PathBuf::from(format!("{}.rs", mod_part)),
538                });
539                // Also try module directory structure
540                refs.push(SourceReference {
541                    source_path: source_path.to_path_buf(),
542                    kind: ReferenceKind::Contains,
543                    target_route: PathBuf::from(format!("{}/mod.rs", mod_part)),
544                });
545            }
546        }
547
548        if !trimmed.starts_with("use ") {
549            continue;
550        }
551
552        // Extract module path from use statement
553        let use_part = trimmed
554            .strip_prefix("use ")
555            .unwrap_or("")
556            .split(';')
557            .next()
558            .unwrap_or("")
559            .split('{')
560            .next()
561            .unwrap_or("")
562            .trim();
563
564        if use_part.is_empty() {
565            continue;
566        }
567
568        // Propose everything that looks like a path.
569        // The graph builder will filter out references that don't resolve to actual nodes.
570        let module_path = use_part
571            .strip_prefix("crate::")
572            .or_else(|| use_part.strip_prefix("self::"))
573            .or_else(|| use_part.strip_prefix("super::"))
574            .unwrap_or(use_part);
575
576        // Convert module path to file path
577        let path_str = module_path
578            .replace("::", "/")
579            .trim_end_matches(|c: char| !c.is_alphanumeric() && c != '_')
580            .to_string();
581
582        refs.push(SourceReference {
583            source_path: source_path.to_path_buf(),
584            kind: ReferenceKind::Uses,
585            target_route: PathBuf::from(format!("{}.rs", path_str)),
586        });
587    }
588
589    refs
590}
591
592/// Detect references in Python source code.
593pub fn detect_python_references(content: &str, source_path: &Path) -> Vec<SourceReference> {
594    let mut refs = Vec::new();
595
596    for line in content.lines() {
597        let trimmed = line.trim();
598
599        // Match "import module" statements
600        if trimmed.starts_with("import ") && !trimmed.starts_with("import(") {
601            let import_part = trimmed
602                .strip_prefix("import ")
603                .unwrap_or("")
604                .split_whitespace()
605                .next()
606                .unwrap_or("")
607                .split(',')
608                .next()
609                .unwrap_or("")
610                .trim();
611
612            if !import_part.is_empty() {
613                let path_str = import_part.replace('.', "/");
614                refs.push(SourceReference {
615                    source_path: source_path.to_path_buf(),
616                    kind: ReferenceKind::Imports,
617                    target_route: PathBuf::from(format!("{}.py", path_str)),
618                });
619            }
620        }
621
622        // Match "from module import something" statements
623        if let Some(module_part) = trimmed
624            .strip_prefix("from ")
625            .and_then(|s| s.split(" import ").next())
626        {
627            let module = module_part.trim();
628            if !module.is_empty() && module != "." && !module.starts_with("..") {
629                let path_str = module.replace('.', "/");
630                refs.push(SourceReference {
631                    source_path: source_path.to_path_buf(),
632                    kind: ReferenceKind::Imports,
633                    target_route: PathBuf::from(format!("{}.py", path_str)),
634                });
635            }
636        }
637    }
638
639    refs
640}
641
642/// Detect references in TypeScript/JavaScript source code.
643pub fn detect_ts_references(content: &str, source_path: &Path) -> Vec<SourceReference> {
644    let mut refs = Vec::new();
645
646    for line in content.lines() {
647        let trimmed = line.trim();
648
649        // Match import statements: import X from 'path' or import 'path'
650        if trimmed.starts_with("import ") {
651            // Extract path from quotes
652            if let Some(path_start) = trimmed.find(['\'', '"']) {
653                let quote_char = trimmed.chars().nth(path_start).unwrap();
654                let rest = &trimmed[path_start + 1..];
655                if let Some(path_end) = rest.find(quote_char) {
656                    let import_path = &rest[..path_end];
657                    // Only track relative imports
658                    if import_path.starts_with('.') {
659                        refs.push(SourceReference {
660                            source_path: source_path.to_path_buf(),
661                            kind: ReferenceKind::Imports,
662                            target_route: PathBuf::from(import_path),
663                        });
664                    }
665                }
666            }
667        }
668    }
669
670    refs
671}
672
673/// Detect references based on file extension.
674pub fn detect_references(content: &str, source_path: &Path) -> Vec<SourceReference> {
675    match source_path.extension().and_then(|e| e.to_str()) {
676        Some("rs") => detect_rust_references(content, source_path),
677        Some("py") => detect_python_references(content, source_path),
678        Some("ts") | Some("tsx") | Some("js") | Some("jsx") => {
679            detect_ts_references(content, source_path)
680        }
681        _ => Vec::new(),
682    }
683}
684
685/// Map file extension to language name.
686fn extension_to_language(ext: &str) -> &'static str {
687    match ext {
688        "rs" => "rust",
689        "py" => "python",
690        "js" => "javascript",
691        "ts" => "typescript",
692        "tsx" => "typescript",
693        "jsx" => "javascript",
694        "go" => "go",
695        "java" => "java",
696        "c" | "h" => "c",
697        "cpp" | "hpp" | "cc" | "cxx" => "cpp",
698        "md" => "markdown",
699        "json" => "json",
700        "yaml" | "yml" => "yaml",
701        "toml" => "toml",
702        _ => "unknown",
703    }
704}
705
706/// Represents an explicitly declared vibe (intent/spec/decision) attached to graph regions.
707#[derive(Debug, Clone, Serialize, Deserialize)]
708pub struct Vibe {
709    /// Stable identifier for referencing the vibe.
710    pub id: String,
711    /// Short title summarizing the intent.
712    pub title: String,
713    /// Richer description with context.
714    pub description: String,
715    /// Target nodes within the graph impacted by the vibe.
716    pub targets: Vec<NodeId>,
717    /// Actor (human or machine) responsible for the vibe.
718    pub created_by: String,
719    /// Timestamp when the vibe entered the system.
720    pub created_at: SystemTime,
721    /// Optional tags or attributes.
722    pub metadata: HashMap<String, String>,
723}
724
725/// Canonical definition of governing rules in effect for a graph.
726#[derive(Debug, Default, Clone, Serialize, Deserialize)]
727pub struct Constitution {
728    /// Unique name of the constitution.
729    pub name: String,
730    /// Semantic version or revision identifier.
731    pub version: String,
732    /// Human-readable description of the guardrails.
733    pub description: String,
734    /// Simple list of policies; future versions may embed richer data.
735    pub policies: Vec<String>,
736}
737
738/// Generic payload that cells in the automaton can store.
739pub type StatePayload = Value;
740
741/// Captures the state of an individual cell in the LLM cellular automaton.
742#[derive(Debug, Clone, Serialize, Deserialize)]
743pub struct CellState {
744    /// Node the cell is associated with.
745    pub node_id: NodeId,
746    /// Arbitrary structured state payload.
747    pub payload: StatePayload,
748    /// Tracking field for energy, confidence, or strength indicators.
749    pub activation: f32,
750    /// Timestamp for the most recent update.
751    pub last_updated: SystemTime,
752    /// Free-form annotations (signals, metrics, citations, etc.).
753    pub annotations: HashMap<String, String>,
754}
755
756impl CellState {
757    /// Creates a fresh `CellState` wrapping the provided payload.
758    pub fn new(node_id: NodeId, payload: StatePayload) -> Self {
759        Self {
760            node_id,
761            payload,
762            activation: 0.0,
763            last_updated: SystemTime::now(),
764            annotations: HashMap::new(),
765        }
766    }
767}
768
769/// Represents a snapshot of the entire system ready to be fossilized in Git.
770#[derive(Debug, Clone, Serialize, Deserialize)]
771pub struct Snapshot {
772    /// Identifier suitable for referencing the snapshot in storage.
773    pub id: String,
774    /// Captured graph at the time of snapshot.
775    pub graph: SourceCodeGraph,
776    /// All vibes considered part of the snapshot.
777    pub vibes: Vec<Vibe>,
778    /// Cell states for the automaton corresponding to the snapshot.
779    pub cell_states: Vec<CellState>,
780    /// Constitution in effect when the snapshot was created.
781    pub constitution: Constitution,
782    /// Timestamp for when the snapshot was recorded.
783    pub created_at: SystemTime,
784}
785
786/// Strategies for mapping a logical graph to a filesystem hierarchy.
787#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
788pub enum LayoutStrategy {
789    /// Everything in one directory (flat).
790    #[default]
791    Flat,
792    /// Spatial organization for lattice-like graphs (rows/cols).
793    Lattice { 
794        width: usize, 
795        group_by_row: bool 
796    },
797    /// Direct mapping (trusts existing paths or uses heuristics).
798    Direct,
799    /// Preserves existing directory structure (Identity).
800    Preserve,
801    /// Modular clustering (auto-detected modules).
802    Modular,
803}