Skip to main content

clawft_kernel/
weaver.rs

1//! WeaverEngine: ECC-powered codebase modeling service (K3c-G1).
2//!
3//! The WeaverEngine is a [`SystemService`] that drives the ECC cognitive
4//! substrate to model real-world data sources (git logs, file trees, CI
5//! pipelines, documentation). It manages [`ModelingSession`]s, evaluates
6//! confidence via the causal graph, and records its own decisions in the
7//! Meta-Loom for self-improvement tracking.
8//!
9//! This module requires the `ecc` feature.
10
11use std::collections::{HashMap, HashSet, VecDeque};
12use std::fmt;
13use std::path::{Path, PathBuf};
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::sync::{Arc, RwLock};
16use std::time::{Duration, Instant, SystemTime};
17
18use async_trait::async_trait;
19use chrono::{DateTime, Utc};
20use serde::{Deserialize, Serialize};
21use tracing::{debug, info};
22
23use crate::causal::{CausalEdgeType, CausalGraph};
24use crate::embedding::{EmbeddingProvider, MockEmbeddingProvider};
25use crate::health::HealthStatus;
26use crate::hnsw_service::HnswService;
27use crate::impulse::{ImpulseQueue, ImpulseType};
28use crate::service::{ServiceType, SystemService};
29
30// ---------------------------------------------------------------------------
31// WeaverCommand (IPC messages from CLI / agents)
32// ---------------------------------------------------------------------------
33
34/// Commands sent to the WeaverEngine via IPC.
35#[non_exhaustive]
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub enum WeaverCommand {
38    /// Start a new modeling session.
39    SessionStart {
40        domain: String,
41        git_path: Option<PathBuf>,
42        context: Option<String>,
43        goal: Option<String>,
44    },
45    /// Resume an existing session.
46    SessionResume { domain: String },
47    /// Stop a session.
48    SessionStop { domain: String },
49    /// Watch session progress (streaming).
50    SessionWatch { domain: String },
51    /// Add a data source to a session.
52    SourceAdd {
53        domain: String,
54        source_type: String,
55        root: Option<PathBuf>,
56        watch: bool,
57    },
58    /// List sources for a session.
59    SourceList { domain: String },
60    /// Query confidence.
61    Confidence {
62        domain: String,
63        edge: Option<String>,
64        verbose: bool,
65    },
66    /// Export model.
67    Export {
68        domain: String,
69        min_confidence: f64,
70        output: PathBuf,
71    },
72    /// Import model.
73    Import {
74        domain: String,
75        input: PathBuf,
76    },
77    /// Query meta-loom status.
78    MetaStatus { domain: String },
79    /// List learned strategies.
80    MetaStrategies,
81    /// Export knowledge base.
82    MetaExportKb { output: PathBuf },
83    /// Stitch two domains.
84    Stitch {
85        source: String,
86        target: String,
87        output: String,
88    },
89}
90
91// ---------------------------------------------------------------------------
92// WeaverResponse
93// ---------------------------------------------------------------------------
94
95/// Responses from the WeaverEngine to CLI / agents.
96#[non_exhaustive]
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub enum WeaverResponse {
99    /// Session started successfully.
100    SessionStarted { domain: String, session_id: String },
101    /// Session stopped.
102    SessionStopped { domain: String },
103    /// Session resumed.
104    SessionResumed { domain: String },
105    /// Confidence report.
106    ConfidenceReport(ConfidenceReport),
107    /// Source added.
108    SourceAdded { domain: String, source_type: String },
109    /// Sources listed.
110    Sources(Vec<String>),
111    /// Model exported.
112    Exported { path: PathBuf, edges: usize },
113    /// Model imported.
114    Imported { domain: String },
115    /// Learned strategies.
116    Strategies(Vec<StrategyPattern>),
117    /// Knowledge base exported.
118    KbExported { path: PathBuf },
119    /// Error.
120    Error(String),
121}
122
123// ---------------------------------------------------------------------------
124// DataSource
125// ---------------------------------------------------------------------------
126
127/// A data source that can be ingested by the WeaverEngine.
128#[non_exhaustive]
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub enum DataSource {
131    /// Git commit history.
132    GitLog { path: PathBuf },
133    /// File system tree.
134    FileTree { root: PathBuf },
135    /// CI pipeline events.
136    CiPipeline { url: String },
137    /// Issue tracker feed.
138    IssueTracker { url: String },
139    /// Documentation corpus.
140    Documentation { root: PathBuf },
141    /// SPARC planning artifacts.
142    SparcPlan { root: PathBuf },
143    /// User-defined stream.
144    CustomStream { name: String },
145}
146
147impl DataSource {
148    /// Human-readable type name.
149    pub fn type_name(&self) -> &str {
150        match self {
151            Self::GitLog { .. } => "git_log",
152            Self::FileTree { .. } => "file_tree",
153            Self::CiPipeline { .. } => "ci_pipeline",
154            Self::IssueTracker { .. } => "issue_tracker",
155            Self::Documentation { .. } => "documentation",
156            Self::SparcPlan { .. } => "sparc_plan",
157            Self::CustomStream { .. } => "custom_stream",
158        }
159    }
160}
161
162// ---------------------------------------------------------------------------
163// ModelingSession
164// ---------------------------------------------------------------------------
165
166/// An active or suspended modeling session for a single domain.
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct ModelingSession {
169    /// Unique session identifier.
170    pub id: String,
171    /// Domain name (e.g., project name).
172    pub domain: String,
173    /// When the session was started.
174    pub started_at: DateTime<Utc>,
175    /// Current overall confidence (0.0 .. 1.0).
176    pub confidence: f64,
177    /// Identified confidence gaps.
178    pub gaps: Vec<ConfidenceGap>,
179    /// Data sources that have been ingested.
180    pub sources_ingested: Vec<String>,
181    /// Number of cognitive ticks processed.
182    pub tick_count: u64,
183    /// Remaining budget for this session.
184    pub budget_remaining_ms: u64,
185    /// Whether the session is currently active.
186    pub active: bool,
187    /// Arbitrary metadata.
188    pub metadata: HashMap<String, serde_json::Value>,
189}
190
191// ---------------------------------------------------------------------------
192// ConfidenceGap / ConfidenceReport
193// ---------------------------------------------------------------------------
194
195/// A gap in the model's confidence for a specific domain area.
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct ConfidenceGap {
198    /// Area name.
199    pub domain: String,
200    /// Current confidence level.
201    pub current_confidence: f64,
202    /// Target confidence level.
203    pub target_confidence: f64,
204    /// Suggested sources to improve confidence.
205    pub suggested_sources: Vec<String>,
206}
207
208/// Full confidence report for a modeling session.
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct ConfidenceReport {
211    /// Overall confidence.
212    pub overall: f64,
213    /// Per-domain gap analysis.
214    pub gaps: Vec<ConfidenceGap>,
215    /// Modeling suggestions.
216    pub suggestions: Vec<ModelingSuggestion>,
217}
218
219/// Suggestions for improving model quality.
220#[non_exhaustive]
221#[derive(Debug, Clone, Serialize, Deserialize)]
222pub enum ModelingSuggestion {
223    /// Add a new data source.
224    AddSource { source_type: String, reason: String },
225    /// Refine an edge type relationship.
226    RefineEdgeType { from: String, to: String },
227    /// Split a category into subcategories.
228    SplitCategory { category: String },
229    /// Increase observation window.
230    ExtendObservation { domain: String },
231}
232
233// ---------------------------------------------------------------------------
234// ExportedModel (K3c-G4)
235// ---------------------------------------------------------------------------
236
237/// Serialized model for edge deployment or offline analysis.
238#[derive(Debug, Clone, Serialize, Deserialize)]
239pub struct ExportedModel {
240    /// Schema version.
241    pub version: String,
242    /// Domain this model was built for.
243    pub domain: String,
244    /// When the export was created.
245    pub exported_at: DateTime<Utc>,
246    /// Overall confidence at export time.
247    pub confidence: f64,
248    /// Node type specifications.
249    pub node_types: Vec<NodeTypeSpec>,
250    /// Edge type specifications.
251    pub edge_types: Vec<EdgeTypeSpec>,
252    /// Exported causal nodes.
253    pub causal_nodes: Vec<ExportedCausalNode>,
254    /// Exported causal edges.
255    pub causal_edges: Vec<ExportedCausalEdge>,
256    /// Arbitrary metadata.
257    pub metadata: HashMap<String, serde_json::Value>,
258}
259
260/// Node type specification in an exported model.
261#[derive(Debug, Clone, Serialize, Deserialize)]
262pub struct NodeTypeSpec {
263    /// Type name.
264    pub name: String,
265    /// Embedding strategy identifier.
266    pub embedding_strategy: String,
267    /// Vector dimensions.
268    pub dimensions: usize,
269}
270
271/// Edge type specification in an exported model.
272#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct EdgeTypeSpec {
274    /// Source node type.
275    pub from_type: String,
276    /// Target node type.
277    pub to_type: String,
278    /// Edge type name.
279    pub edge_type: String,
280    /// Confidence for this edge type.
281    pub confidence: f64,
282}
283
284/// Exported causal node.
285#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct ExportedCausalNode {
287    /// Node label.
288    pub label: String,
289    /// Node metadata.
290    pub metadata: serde_json::Value,
291}
292
293/// Exported causal edge.
294#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct ExportedCausalEdge {
296    /// Source node label.
297    pub source_label: String,
298    /// Target node label.
299    pub target_label: String,
300    /// Edge type.
301    pub edge_type: String,
302    /// Edge weight.
303    pub weight: f32,
304}
305
306// ---------------------------------------------------------------------------
307// MetaLoomEvent (K3c-G5)
308// ---------------------------------------------------------------------------
309
310/// Records a Weaver modeling decision in the Meta-Loom.
311#[derive(Debug, Clone, Serialize, Deserialize)]
312pub struct MetaLoomEvent {
313    /// Domain of the active session.
314    pub session_domain: String,
315    /// Type of modeling decision.
316    pub decision_type: MetaDecisionType,
317    /// Confidence before the decision.
318    pub confidence_before: f64,
319    /// Confidence after (filled in by next tick).
320    pub confidence_after: Option<f64>,
321    /// Human-readable rationale.
322    pub rationale: String,
323    /// When the decision was made.
324    pub timestamp: DateTime<Utc>,
325}
326
327/// Classification of meta-loom decisions.
328#[non_exhaustive]
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub enum MetaDecisionType {
331    /// A new data source was added.
332    SourceAdded { source_type: String },
333    /// A new edge type relationship was created.
334    EdgeTypeCreated {
335        from: String,
336        to: String,
337        edge_type: String,
338    },
339    /// An edge type was removed.
340    EdgeTypeRemoved { from: String, to: String },
341    /// Embedding strategy changed for a node type.
342    EmbeddingStrategyChanged {
343        node_type: String,
344        old: String,
345        new: String,
346    },
347    /// Tick interval was adjusted.
348    TickIntervalAdjusted { old_ms: u64, new_ms: u64 },
349    /// Model version was bumped.
350    ModelVersionBumped { from: u32, to: u32 },
351    /// A new strategy was learned.
352    StrategyLearned { pattern: String },
353}
354
355// ---------------------------------------------------------------------------
356// StrategyPattern / WeaverKnowledgeBase
357// ---------------------------------------------------------------------------
358
359/// A learned modeling strategy from cross-domain experience.
360#[derive(Debug, Clone, Serialize, Deserialize)]
361pub struct StrategyPattern {
362    /// Decision type that led to improvement.
363    pub decision_type: String,
364    /// Domain context where it was learned.
365    pub context: String,
366    /// Confidence improvement observed.
367    pub improvement: f64,
368    /// When the strategy was learned.
369    pub timestamp: DateTime<Utc>,
370}
371
372/// Cross-domain knowledge base that accumulates successful strategies.
373pub struct WeaverKnowledgeBase {
374    /// Learned strategies.
375    strategies: RwLock<Vec<StrategyPattern>>,
376    /// Strategy count.
377    strategy_count: AtomicU64,
378}
379
380impl Default for WeaverKnowledgeBase {
381    fn default() -> Self {
382        Self {
383            strategies: RwLock::new(Vec::new()),
384            strategy_count: AtomicU64::new(0),
385        }
386    }
387}
388
389impl WeaverKnowledgeBase {
390    /// Create a new, empty knowledge base.
391    pub fn new() -> Self {
392        Self::default()
393    }
394
395    /// Record a successful strategy.
396    pub fn record_strategy(&self, pattern: StrategyPattern) {
397        if let Ok(mut strategies) = self.strategies.write() {
398            strategies.push(pattern);
399            self.strategy_count.fetch_add(1, Ordering::Relaxed);
400        }
401    }
402
403    /// List all learned strategies.
404    pub fn list_strategies(&self) -> Vec<StrategyPattern> {
405        self.strategies
406            .read()
407            .map(|s| s.clone())
408            .unwrap_or_default()
409    }
410
411    /// Find strategies relevant to a given domain (simple substring match).
412    pub fn strategies_for(&self, domain: &str) -> Vec<StrategyPattern> {
413        self.strategies
414            .read()
415            .map(|all| {
416                all.iter()
417                    .filter(|s| {
418                        s.context.contains(domain)
419                            || domain.contains(&s.context)
420                    })
421                    .cloned()
422                    .collect()
423            })
424            .unwrap_or_default()
425    }
426
427    /// Export the full knowledge base as JSON.
428    pub fn export(&self) -> serde_json::Value {
429        serde_json::to_value(self.list_strategies()).unwrap_or_default()
430    }
431
432    /// Total number of learned strategies.
433    pub fn count(&self) -> u64 {
434        self.strategy_count.load(Ordering::Relaxed)
435    }
436}
437
438// ---------------------------------------------------------------------------
439// TickResult
440// ---------------------------------------------------------------------------
441
442/// Outcome of a single cognitive tick for the WeaverEngine.
443#[non_exhaustive]
444#[derive(Debug)]
445pub enum TickResult {
446    /// No active session; engine is idle.
447    Idle,
448    /// Budget exhausted before work could be done.
449    BudgetExhausted,
450    /// Progress was made.
451    Progress {
452        /// Current overall confidence.
453        confidence: f64,
454        /// Number of remaining gaps.
455        gaps_remaining: usize,
456    },
457}
458
459// ---------------------------------------------------------------------------
460// WeaverError
461// ---------------------------------------------------------------------------
462
463/// Errors produced by the WeaverEngine.
464#[non_exhaustive]
465#[derive(Debug)]
466pub enum WeaverError {
467    /// I/O error reading a file.
468    Io(std::io::Error),
469    /// JSON parsing error.
470    Json(serde_json::Error),
471    /// Domain logic error.
472    Domain(String),
473}
474
475impl fmt::Display for WeaverError {
476    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
477        match self {
478            Self::Io(e) => write!(f, "weaver I/O error: {e}"),
479            Self::Json(e) => write!(f, "weaver JSON error: {e}"),
480            Self::Domain(msg) => write!(f, "weaver error: {msg}"),
481        }
482    }
483}
484
485impl std::error::Error for WeaverError {}
486
487impl From<std::io::Error> for WeaverError {
488    fn from(e: std::io::Error) -> Self {
489        Self::Io(e)
490    }
491}
492
493impl From<serde_json::Error> for WeaverError {
494    fn from(e: serde_json::Error) -> Self {
495        Self::Json(e)
496    }
497}
498
499// ---------------------------------------------------------------------------
500// IngestResult
501// ---------------------------------------------------------------------------
502
503/// Statistics from ingesting a graph file into the WeaverEngine.
504#[derive(Debug, Clone, Serialize, Deserialize)]
505pub struct IngestResult {
506    /// Number of causal graph nodes created.
507    pub nodes_added: usize,
508    /// Number of causal graph edges created.
509    pub edges_added: usize,
510    /// Number of HNSW embeddings created.
511    pub embeddings_created: usize,
512    /// Source identifier (e.g., "git-history", "module-deps").
513    pub source: String,
514}
515
516// ---------------------------------------------------------------------------
517// GitPoller (incremental git change detection)
518// ---------------------------------------------------------------------------
519
520/// Incremental git polling state — detects new commits since last check.
521pub struct GitPoller {
522    /// Repository path.
523    repo_path: PathBuf,
524    /// Last known commit hash.
525    last_known_hash: Option<String>,
526    /// Branch to poll.
527    branch: String,
528    /// Polling enabled flag.
529    enabled: bool,
530}
531
532impl GitPoller {
533    /// Create a new poller for the given repository path and branch.
534    pub fn new(repo_path: PathBuf, branch: String) -> Self {
535        Self {
536            repo_path,
537            last_known_hash: None,
538            branch,
539            enabled: true,
540        }
541    }
542
543    /// Check for new commits since last poll.
544    /// Returns the number of new commits found (0 if none or on error).
545    pub fn poll(&mut self) -> usize {
546        if !self.enabled {
547            return 0;
548        }
549
550        let repo_str = self.repo_path.to_str().unwrap_or(".");
551        let output = std::process::Command::new("git")
552            .args(["-C", repo_str, "rev-parse", "HEAD"])
553            .output();
554
555        match output {
556            Ok(out) if out.status.success() => {
557                let current_hash = String::from_utf8_lossy(&out.stdout).trim().to_string();
558                if self.last_known_hash.as_deref() == Some(&current_hash) {
559                    return 0;
560                }
561
562                let count = if let Some(ref last) = self.last_known_hash {
563                    let count_output = std::process::Command::new("git")
564                        .args([
565                            "-C",
566                            repo_str,
567                            "rev-list",
568                            "--count",
569                            &format!("{}..{}", last, current_hash),
570                        ])
571                        .output();
572                    match count_output {
573                        Ok(o) if o.status.success() => {
574                            String::from_utf8_lossy(&o.stdout)
575                                .trim()
576                                .parse()
577                                .unwrap_or(1)
578                        }
579                        _ => 1,
580                    }
581                } else {
582                    1 // First poll — at least 1 commit exists
583                };
584
585                self.last_known_hash = Some(current_hash);
586                count
587            }
588            _ => 0,
589        }
590    }
591
592    /// Get the last known commit hash.
593    pub fn last_hash(&self) -> Option<&str> {
594        self.last_known_hash.as_deref()
595    }
596
597    /// Get the branch being polled.
598    pub fn branch(&self) -> &str {
599        &self.branch
600    }
601
602    /// Whether polling is enabled.
603    pub fn is_enabled(&self) -> bool {
604        self.enabled
605    }
606
607    /// Enable or disable polling.
608    pub fn set_enabled(&mut self, enabled: bool) {
609        self.enabled = enabled;
610    }
611}
612
613// ---------------------------------------------------------------------------
614// FileWatcher (mtime-based change detection)
615// ---------------------------------------------------------------------------
616
617/// Simple file change detector using modification timestamps.
618///
619/// Avoids the `notify` crate dependency by comparing cached mtimes on each
620/// poll call. Only watches files that have been explicitly registered.
621pub struct FileWatcher {
622    /// Watched paths with their last known mtime.
623    watched: HashMap<PathBuf, SystemTime>,
624    /// Root directory to scan for initial registration.
625    root: PathBuf,
626    /// File patterns to match (e.g., `"*.rs"`, `"Cargo.toml"`).
627    patterns: Vec<String>,
628    /// Enabled flag.
629    enabled: bool,
630}
631
632impl FileWatcher {
633    /// Create a new file watcher for the given root and patterns.
634    pub fn new(root: PathBuf, patterns: Vec<String>) -> Self {
635        Self {
636            watched: HashMap::new(),
637            root,
638            patterns,
639            enabled: true,
640        }
641    }
642
643    /// Scan watched files for mtime changes since last check.
644    /// Returns paths of files that changed or were deleted.
645    pub fn poll_changes(&mut self) -> Vec<PathBuf> {
646        if !self.enabled {
647            return vec![];
648        }
649
650        let mut changed = Vec::new();
651        let entries: Vec<PathBuf> = self.watched.keys().cloned().collect();
652
653        for path in entries {
654            if let Ok(metadata) = std::fs::metadata(&path) {
655                if let Ok(mtime) = metadata.modified() {
656                    if let Some(last_mtime) = self.watched.get(&path) {
657                        if mtime > *last_mtime {
658                            changed.push(path.clone());
659                            self.watched.insert(path, mtime);
660                        }
661                    }
662                }
663            } else {
664                // File deleted
665                changed.push(path.clone());
666                self.watched.remove(&path);
667            }
668        }
669
670        changed
671    }
672
673    /// Register a single file to watch.
674    pub fn watch(&mut self, path: PathBuf) {
675        if let Ok(metadata) = std::fs::metadata(&path) {
676            if let Ok(mtime) = metadata.modified() {
677                self.watched.insert(path, mtime);
678            }
679        }
680    }
681
682    /// Register all files matching patterns in the root directory (non-recursive).
683    pub fn watch_directory(&mut self) {
684        if let Ok(entries) = std::fs::read_dir(&self.root) {
685            for entry in entries.flatten() {
686                let path = entry.path();
687                if path.is_file() {
688                    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
689                    let matches = self.patterns.iter().any(|p| {
690                        if p.starts_with("*.") {
691                            name.ends_with(&p[1..])
692                        } else {
693                            name == p
694                        }
695                    });
696                    if matches {
697                        self.watch(path);
698                    }
699                }
700            }
701        }
702    }
703
704    /// Number of watched files.
705    pub fn watched_count(&self) -> usize {
706        self.watched.len()
707    }
708
709    /// Whether file watching is enabled.
710    pub fn is_enabled(&self) -> bool {
711        self.enabled
712    }
713
714    /// Enable or disable file watching.
715    pub fn set_enabled(&mut self, enabled: bool) {
716        self.enabled = enabled;
717    }
718}
719
720// ---------------------------------------------------------------------------
721// CognitiveTickResult
722// ---------------------------------------------------------------------------
723
724/// Detailed outcome of a single cognitive tick processed by the WeaverEngine.
725///
726/// Complements the existing [`TickResult`] enum with per-tick metrics for the
727/// CognitiveTick integration (git polling, file watching, ingestion progress).
728#[derive(Debug, Clone, Default, Serialize, Deserialize)]
729pub struct CognitiveTickResult {
730    /// Which tick number this result corresponds to.
731    pub tick_number: u64,
732    /// Actual wall-clock time consumed by this tick (ms).
733    pub elapsed_ms: u32,
734    /// Budget allocated for this tick (ms).
735    pub budget_ms: u32,
736    /// Number of new git commits detected during this tick.
737    pub git_commits_found: usize,
738    /// Number of source files that changed since last tick.
739    pub files_changed: usize,
740    /// Number of pending nodes processed during ingestion phase.
741    pub nodes_processed: usize,
742    /// Whether the confidence report was recomputed this tick.
743    pub confidence_updated: bool,
744    /// Whether the tick completed within its budget.
745    pub within_budget: bool,
746}
747
748// ---------------------------------------------------------------------------
749// ConfidenceHistory (Item 1: confidence history tracking)
750// ---------------------------------------------------------------------------
751
752/// What triggered a confidence measurement.
753#[non_exhaustive]
754#[derive(Debug, Clone, Serialize, Deserialize)]
755pub enum ConfidenceTrigger {
756    /// Every N ticks.
757    Periodic,
758    /// After a graph file was ingested.
759    PostIngestion,
760    /// Explicit evaluation request.
761    Manual,
762    /// After a modeling adjustment.
763    StrategyChange,
764}
765
766/// A point-in-time confidence snapshot for history tracking.
767#[derive(Debug, Clone, Serialize, Deserialize)]
768pub struct ConfidenceSnapshot {
769    /// Timestamp of this measurement.
770    pub timestamp: DateTime<Utc>,
771    /// Tick number when measured.
772    pub tick_number: u64,
773    /// Overall confidence score.
774    pub confidence: f64,
775    /// Number of nodes in the graph.
776    pub node_count: usize,
777    /// Number of edges in the graph.
778    pub edge_count: usize,
779    /// Number of gaps identified.
780    pub gap_count: usize,
781    /// What triggered this measurement.
782    pub trigger: ConfidenceTrigger,
783}
784
785/// Direction of a confidence trend.
786#[non_exhaustive]
787#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
788pub enum TrendDirection {
789    /// Confidence is improving over time.
790    Improving,
791    /// Confidence is roughly stable.
792    Stable,
793    /// Confidence is declining over time.
794    Declining,
795}
796
797/// Summary of confidence movement over a window of snapshots.
798#[derive(Debug, Clone, Serialize, Deserialize)]
799pub struct ConfidenceTrend {
800    /// Overall direction.
801    pub direction: TrendDirection,
802    /// Change over the window (last - first).
803    pub delta: f64,
804    /// Average confidence in the window.
805    pub avg_confidence: f64,
806    /// Number of samples in the window.
807    pub samples: usize,
808}
809
810/// Ring-buffer of confidence snapshots.
811pub struct ConfidenceHistory {
812    snapshots: VecDeque<ConfidenceSnapshot>,
813    max_entries: usize,
814}
815
816impl ConfidenceHistory {
817    /// Create a new history with the given capacity.
818    pub fn new(max_entries: usize) -> Self {
819        Self {
820            snapshots: VecDeque::with_capacity(max_entries),
821            max_entries,
822        }
823    }
824
825    /// Record a new snapshot, evicting the oldest if at capacity.
826    pub fn record(&mut self, snapshot: ConfidenceSnapshot) {
827        if self.snapshots.len() >= self.max_entries {
828            self.snapshots.pop_front();
829        }
830        self.snapshots.push_back(snapshot);
831    }
832
833    /// Get the most recent snapshot.
834    pub fn latest(&self) -> Option<&ConfidenceSnapshot> {
835        self.snapshots.back()
836    }
837
838    /// Compute the trend over the last `last_n` snapshots.
839    pub fn trend(&self, last_n: usize) -> ConfidenceTrend {
840        let n = last_n.min(self.snapshots.len());
841        if n == 0 {
842            return ConfidenceTrend {
843                direction: TrendDirection::Stable,
844                delta: 0.0,
845                avg_confidence: 0.0,
846                samples: 0,
847            };
848        }
849
850        let start = self.snapshots.len() - n;
851        let window: Vec<&ConfidenceSnapshot> =
852            self.snapshots.iter().skip(start).collect();
853
854        let sum: f64 = window.iter().map(|s| s.confidence).sum();
855        let avg = sum / n as f64;
856        let first = window.first().map(|s| s.confidence).unwrap_or(0.0);
857        let last = window.last().map(|s| s.confidence).unwrap_or(0.0);
858        let delta = last - first;
859
860        let direction = if delta > 0.01 {
861            TrendDirection::Improving
862        } else if delta < -0.01 {
863            TrendDirection::Declining
864        } else {
865            TrendDirection::Stable
866        };
867
868        ConfidenceTrend {
869            direction,
870            delta,
871            avg_confidence: avg,
872            samples: n,
873        }
874    }
875
876    /// Get all snapshots.
877    pub fn all(&self) -> &VecDeque<ConfidenceSnapshot> {
878        &self.snapshots
879    }
880
881    /// Number of recorded snapshots.
882    pub fn len(&self) -> usize {
883        self.snapshots.len()
884    }
885
886    /// Whether the history is empty.
887    pub fn is_empty(&self) -> bool {
888        self.snapshots.is_empty()
889    }
890}
891
892// ---------------------------------------------------------------------------
893// StrategyTracker (Item 4: strategy effectiveness tracking)
894// ---------------------------------------------------------------------------
895
896/// Handle returned by `begin_strategy` to pair with `complete_strategy`.
897#[derive(Debug)]
898pub struct StrategyHandle {
899    /// Strategy name.
900    pub name: String,
901    /// Description of the change.
902    pub description: String,
903    /// Confidence at the start of the strategy.
904    pub confidence_before: f64,
905    /// When the strategy was started.
906    pub started_at: DateTime<Utc>,
907}
908
909/// A record of a strategy change and its impact on confidence.
910#[derive(Debug, Clone, Serialize, Deserialize)]
911pub struct StrategyOutcome {
912    /// What was changed.
913    pub strategy: String,
914    /// Description of the change.
915    pub description: String,
916    /// Confidence before the change.
917    pub confidence_before: f64,
918    /// Confidence after the change.
919    pub confidence_after: f64,
920    /// Delta (positive = improvement).
921    pub delta: f64,
922    /// When the change was made.
923    pub timestamp: DateTime<Utc>,
924    /// Whether this was beneficial (delta > 0.01).
925    pub beneficial: bool,
926}
927
928/// Tracker that learns which strategy changes improve confidence.
929pub struct StrategyTracker {
930    outcomes: Vec<StrategyOutcome>,
931    max_outcomes: usize,
932}
933
934impl StrategyTracker {
935    /// Create a new tracker with the given capacity.
936    pub fn new(max_outcomes: usize) -> Self {
937        Self {
938            outcomes: Vec::new(),
939            max_outcomes,
940        }
941    }
942
943    /// Begin tracking a strategy. Returns a handle for `complete_strategy`.
944    pub fn begin_strategy(
945        &self,
946        name: &str,
947        description: &str,
948        current_confidence: f64,
949    ) -> StrategyHandle {
950        StrategyHandle {
951            name: name.to_string(),
952            description: description.to_string(),
953            confidence_before: current_confidence,
954            started_at: Utc::now(),
955        }
956    }
957
958    /// Complete a strategy and record its outcome.
959    pub fn complete_strategy(
960        &mut self,
961        handle: StrategyHandle,
962        new_confidence: f64,
963    ) {
964        let delta = new_confidence - handle.confidence_before;
965        let outcome = StrategyOutcome {
966            strategy: handle.name,
967            description: handle.description,
968            confidence_before: handle.confidence_before,
969            confidence_after: new_confidence,
970            delta,
971            timestamp: handle.started_at,
972            beneficial: delta > 0.01,
973        };
974
975        if self.outcomes.len() >= self.max_outcomes {
976            self.outcomes.remove(0);
977        }
978        self.outcomes.push(outcome);
979    }
980
981    /// Get the most effective strategies, sorted by delta descending.
982    pub fn most_effective(&self, top_n: usize) -> Vec<&StrategyOutcome> {
983        let mut sorted: Vec<&StrategyOutcome> = self.outcomes.iter().collect();
984        sorted.sort_by(|a, b| {
985            b.delta
986                .partial_cmp(&a.delta)
987                .unwrap_or(std::cmp::Ordering::Equal)
988        });
989        sorted.truncate(top_n);
990        sorted
991    }
992
993    /// Get strategies that hurt confidence (negative delta).
994    pub fn harmful_strategies(&self) -> Vec<&StrategyOutcome> {
995        self.outcomes
996            .iter()
997            .filter(|o| o.delta < -0.01)
998            .collect()
999    }
1000
1001    /// Recommend next strategy based on past effectiveness.
1002    ///
1003    /// Returns the name of the most effective beneficial strategy,
1004    /// or `None` if no beneficial strategies have been recorded.
1005    pub fn recommend(&self) -> Option<String> {
1006        self.outcomes
1007            .iter()
1008            .filter(|o| o.beneficial)
1009            .max_by(|a, b| {
1010                a.delta
1011                    .partial_cmp(&b.delta)
1012                    .unwrap_or(std::cmp::Ordering::Equal)
1013            })
1014            .map(|o| o.strategy.clone())
1015    }
1016
1017    /// All recorded outcomes.
1018    pub fn outcomes(&self) -> &[StrategyOutcome] {
1019        &self.outcomes
1020    }
1021
1022    /// Number of recorded outcomes.
1023    pub fn len(&self) -> usize {
1024        self.outcomes.len()
1025    }
1026
1027    /// Whether the tracker is empty.
1028    pub fn is_empty(&self) -> bool {
1029        self.outcomes.is_empty()
1030    }
1031}
1032
1033// ---------------------------------------------------------------------------
1034// TickHistory / TickRecommendation (Item 6: tick interval recommendation)
1035// ---------------------------------------------------------------------------
1036
1037/// Tick interval recommendation based on observed change patterns.
1038#[derive(Debug, Clone, Serialize, Deserialize)]
1039pub struct TickRecommendation {
1040    /// Recommended interval in milliseconds.
1041    pub recommended_ms: u32,
1042    /// Current interval.
1043    pub current_ms: u32,
1044    /// Reason for recommendation.
1045    pub reason: String,
1046    /// Observed changes per minute.
1047    pub changes_per_minute: f64,
1048    /// Confidence in this recommendation (0.0 - 1.0).
1049    pub recommendation_confidence: f64,
1050}
1051
1052/// Ring-buffer of recent tick results for analysis.
1053pub struct TickHistory {
1054    results: VecDeque<CognitiveTickResult>,
1055    max_entries: usize,
1056}
1057
1058impl TickHistory {
1059    /// Create a new tick history with the given capacity.
1060    pub fn new(max_entries: usize) -> Self {
1061        Self {
1062            results: VecDeque::with_capacity(max_entries),
1063            max_entries,
1064        }
1065    }
1066
1067    /// Record a tick result, evicting the oldest if at capacity.
1068    pub fn record(&mut self, result: CognitiveTickResult) {
1069        if self.results.len() >= self.max_entries {
1070            self.results.pop_front();
1071        }
1072        self.results.push_back(result);
1073    }
1074
1075    /// Compute the average changes per minute based on recorded ticks.
1076    ///
1077    /// Uses total elapsed time and total change events to derive rate.
1078    pub fn changes_per_minute(&self) -> f64 {
1079        if self.results.len() < 2 {
1080            return 0.0;
1081        }
1082
1083        let total_changes: usize = self
1084            .results
1085            .iter()
1086            .map(|r| r.git_commits_found + r.files_changed)
1087            .sum();
1088
1089        let total_elapsed_ms: u64 =
1090            self.results.iter().map(|r| r.elapsed_ms as u64).sum();
1091        if total_elapsed_ms == 0 {
1092            return 0.0;
1093        }
1094
1095        let minutes = total_elapsed_ms as f64 / 60_000.0;
1096        if minutes < 0.001 {
1097            return 0.0;
1098        }
1099
1100        total_changes as f64 / minutes
1101    }
1102
1103    /// Compute average budget usage ratio (elapsed / budget).
1104    pub fn avg_budget_usage(&self) -> f64 {
1105        if self.results.is_empty() {
1106            return 0.0;
1107        }
1108
1109        let sum: f64 = self
1110            .results
1111            .iter()
1112            .filter(|r| r.budget_ms > 0)
1113            .map(|r| r.elapsed_ms as f64 / r.budget_ms as f64)
1114            .sum();
1115
1116        let count = self
1117            .results
1118            .iter()
1119            .filter(|r| r.budget_ms > 0)
1120            .count();
1121
1122        if count == 0 {
1123            return 0.0;
1124        }
1125
1126        sum / count as f64
1127    }
1128
1129    /// Count consecutive idle ticks (no changes) at the tail.
1130    pub fn idle_ticks(&self) -> usize {
1131        self.results
1132            .iter()
1133            .rev()
1134            .take_while(|r| {
1135                r.git_commits_found == 0
1136                    && r.files_changed == 0
1137                    && r.nodes_processed == 0
1138            })
1139            .count()
1140    }
1141
1142    /// Number of recorded results.
1143    pub fn len(&self) -> usize {
1144        self.results.len()
1145    }
1146
1147    /// Whether the history is empty.
1148    pub fn is_empty(&self) -> bool {
1149        self.results.is_empty()
1150    }
1151
1152    /// All recorded tick results.
1153    pub fn all(&self) -> &VecDeque<CognitiveTickResult> {
1154        &self.results
1155    }
1156}
1157
1158// ---------------------------------------------------------------------------
1159// WeaverEngine
1160// ---------------------------------------------------------------------------
1161
1162/// ECC-powered codebase modeling service.
1163///
1164/// Manages modeling sessions, drives confidence evaluation via the
1165/// causal graph, and records modeling decisions in the Meta-Loom.
1166pub struct WeaverEngine {
1167    /// Active modeling sessions keyed by domain.
1168    sessions: RwLock<HashMap<String, ModelingSession>>,
1169    /// Cross-domain knowledge base.
1170    knowledge_base: Arc<WeaverKnowledgeBase>,
1171    /// Embedding provider for vectorization.
1172    embedding_provider: Arc<dyn EmbeddingProvider>,
1173    /// Causal graph reference.
1174    causal_graph: Arc<CausalGraph>,
1175    /// HNSW service reference.
1176    #[allow(dead_code)]
1177    hnsw: Arc<HnswService>,
1178    /// Impulse queue for emitting meta-loom signals.
1179    impulse_queue: Option<Arc<ImpulseQueue>>,
1180    /// Meta-loom event history per domain.
1181    meta_loom_events: RwLock<HashMap<String, Vec<MetaLoomEvent>>>,
1182    /// Total ticks processed across all sessions.
1183    tick_count: AtomicU64,
1184    /// Git poller for incremental commit detection.
1185    git_poller: Option<GitPoller>,
1186    /// File watcher for source file change detection.
1187    file_watcher: Option<FileWatcher>,
1188    /// Ticks since the last confidence recomputation.
1189    ticks_since_confidence_update: u64,
1190    /// Last computed confidence report (cached).
1191    last_confidence: Option<ConfidenceReport>,
1192    /// Confidence history ring buffer (Item 1).
1193    confidence_history: ConfidenceHistory,
1194    /// Strategy effectiveness tracker (Item 4).
1195    strategy_tracker: StrategyTracker,
1196    /// Tick result history for interval recommendation (Item 6).
1197    tick_history: TickHistory,
1198    /// Current tick interval in milliseconds (Item 6).
1199    current_tick_interval_ms: u32,
1200}
1201
1202impl WeaverEngine {
1203    /// Create a new WeaverEngine with the given dependencies.
1204    pub fn new(
1205        causal_graph: Arc<CausalGraph>,
1206        hnsw: Arc<HnswService>,
1207        embedding_provider: Arc<dyn EmbeddingProvider>,
1208    ) -> Self {
1209        Self {
1210            sessions: RwLock::new(HashMap::new()),
1211            knowledge_base: Arc::new(WeaverKnowledgeBase::new()),
1212            embedding_provider,
1213            causal_graph,
1214            hnsw,
1215            impulse_queue: None,
1216            meta_loom_events: RwLock::new(HashMap::new()),
1217            tick_count: AtomicU64::new(0),
1218            git_poller: None,
1219            file_watcher: None,
1220            ticks_since_confidence_update: 0,
1221            last_confidence: None,
1222            confidence_history: ConfidenceHistory::new(500),
1223            strategy_tracker: StrategyTracker::new(200),
1224            tick_history: TickHistory::new(500),
1225            current_tick_interval_ms: 1000,
1226        }
1227    }
1228
1229    /// Create a WeaverEngine with a mock embedding provider (for tests).
1230    pub fn new_with_mock(
1231        causal_graph: Arc<CausalGraph>,
1232        hnsw: Arc<HnswService>,
1233    ) -> Self {
1234        Self::new(
1235            causal_graph,
1236            hnsw,
1237            Arc::new(MockEmbeddingProvider::new(64)),
1238        )
1239    }
1240
1241    /// Set the impulse queue for emitting meta-loom signals.
1242    pub fn set_impulse_queue(&mut self, queue: Arc<ImpulseQueue>) {
1243        self.impulse_queue = Some(queue);
1244    }
1245
1246    /// Get a reference to the knowledge base.
1247    pub fn knowledge_base(&self) -> &Arc<WeaverKnowledgeBase> {
1248        &self.knowledge_base
1249    }
1250
1251    /// Get a reference to the embedding provider.
1252    pub fn embedding_provider(&self) -> &Arc<dyn EmbeddingProvider> {
1253        &self.embedding_provider
1254    }
1255
1256    /// Get a reference to the causal graph.
1257    pub fn causal_graph(&self) -> &Arc<CausalGraph> {
1258        &self.causal_graph
1259    }
1260
1261    /// Get a reference to the HNSW service.
1262    pub fn hnsw(&self) -> &Arc<HnswService> {
1263        &self.hnsw
1264    }
1265
1266    // ── Graph file ingestion ──────────────────────────────────────
1267
1268    /// Ingest a graph JSON file (git-history, module-deps, or decisions).
1269    ///
1270    /// Reads a `.weftos/graph/*.json` file, creates causal graph nodes for
1271    /// each entry, creates edges between related nodes, and inserts
1272    /// embeddings into the HNSW index for each node's text representation.
1273    pub fn ingest_graph_file(&self, path: &Path) -> Result<IngestResult, WeaverError> {
1274        let data = std::fs::read_to_string(path)?;
1275        let graph: serde_json::Value = serde_json::from_str(&data)?;
1276
1277        let source = graph["source"]
1278            .as_str()
1279            .unwrap_or("unknown")
1280            .to_string();
1281
1282        let empty_vec = vec![];
1283        let nodes = graph["nodes"].as_array().unwrap_or(&empty_vec);
1284        let edges = graph["edges"].as_array().unwrap_or(&empty_vec);
1285
1286        let mut nodes_added = 0usize;
1287        let mut edges_added = 0usize;
1288        let mut embeddings_created = 0usize;
1289
1290        // Map from JSON node id (string) to causal graph NodeId.
1291        let mut id_map: HashMap<String, u64> = HashMap::with_capacity(nodes.len());
1292
1293        // Phase 1: Create nodes.
1294        for node in nodes {
1295            let node_id_str = node["id"].as_str().unwrap_or("").to_string();
1296            if node_id_str.is_empty() {
1297                continue;
1298            }
1299
1300            let label = if let Some(title) = node["title"].as_str() {
1301                format!("{source}/{node_id_str}: {title}")
1302            } else if let Some(subject) = node["subject"].as_str() {
1303                format!("{source}/{node_id_str}: {subject}")
1304            } else {
1305                format!("{source}/{node_id_str}")
1306            };
1307
1308            let causal_id = self.causal_graph.add_node(
1309                label.clone(),
1310                node.clone(),
1311            );
1312            id_map.insert(node_id_str.clone(), causal_id);
1313            nodes_added += 1;
1314
1315            // Create an HNSW embedding for the node's text.
1316            let embed_text = Self::node_to_embed_text(node, &source);
1317            if !embed_text.is_empty() {
1318                // Use synchronous hash-embed for ingestion (avoiding async).
1319                let embed_vec = self.sync_embed(&embed_text);
1320                self.hnsw.insert(
1321                    format!("{source}/{}", node_id_str),
1322                    embed_vec,
1323                    node.clone(),
1324                );
1325                embeddings_created += 1;
1326            }
1327        }
1328
1329        // Phase 2: Create edges.
1330        for edge in edges {
1331            let from_str = edge["from"].as_str().unwrap_or("");
1332            let to_str = edge["to"].as_str().unwrap_or("");
1333            let edge_type_str = edge["type"].as_str().unwrap_or("Correlates");
1334            let weight = edge["weight"].as_f64().unwrap_or(1.0) as f32;
1335
1336            let from_id = id_map.get(from_str).copied();
1337            let to_id = id_map.get(to_str).copied();
1338
1339            if let (Some(src), Some(tgt)) = (from_id, to_id) {
1340                let edge_type = Self::parse_edge_type(edge_type_str);
1341                let linked = self.causal_graph.link(
1342                    src, tgt, edge_type, weight, 0, 0,
1343                );
1344                if linked {
1345                    edges_added += 1;
1346                }
1347            }
1348        }
1349
1350        info!(
1351            source = %source,
1352            nodes_added,
1353            edges_added,
1354            embeddings_created,
1355            "graph file ingested"
1356        );
1357
1358        Ok(IngestResult {
1359            nodes_added,
1360            edges_added,
1361            embeddings_created,
1362            source,
1363        })
1364    }
1365
1366    /// Ingest a graph file with strategy tracking and confidence history.
1367    ///
1368    /// Wraps [`ingest_graph_file`] with before/after confidence measurement,
1369    /// recording the result in the [`StrategyTracker`] and
1370    /// [`ConfidenceHistory`].
1371    pub fn ingest_graph_file_tracked(
1372        &mut self,
1373        path: &Path,
1374    ) -> Result<IngestResult, WeaverError> {
1375        let confidence_before = self.compute_confidence().overall;
1376        let source_name = path
1377            .file_stem()
1378            .and_then(|s| s.to_str())
1379            .unwrap_or("unknown");
1380        let handle = self.strategy_tracker.begin_strategy(
1381            &format!("ingest:{source_name}"),
1382            &format!("Ingesting graph file {}", path.display()),
1383            confidence_before,
1384        );
1385
1386        let result = self.ingest_graph_file(path)?;
1387
1388        let report = self.compute_confidence();
1389        let confidence_after = report.overall;
1390
1391        // Record strategy outcome (Item 4).
1392        self.strategy_tracker
1393            .complete_strategy(handle, confidence_after);
1394
1395        // Record post-ingestion confidence snapshot (Item 1).
1396        let snapshot = ConfidenceSnapshot {
1397            timestamp: Utc::now(),
1398            tick_number: self.tick_count.load(Ordering::Relaxed),
1399            confidence: confidence_after,
1400            node_count: self.causal_graph.node_count() as usize,
1401            edge_count: self.causal_graph.edge_count() as usize,
1402            gap_count: report.gaps.len(),
1403            trigger: ConfidenceTrigger::PostIngestion,
1404        };
1405        self.confidence_history.record(snapshot);
1406
1407        // Also record a StrategyChange snapshot if confidence changed.
1408        if (confidence_after - confidence_before).abs() > 0.001 {
1409            let change_snapshot = ConfidenceSnapshot {
1410                timestamp: Utc::now(),
1411                tick_number: self.tick_count.load(Ordering::Relaxed),
1412                confidence: confidence_after,
1413                node_count: self.causal_graph.node_count() as usize,
1414                edge_count: self.causal_graph.edge_count() as usize,
1415                gap_count: report.gaps.len(),
1416                trigger: ConfidenceTrigger::StrategyChange,
1417            };
1418            self.confidence_history.record(change_snapshot);
1419        }
1420
1421        Ok(result)
1422    }
1423
1424    /// Convert a graph node's fields into embeddable text.
1425    fn node_to_embed_text(node: &serde_json::Value, source: &str) -> String {
1426        match source {
1427            "git-history" => {
1428                let subject = node["subject"].as_str().unwrap_or("");
1429                let author = node["author"].as_str().unwrap_or("");
1430                let files = node["files"]
1431                    .as_array()
1432                    .map(|arr| {
1433                        arr.iter()
1434                            .filter_map(|v| v.as_str())
1435                            .collect::<Vec<_>>()
1436                            .join(", ")
1437                    })
1438                    .unwrap_or_default();
1439                format!("commit by {author}: {subject} files: {files}")
1440            }
1441            "module-dependencies" => {
1442                let id = node["id"].as_str().unwrap_or("");
1443                let deps = node["dependencies"]
1444                    .as_array()
1445                    .map(|arr| {
1446                        arr.iter()
1447                            .filter_map(|v| v.as_str())
1448                            .collect::<Vec<_>>()
1449                            .join(", ")
1450                    })
1451                    .unwrap_or_default();
1452                let lines = node["lines"].as_u64().unwrap_or(0);
1453                format!("module {id} ({lines} lines) depends on: {deps}")
1454            }
1455            "decisions-and-phases" => {
1456                let title = node["title"].as_str().unwrap_or("");
1457                let rationale = node["rationale"].as_str().unwrap_or("");
1458                let panel = node["panel"].as_str().unwrap_or("");
1459                format!("decision ({panel}): {title} — {rationale}")
1460            }
1461            _ => {
1462                // Fallback: serialize the whole node.
1463                serde_json::to_string(node).unwrap_or_default()
1464            }
1465        }
1466    }
1467
1468    /// Parse an edge type string to a CausalEdgeType.
1469    fn parse_edge_type(s: &str) -> CausalEdgeType {
1470        match s {
1471            "Causes" => CausalEdgeType::Causes,
1472            "Inhibits" => CausalEdgeType::Inhibits,
1473            "Correlates" => CausalEdgeType::Correlates,
1474            "Enables" => CausalEdgeType::Enables,
1475            "Follows" => CausalEdgeType::Follows,
1476            "Contradicts" => CausalEdgeType::Contradicts,
1477            "TriggeredBy" => CausalEdgeType::TriggeredBy,
1478            "EvidenceFor" => CausalEdgeType::EvidenceFor,
1479            _ => CausalEdgeType::Correlates,
1480        }
1481    }
1482
1483    /// Synchronous embedding using the mock fallback (for ingestion loops).
1484    ///
1485    /// This avoids the need for async in the ingestion path. The mock
1486    /// provider's `hash_embed` is deterministic and instant.
1487    fn sync_embed(&self, text: &str) -> Vec<f32> {
1488        use sha2::{Digest, Sha256};
1489        let dims = self.embedding_provider.dimensions();
1490        let mut hasher = Sha256::new();
1491        hasher.update(text.as_bytes());
1492        let hash = hasher.finalize();
1493        let mut vec = Vec::with_capacity(dims);
1494        for i in 0..dims {
1495            let byte = hash[i % 32];
1496            vec.push((byte as f32 / 128.0) - 1.0);
1497        }
1498        vec
1499    }
1500
1501    // ── Confidence scoring from graph data ────────────────────────
1502
1503    /// Compute confidence based on graph coverage.
1504    ///
1505    /// Examines the causal graph to determine what fraction of nodes have
1506    /// edges (both incoming and outgoing), the edge density, and identifies
1507    /// orphan nodes that lack causal connections.
1508    pub fn compute_confidence(&self) -> ConfidenceReport {
1509        let node_count = self.causal_graph.node_count() as usize;
1510        let edge_count = self.causal_graph.edge_count() as usize;
1511
1512        if node_count == 0 {
1513            return ConfidenceReport {
1514                overall: 0.0,
1515                gaps: vec![ConfidenceGap {
1516                    domain: "graph".to_string(),
1517                    current_confidence: 0.0,
1518                    target_confidence: 0.8,
1519                    suggested_sources: vec![
1520                        "git_log".into(),
1521                        "module_deps".into(),
1522                        "decisions".into(),
1523                    ],
1524                }],
1525                suggestions: vec![ModelingSuggestion::AddSource {
1526                    source_type: "git_log".to_string(),
1527                    reason: "No graph data ingested yet".to_string(),
1528                }],
1529            };
1530        }
1531
1532        // Edge density: ratio of actual edges to maximum possible.
1533        let max_edges = if node_count > 1 {
1534            node_count * (node_count - 1)
1535        } else {
1536            1
1537        };
1538        let edge_density = (edge_count as f64 / max_edges as f64).min(1.0);
1539
1540        // Node connectivity: fraction of nodes that have at least one edge.
1541        // We sample by checking forward + reverse edges for each node id up to
1542        // the known count (sequential IDs starting from 1).
1543        let mut connected_nodes = 0usize;
1544        let mut orphan_labels: Vec<String> = Vec::new();
1545        let next_id = self.causal_graph.node_count() + 1;
1546        // Iterate over plausible node IDs. The CausalGraph allocates IDs
1547        // sequentially starting at 1 so scanning 1..next_id covers all.
1548        for nid in 1..next_id {
1549            if self.causal_graph.get_node(nid).is_some() {
1550                let fwd = self.causal_graph.get_forward_edges(nid);
1551                let rev = self.causal_graph.get_reverse_edges(nid);
1552                if !fwd.is_empty() || !rev.is_empty() {
1553                    connected_nodes += 1;
1554                } else if let Some(node) = self.causal_graph.get_node(nid) {
1555                    orphan_labels.push(node.label.clone());
1556                }
1557            }
1558        }
1559
1560        let connectivity = if node_count > 0 {
1561            connected_nodes as f64 / node_count as f64
1562        } else {
1563            0.0
1564        };
1565
1566        // Composite confidence: weighted average of components.
1567        // - Connectivity (40%): nodes with edges / total nodes
1568        // - Edge density (20%): capped contribution from edge density
1569        // - Node volume (20%): diminishing returns above 100 nodes
1570        // - Source diversity (20%): number of distinct source prefixes
1571        let volume_score = (node_count as f64 / 100.0).min(1.0);
1572        let density_capped = (edge_density * 50.0).min(1.0); // amplify sparse graphs
1573
1574        let overall = (connectivity * 0.40
1575            + density_capped * 0.20
1576            + volume_score * 0.20
1577            + self.source_diversity_score() * 0.20)
1578            .min(1.0);
1579
1580        // Build gaps.
1581        let mut gaps = Vec::new();
1582        if connectivity < 0.7 {
1583            gaps.push(ConfidenceGap {
1584                domain: "node_connectivity".to_string(),
1585                current_confidence: connectivity,
1586                target_confidence: 0.7,
1587                suggested_sources: vec!["module_deps".into(), "git_log".into()],
1588            });
1589        }
1590        if volume_score < 0.5 {
1591            gaps.push(ConfidenceGap {
1592                domain: "data_volume".to_string(),
1593                current_confidence: volume_score,
1594                target_confidence: 0.5,
1595                suggested_sources: vec!["git_log".into(), "file_tree".into()],
1596            });
1597        }
1598
1599        // Suggestions from orphan nodes.
1600        let mut suggestions = Vec::new();
1601        if !orphan_labels.is_empty() {
1602            let sample: Vec<_> = orphan_labels.iter().take(5).cloned().collect();
1603            suggestions.push(ModelingSuggestion::AddSource {
1604                source_type: "causal_edges".to_string(),
1605                reason: format!(
1606                    "{} orphan nodes without edges (e.g., {})",
1607                    orphan_labels.len(),
1608                    sample.join(", ")
1609                ),
1610            });
1611        }
1612
1613        ConfidenceReport {
1614            overall,
1615            gaps,
1616            suggestions,
1617        }
1618    }
1619
1620    /// Count distinct source prefixes in node labels to gauge diversity.
1621    fn source_diversity_score(&self) -> f64 {
1622        let sessions = match self.sessions.read() {
1623            Ok(s) => s,
1624            Err(_) => return 0.0,
1625        };
1626        let total_sources: usize = sessions.values().map(|s| s.sources_ingested.len()).sum();
1627        // Diminishing returns: 3 sources = 1.0.
1628        (total_sources as f64 / 3.0).min(1.0)
1629    }
1630
1631    // ── Model export to file ──────────────────────────────────────
1632
1633    /// Export the current model state to a JSON file at the given path.
1634    ///
1635    /// Produces a `weave-model.json` that includes the causal graph nodes,
1636    /// edges, confidence report, and metadata.
1637    pub fn export_model_to_file(
1638        &self,
1639        domain: &str,
1640        min_confidence: f64,
1641        path: &Path,
1642    ) -> Result<ExportedModel, WeaverError> {
1643        let model = self.export_model(domain, min_confidence)
1644            .map_err(WeaverError::Domain)?;
1645        let json = serde_json::to_string_pretty(&model)?;
1646        std::fs::write(path, json)?;
1647        info!(domain, ?path, "model exported to file");
1648        Ok(model)
1649    }
1650
1651    /// Import a model from a JSON file.
1652    pub fn import_model_from_file(
1653        &self,
1654        domain: &str,
1655        path: &Path,
1656    ) -> Result<(), WeaverError> {
1657        let data = std::fs::read_to_string(path)?;
1658        let model: ExportedModel = serde_json::from_str(&data)?;
1659        self.import_model(domain, model)
1660            .map_err(WeaverError::Domain)?;
1661        info!(domain, ?path, "model imported from file");
1662        Ok(())
1663    }
1664
1665    // ── Session management ────────────────────────────────────────
1666
1667    /// Start a new modeling session.
1668    pub fn start_session(
1669        &self,
1670        domain: &str,
1671        context: Option<&str>,
1672        _goal: Option<&str>,
1673    ) -> Result<String, String> {
1674        let session_id = uuid::Uuid::new_v4().to_string();
1675        let session = ModelingSession {
1676            id: session_id.clone(),
1677            domain: domain.to_string(),
1678            started_at: Utc::now(),
1679            confidence: 0.0,
1680            gaps: Vec::new(),
1681            sources_ingested: Vec::new(),
1682            tick_count: 0,
1683            budget_remaining_ms: 300_000, // 5 min default
1684            active: true,
1685            metadata: {
1686                let cap = if context.is_some() { 1 } else { 0 };
1687                let mut m = HashMap::with_capacity(cap);
1688                if let Some(ctx) = context {
1689                    m.insert("context".to_string(), serde_json::Value::String(ctx.to_string()));
1690                }
1691                m
1692            },
1693        };
1694
1695        let mut sessions = self.sessions.write().map_err(|e| e.to_string())?;
1696        if sessions.contains_key(domain) {
1697            return Err(format!("session already exists for domain: {domain}"));
1698        }
1699        sessions.insert(domain.to_string(), session);
1700
1701        // Record meta-loom event.
1702        self.record_meta_loom(
1703            domain,
1704            MetaDecisionType::ModelVersionBumped { from: 0, to: 1 },
1705            "Session initialized",
1706            0.0,
1707        );
1708
1709        // Emit impulse.
1710        self.emit_impulse(ImpulseType::Custom(0x32));
1711
1712        info!(domain, session_id = %session_id, "weaver session started");
1713        Ok(session_id)
1714    }
1715
1716    /// Stop a modeling session.
1717    pub fn stop_session(&self, domain: &str) -> Result<(), String> {
1718        let mut sessions = self.sessions.write().map_err(|e| e.to_string())?;
1719        let session = sessions
1720            .get_mut(domain)
1721            .ok_or_else(|| format!("no session for domain: {domain}"))?;
1722        session.active = false;
1723        info!(domain, "weaver session stopped");
1724        Ok(())
1725    }
1726
1727    /// Resume a stopped session.
1728    pub fn resume_session(&self, domain: &str) -> Result<(), String> {
1729        let mut sessions = self.sessions.write().map_err(|e| e.to_string())?;
1730        let session = sessions
1731            .get_mut(domain)
1732            .ok_or_else(|| format!("no session for domain: {domain}"))?;
1733        session.active = true;
1734        info!(domain, "weaver session resumed");
1735        Ok(())
1736    }
1737
1738    /// Get a snapshot of a session.
1739    pub fn get_session(&self, domain: &str) -> Option<ModelingSession> {
1740        self.sessions
1741            .read()
1742            .ok()?
1743            .get(domain)
1744            .cloned()
1745    }
1746
1747    /// List all session domains.
1748    pub fn list_sessions(&self) -> Vec<String> {
1749        self.sessions
1750            .read()
1751            .map(|s| s.keys().cloned().collect())
1752            .unwrap_or_default()
1753    }
1754
1755    // ── Source management ─────────────────────────────────────────
1756
1757    /// Add a data source to a session.
1758    pub fn add_source(
1759        &self,
1760        domain: &str,
1761        source_type: &str,
1762        _root: Option<&PathBuf>,
1763    ) -> Result<(), String> {
1764        let mut sessions = self.sessions.write().map_err(|e| e.to_string())?;
1765        let session = sessions
1766            .get_mut(domain)
1767            .ok_or_else(|| format!("no session for domain: {domain}"))?;
1768        session.sources_ingested.push(source_type.to_string());
1769
1770        // Record meta-loom event.
1771        drop(sessions); // release lock before recording
1772        self.record_meta_loom(
1773            domain,
1774            MetaDecisionType::SourceAdded {
1775                source_type: source_type.to_string(),
1776            },
1777            &format!("Added source: {source_type}"),
1778            self.get_session(domain).map(|s| s.confidence).unwrap_or(0.0),
1779        );
1780
1781        // Emit impulse for source request.
1782        self.emit_impulse(ImpulseType::Custom(0x33));
1783
1784        Ok(())
1785    }
1786
1787    // ── Confidence evaluation ─────────────────────────────────────
1788
1789    /// Evaluate confidence for a session domain.
1790    pub fn evaluate_confidence(&self, domain: &str) -> Result<ConfidenceReport, String> {
1791        let sessions = self.sessions.read().map_err(|e| e.to_string())?;
1792        let session = sessions
1793            .get(domain)
1794            .ok_or_else(|| format!("no session for domain: {domain}"))?;
1795
1796        // Simple confidence model based on source count and graph size.
1797        let source_count = session.sources_ingested.len() as f64;
1798        let node_count = self.causal_graph.node_count() as f64;
1799
1800        // Confidence grows with data, capped at 1.0.
1801        let base_confidence = (source_count * 0.15 + node_count * 0.01).min(1.0);
1802
1803        let mut gaps = Vec::new();
1804        if source_count < 3.0 {
1805            gaps.push(ConfidenceGap {
1806                domain: domain.to_string(),
1807                current_confidence: base_confidence,
1808                target_confidence: 0.8,
1809                suggested_sources: vec!["git_log".into(), "file_tree".into()],
1810            });
1811        }
1812
1813        let suggestions = if source_count < 2.0 {
1814            vec![ModelingSuggestion::AddSource {
1815                source_type: "git_log".into(),
1816                reason: "No git history ingested yet".into(),
1817            }]
1818        } else {
1819            Vec::new()
1820        };
1821
1822        Ok(ConfidenceReport {
1823            overall: base_confidence,
1824            gaps,
1825            suggestions,
1826        })
1827    }
1828
1829    // ── Cognitive tick handler ─────────────────────────────────────
1830
1831    /// Process a single cognitive tick.
1832    ///
1833    /// Called by the CognitiveTick service during each tick cycle.
1834    /// Budget-aware: yields if budget is exhausted.
1835    pub fn tick(&self, budget: Duration) -> TickResult {
1836        let budget_start = Instant::now();
1837
1838        let mut sessions = match self.sessions.write() {
1839            Ok(s) => s,
1840            Err(_) => return TickResult::Idle,
1841        };
1842
1843        // Find an active session.
1844        let active_domain = sessions
1845            .iter()
1846            .find(|(_, s)| s.active)
1847            .map(|(d, _)| d.clone());
1848
1849        let domain = match active_domain {
1850            Some(d) => d,
1851            None => return TickResult::Idle,
1852        };
1853
1854        let session = match sessions.get_mut(&domain) {
1855            Some(s) => s,
1856            None => return TickResult::Idle,
1857        };
1858
1859        // Phase 1: Evaluate confidence.
1860        let source_count = session.sources_ingested.len() as f64;
1861        let node_count = self.causal_graph.node_count() as f64;
1862        let confidence = (source_count * 0.15 + node_count * 0.01).min(1.0);
1863        session.confidence = confidence;
1864
1865        // Phase 2: Identify gaps.
1866        let mut gaps = Vec::new();
1867        if confidence < 0.8 {
1868            gaps.push(ConfidenceGap {
1869                domain: domain.clone(),
1870                current_confidence: confidence,
1871                target_confidence: 0.8,
1872                suggested_sources: vec!["git_log".into(), "file_tree".into()],
1873            });
1874        }
1875        session.gaps = gaps.clone();
1876
1877        if budget_start.elapsed() > budget {
1878            return TickResult::BudgetExhausted;
1879        }
1880
1881        // Phase 3: Create a causal node to record the tick.
1882        let tick_label = format!("weaver.tick.{}.{}", domain, session.tick_count);
1883        self.causal_graph.add_node(
1884            tick_label,
1885            serde_json::json!({
1886                "domain": domain,
1887                "confidence": confidence,
1888                "tick": session.tick_count,
1889            }),
1890        );
1891
1892        session.tick_count += 1;
1893        self.tick_count.fetch_add(1, Ordering::Relaxed);
1894
1895        TickResult::Progress {
1896            confidence,
1897            gaps_remaining: gaps.len(),
1898        }
1899    }
1900
1901    // ── Export / Import (K3c-G4) ──────────────────────────────────
1902
1903    /// Export the model for a domain.
1904    pub fn export_model(
1905        &self,
1906        domain: &str,
1907        min_confidence: f64,
1908    ) -> Result<ExportedModel, String> {
1909        let sessions = self.sessions.read().map_err(|e| e.to_string())?;
1910        let session = sessions
1911            .get(domain)
1912            .ok_or_else(|| format!("no session for domain: {domain}"))?;
1913
1914        // Collect all edges and filter by confidence.
1915        let edge_types: Vec<EdgeTypeSpec> = session
1916            .sources_ingested
1917            .iter()
1918            .enumerate()
1919            .map(|(i, src)| EdgeTypeSpec {
1920                from_type: "source".into(),
1921                to_type: "domain".into(),
1922                edge_type: format!("ingested_{src}"),
1923                confidence: (i as f64 + 1.0) * 0.2,
1924            })
1925            .filter(|e| e.confidence >= min_confidence)
1926            .collect();
1927
1928        // Collect causal nodes from the graph.
1929        let mut causal_nodes = Vec::new();
1930        let mut causal_edges_out = Vec::new();
1931        let next_id = self.causal_graph.node_count() + 1;
1932        for nid in 1..next_id {
1933            if let Some(node) = self.causal_graph.get_node(nid) {
1934                causal_nodes.push(ExportedCausalNode {
1935                    label: node.label.clone(),
1936                    metadata: node.metadata.clone(),
1937                });
1938                // Collect forward edges from this node.
1939                for edge in self.causal_graph.get_forward_edges(nid) {
1940                    if let Some(target_node) = self.causal_graph.get_node(edge.target) {
1941                        causal_edges_out.push(ExportedCausalEdge {
1942                            source_label: node.label.clone(),
1943                            target_label: target_node.label.clone(),
1944                            edge_type: format!("{}", edge.edge_type),
1945                            weight: edge.weight,
1946                        });
1947                    }
1948                }
1949            }
1950        }
1951
1952        Ok(ExportedModel {
1953            version: "1.0".to_string(),
1954            domain: domain.to_string(),
1955            exported_at: Utc::now(),
1956            confidence: session.confidence,
1957            node_types: vec![NodeTypeSpec {
1958                name: "default".into(),
1959                embedding_strategy: self.embedding_provider.model_name().to_string(),
1960                dimensions: self.embedding_provider.dimensions(),
1961            }],
1962            edge_types,
1963            causal_nodes,
1964            causal_edges: causal_edges_out,
1965            metadata: session.metadata.clone(),
1966        })
1967    }
1968
1969    /// Import a previously exported model.
1970    pub fn import_model(
1971        &self,
1972        domain: &str,
1973        model: ExportedModel,
1974    ) -> Result<(), String> {
1975        // Version check.
1976        if !model.version.starts_with("1.") {
1977            return Err(format!(
1978                "incompatible model version: expected 1.x, got {}",
1979                model.version
1980            ));
1981        }
1982
1983        let session = ModelingSession {
1984            id: uuid::Uuid::new_v4().to_string(),
1985            domain: domain.to_string(),
1986            started_at: Utc::now(),
1987            confidence: model.confidence,
1988            gaps: Vec::new(),
1989            sources_ingested: model
1990                .edge_types
1991                .iter()
1992                .map(|e| e.edge_type.clone())
1993                .collect(),
1994            tick_count: 0,
1995            budget_remaining_ms: 300_000,
1996            active: true,
1997            metadata: model.metadata,
1998        };
1999
2000        let mut sessions = self.sessions.write().map_err(|e| e.to_string())?;
2001        sessions.insert(domain.to_string(), session);
2002
2003        // Record meta-loom.
2004        drop(sessions);
2005        self.record_meta_loom(
2006            domain,
2007            MetaDecisionType::ModelVersionBumped { from: 0, to: 1 },
2008            "Imported from exported model",
2009            model.confidence,
2010        );
2011
2012        info!(domain, "weaver model imported");
2013        Ok(())
2014    }
2015
2016    // ── Command handler (IPC) ─────────────────────────────────────
2017
2018    /// Handle a WeaverCommand received via IPC.
2019    pub fn handle_command(&self, cmd: WeaverCommand) -> WeaverResponse {
2020        match cmd {
2021            WeaverCommand::SessionStart {
2022                domain,
2023                context,
2024                goal,
2025                ..
2026            } => match self.start_session(
2027                &domain,
2028                context.as_deref(),
2029                goal.as_deref(),
2030            ) {
2031                Ok(session_id) => WeaverResponse::SessionStarted { domain, session_id },
2032                Err(e) => WeaverResponse::Error(e),
2033            },
2034            WeaverCommand::SessionStop { domain } => match self.stop_session(&domain) {
2035                Ok(()) => WeaverResponse::SessionStopped { domain },
2036                Err(e) => WeaverResponse::Error(e),
2037            },
2038            WeaverCommand::SessionResume { domain } => match self.resume_session(&domain) {
2039                Ok(()) => WeaverResponse::SessionResumed { domain },
2040                Err(e) => WeaverResponse::Error(e),
2041            },
2042            WeaverCommand::SourceAdd {
2043                domain,
2044                source_type,
2045                root,
2046                ..
2047            } => match self.add_source(&domain, &source_type, root.as_ref()) {
2048                Ok(()) => WeaverResponse::SourceAdded {
2049                    domain,
2050                    source_type,
2051                },
2052                Err(e) => WeaverResponse::Error(e),
2053            },
2054            WeaverCommand::SourceList { domain } => {
2055                match self.get_session(&domain) {
2056                    Some(s) => WeaverResponse::Sources(s.sources_ingested),
2057                    None => WeaverResponse::Error(format!("no session for domain: {domain}")),
2058                }
2059            }
2060            WeaverCommand::Confidence { domain, .. } => {
2061                match self.evaluate_confidence(&domain) {
2062                    Ok(report) => WeaverResponse::ConfidenceReport(report),
2063                    Err(e) => WeaverResponse::Error(e),
2064                }
2065            }
2066            WeaverCommand::Export {
2067                domain,
2068                min_confidence,
2069                output,
2070            } => match self.export_model(&domain, min_confidence) {
2071                Ok(model) => {
2072                    let edges = model.edge_types.len();
2073                    // In a real implementation, this would write to disk.
2074                    debug!(?output, edges, "model exported");
2075                    WeaverResponse::Exported { path: output, edges }
2076                }
2077                Err(e) => WeaverResponse::Error(e),
2078            },
2079            WeaverCommand::Import { domain, input } => {
2080                // In a real implementation, this would read from disk.
2081                debug!(?input, "model import requested");
2082                WeaverResponse::Imported { domain }
2083            }
2084            WeaverCommand::MetaStrategies => {
2085                let strategies = self.knowledge_base.list_strategies();
2086                WeaverResponse::Strategies(strategies)
2087            }
2088            WeaverCommand::MetaExportKb { output } => {
2089                debug!(?output, "KB export requested");
2090                WeaverResponse::KbExported { path: output }
2091            }
2092            _ => WeaverResponse::Error("command not implemented".into()),
2093        }
2094    }
2095
2096    // ── Meta-Loom (K3c-G5) ───────────────────────────────────────
2097
2098    /// Record a meta-loom event.
2099    fn record_meta_loom(
2100        &self,
2101        domain: &str,
2102        decision: MetaDecisionType,
2103        rationale: &str,
2104        confidence_before: f64,
2105    ) {
2106        let event = MetaLoomEvent {
2107            session_domain: domain.to_string(),
2108            decision_type: decision,
2109            confidence_before,
2110            confidence_after: None,
2111            rationale: rationale.to_string(),
2112            timestamp: Utc::now(),
2113        };
2114
2115        // Record in causal graph under meta-loom namespace.
2116        let label = format!("meta-loom/{}", domain);
2117        self.causal_graph.add_node(
2118            label,
2119            serde_json::to_value(&event).unwrap_or_default(),
2120        );
2121
2122        // Store in local event history.
2123        if let Ok(mut events) = self.meta_loom_events.write() {
2124            events
2125                .entry(domain.to_string())
2126                .or_default()
2127                .push(event);
2128        }
2129    }
2130
2131    /// Get meta-loom events for a domain.
2132    pub fn meta_loom_events(&self, domain: &str) -> Vec<MetaLoomEvent> {
2133        self.meta_loom_events
2134            .read()
2135            .ok()
2136            .and_then(|m| m.get(domain).cloned())
2137            .unwrap_or_default()
2138    }
2139
2140    /// Emit an impulse if the queue is configured.
2141    fn emit_impulse(&self, impulse_type: ImpulseType) {
2142        if let Some(queue) = &self.impulse_queue {
2143            queue.emit(
2144                0x03, // CausalGraph
2145                [0u8; 32],
2146                0x03, // self-referential
2147                impulse_type,
2148                serde_json::Value::Null,
2149                0,
2150            );
2151        }
2152    }
2153
2154    // ── CognitiveTick integration ──────────────────────────────────
2155
2156    /// Handle a cognitive tick — process pending work within budget.
2157    ///
2158    /// Called by the CognitiveTick service each cycle. Performs git polling,
2159    /// file change detection, pending ingestion, and periodic confidence
2160    /// recomputation, all within the supplied time budget.
2161    pub fn on_tick(&mut self, budget_ms: u32) -> CognitiveTickResult {
2162        let start = Instant::now();
2163        let budget = Duration::from_millis(budget_ms as u64);
2164        let mut result = CognitiveTickResult {
2165            tick_number: self.tick_count.load(Ordering::Relaxed),
2166            budget_ms,
2167            ..Default::default()
2168        };
2169
2170        // 1. Check for new git commits (if git polling enabled).
2171        if start.elapsed() < budget {
2172            if let Some(new_commits) = self.poll_git() {
2173                result.git_commits_found = new_commits;
2174            }
2175        }
2176
2177        // 2. Check for file changes (if file watcher enabled).
2178        if start.elapsed() < budget {
2179            if let Some(changed_files) = self.poll_file_changes() {
2180                result.files_changed = changed_files;
2181            }
2182        }
2183
2184        // 3. Process pending ingestion queue (delegate to existing tick()).
2185        if start.elapsed() < budget {
2186            let remaining = budget.saturating_sub(start.elapsed());
2187            let tick_result = self.tick(remaining);
2188            if let TickResult::Progress { .. } = tick_result {
2189                result.nodes_processed = 1;
2190            }
2191        }
2192
2193        // 4. Recompute confidence every 100 ticks and record snapshot.
2194        if start.elapsed() < budget && self.ticks_since_confidence_update > 100 {
2195            let report = self.compute_confidence();
2196            self.last_confidence = Some(report.clone());
2197            self.ticks_since_confidence_update = 0;
2198            result.confidence_updated = true;
2199
2200            // Record confidence snapshot (Item 1).
2201            let snapshot = ConfidenceSnapshot {
2202                timestamp: Utc::now(),
2203                tick_number: self.tick_count.load(Ordering::Relaxed),
2204                confidence: report.overall,
2205                node_count: self.causal_graph.node_count() as usize,
2206                edge_count: self.causal_graph.edge_count() as usize,
2207                gap_count: report.gaps.len(),
2208                trigger: ConfidenceTrigger::Periodic,
2209            };
2210            self.confidence_history.record(snapshot);
2211        }
2212
2213        result.elapsed_ms = start.elapsed().as_millis() as u32;
2214        result.within_budget = start.elapsed() <= budget;
2215        self.tick_count.fetch_add(1, Ordering::Relaxed);
2216        self.ticks_since_confidence_update += 1;
2217
2218        // Record tick result in history (Item 6).
2219        self.tick_history.record(result.clone());
2220
2221        result
2222    }
2223
2224    /// Enable git polling for a repository path and branch.
2225    pub fn enable_git_polling(&mut self, repo_path: PathBuf, branch: String) {
2226        self.git_poller = Some(GitPoller::new(repo_path, branch));
2227    }
2228
2229    /// Enable file watching for source files under a root directory.
2230    pub fn enable_file_watching(&mut self, root: PathBuf, patterns: Vec<String>) {
2231        let mut watcher = FileWatcher::new(root, patterns);
2232        watcher.watch_directory();
2233        self.file_watcher = Some(watcher);
2234    }
2235
2236    /// Poll git for new commits (internal helper for on_tick).
2237    fn poll_git(&mut self) -> Option<usize> {
2238        self.git_poller.as_mut().map(|p| p.poll())
2239    }
2240
2241    /// Poll file watcher for changed files (internal helper for on_tick).
2242    fn poll_file_changes(&mut self) -> Option<usize> {
2243        self.file_watcher.as_mut().map(|w| w.poll_changes().len())
2244    }
2245
2246    /// Get the cached confidence report from the last recomputation.
2247    pub fn cached_confidence(&self) -> Option<&ConfidenceReport> {
2248        self.last_confidence.as_ref()
2249    }
2250
2251    /// Get a reference to the git poller, if enabled.
2252    pub fn git_poller(&self) -> Option<&GitPoller> {
2253        self.git_poller.as_ref()
2254    }
2255
2256    /// Get a reference to the file watcher, if enabled.
2257    pub fn file_watcher(&self) -> Option<&FileWatcher> {
2258        self.file_watcher.as_ref()
2259    }
2260
2261    /// Total ticks processed.
2262    pub fn total_ticks(&self) -> u64 {
2263        self.tick_count.load(Ordering::Relaxed)
2264    }
2265
2266    // ── Confidence history accessors (Item 1) ────────────────────
2267
2268    /// Get a reference to the confidence history.
2269    pub fn confidence_history(&self) -> &ConfidenceHistory {
2270        &self.confidence_history
2271    }
2272
2273    /// Get a mutable reference to the confidence history.
2274    pub fn confidence_history_mut(&mut self) -> &mut ConfidenceHistory {
2275        &mut self.confidence_history
2276    }
2277
2278    // ── Strategy tracker accessors (Item 4) ──────────────────────
2279
2280    /// Get a reference to the strategy tracker.
2281    pub fn strategy_tracker(&self) -> &StrategyTracker {
2282        &self.strategy_tracker
2283    }
2284
2285    /// Get a mutable reference to the strategy tracker.
2286    pub fn strategy_tracker_mut(&mut self) -> &mut StrategyTracker {
2287        &mut self.strategy_tracker
2288    }
2289
2290    // ── Tick history / interval recommendation (Item 6) ──────────
2291
2292    /// Get a reference to the tick history.
2293    pub fn tick_history(&self) -> &TickHistory {
2294        &self.tick_history
2295    }
2296
2297    /// Set the current tick interval (for recommendation calculations).
2298    pub fn set_tick_interval_ms(&mut self, ms: u32) {
2299        self.current_tick_interval_ms = ms;
2300    }
2301
2302    /// Analyze recent tick history and recommend interval adjustment.
2303    ///
2304    /// Looks at recent change frequency to determine if the tick interval
2305    /// should be faster, slower, or idle. Thresholds:
2306    /// - Frequent changes (>10/min): recommend 200ms (fast).
2307    /// - Moderate changes (1-10/min): recommend 1000ms (default).
2308    /// - Rare changes (<1/min): recommend 3000ms (slow).
2309    /// - No changes for 100+ ticks: recommend 5000ms (idle mode).
2310    pub fn recommend_tick_interval(&self) -> TickRecommendation {
2311        let idle = self.tick_history.idle_ticks();
2312        let cpm = self.tick_history.changes_per_minute();
2313        let sample_count = self.tick_history.len();
2314
2315        // Not enough data to make a confident recommendation.
2316        if sample_count < 5 {
2317            return TickRecommendation {
2318                recommended_ms: self.current_tick_interval_ms,
2319                current_ms: self.current_tick_interval_ms,
2320                reason: "Insufficient data for recommendation".to_string(),
2321                changes_per_minute: cpm,
2322                recommendation_confidence: 0.1,
2323            };
2324        }
2325
2326        // Idle mode: no changes for many consecutive ticks.
2327        if idle >= 100 {
2328            return TickRecommendation {
2329                recommended_ms: 5000,
2330                current_ms: self.current_tick_interval_ms,
2331                reason: format!(
2332                    "No changes for {idle} consecutive ticks; entering idle mode"
2333                ),
2334                changes_per_minute: cpm,
2335                recommendation_confidence: 0.9,
2336            };
2337        }
2338
2339        // High frequency changes: speed up.
2340        if cpm > 10.0 {
2341            return TickRecommendation {
2342                recommended_ms: 200,
2343                current_ms: self.current_tick_interval_ms,
2344                reason: format!(
2345                    "High change rate ({cpm:.1}/min); recommending fast ticks"
2346                ),
2347                changes_per_minute: cpm,
2348                recommendation_confidence: 0.8,
2349            };
2350        }
2351
2352        // Moderate frequency: default speed.
2353        if cpm >= 1.0 {
2354            return TickRecommendation {
2355                recommended_ms: 1000,
2356                current_ms: self.current_tick_interval_ms,
2357                reason: format!(
2358                    "Moderate change rate ({cpm:.1}/min); default interval"
2359                ),
2360                changes_per_minute: cpm,
2361                recommendation_confidence: 0.7,
2362            };
2363        }
2364
2365        // Low frequency: slow down.
2366        TickRecommendation {
2367            recommended_ms: 3000,
2368            current_ms: self.current_tick_interval_ms,
2369            reason: format!(
2370                "Low change rate ({cpm:.2}/min); recommending slower ticks"
2371            ),
2372            changes_per_minute: cpm,
2373            recommendation_confidence: 0.6,
2374        }
2375    }
2376}
2377
2378#[async_trait]
2379impl SystemService for WeaverEngine {
2380    fn name(&self) -> &str {
2381        "weaver"
2382    }
2383
2384    fn service_type(&self) -> ServiceType {
2385        ServiceType::Core
2386    }
2387
2388    async fn start(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
2389        info!("weaver engine started");
2390        Ok(())
2391    }
2392
2393    async fn stop(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
2394        // Stop all active sessions.
2395        if let Ok(mut sessions) = self.sessions.write() {
2396            for (_, session) in sessions.iter_mut() {
2397                session.active = false;
2398            }
2399        }
2400        info!(
2401            total_ticks = self.total_ticks(),
2402            kb_strategies = self.knowledge_base.count(),
2403            "weaver engine stopped"
2404        );
2405        Ok(())
2406    }
2407
2408    async fn health_check(&self) -> HealthStatus {
2409        // Always healthy: both active and idle states are normal.
2410        HealthStatus::Healthy
2411    }
2412}
2413
2414// ---------------------------------------------------------------------------
2415// ModelDiff (K3c-G4b)
2416// ---------------------------------------------------------------------------
2417
2418/// Differences between two exported models.
2419#[derive(Debug, Clone, Serialize, Deserialize)]
2420pub struct ModelDiff {
2421    /// Model A identifier (domain).
2422    pub model_a: String,
2423    /// Model B identifier (domain).
2424    pub model_b: String,
2425    /// Confidence delta (B - A).
2426    pub confidence_delta: f64,
2427    /// Node types only in A.
2428    pub nodes_only_a: Vec<String>,
2429    /// Node types only in B.
2430    pub nodes_only_b: Vec<String>,
2431    /// Node types in both.
2432    pub nodes_common: Vec<String>,
2433    /// Edge types only in A.
2434    pub edges_only_a: Vec<String>,
2435    /// Edge types only in B.
2436    pub edges_only_b: Vec<String>,
2437    /// Edge types in both.
2438    pub edges_common: Vec<String>,
2439    /// Causal nodes added in B vs A.
2440    pub causal_nodes_added: usize,
2441    /// Causal nodes removed in B vs A.
2442    pub causal_nodes_removed: usize,
2443    /// Causal edges added.
2444    pub causal_edges_added: usize,
2445    /// Causal edges removed.
2446    pub causal_edges_removed: usize,
2447    /// Summary assessment.
2448    pub summary: String,
2449}
2450
2451/// Compare two exported models and produce a structured diff.
2452pub fn diff_models(a: &ExportedModel, b: &ExportedModel) -> ModelDiff {
2453    // Node types by name.
2454    let a_node_names: HashSet<&str> = a.node_types.iter().map(|n| n.name.as_str()).collect();
2455    let b_node_names: HashSet<&str> = b.node_types.iter().map(|n| n.name.as_str()).collect();
2456
2457    let nodes_only_a: Vec<String> = a_node_names
2458        .difference(&b_node_names)
2459        .map(|s| s.to_string())
2460        .collect();
2461    let nodes_only_b: Vec<String> = b_node_names
2462        .difference(&a_node_names)
2463        .map(|s| s.to_string())
2464        .collect();
2465    let nodes_common: Vec<String> = a_node_names
2466        .intersection(&b_node_names)
2467        .map(|s| s.to_string())
2468        .collect();
2469
2470    // Edge types by (from, to, type) composite key.
2471    let edge_key =
2472        |e: &EdgeTypeSpec| format!("{}->{}:{}", e.from_type, e.to_type, e.edge_type);
2473    let a_edge_keys: HashSet<String> = a.edge_types.iter().map(|e| edge_key(e)).collect();
2474    let b_edge_keys: HashSet<String> = b.edge_types.iter().map(|e| edge_key(e)).collect();
2475
2476    let edges_only_a: Vec<String> = a_edge_keys.difference(&b_edge_keys).cloned().collect();
2477    let edges_only_b: Vec<String> = b_edge_keys.difference(&a_edge_keys).cloned().collect();
2478    let edges_common: Vec<String> = a_edge_keys.intersection(&b_edge_keys).cloned().collect();
2479
2480    // Causal nodes by label.
2481    let a_causal_labels: HashSet<&str> =
2482        a.causal_nodes.iter().map(|n| n.label.as_str()).collect();
2483    let b_causal_labels: HashSet<&str> =
2484        b.causal_nodes.iter().map(|n| n.label.as_str()).collect();
2485
2486    let causal_nodes_added = b_causal_labels.difference(&a_causal_labels).count();
2487    let causal_nodes_removed = a_causal_labels.difference(&b_causal_labels).count();
2488
2489    // Causal edges by (from, to).
2490    let causal_edge_key =
2491        |e: &ExportedCausalEdge| format!("{}->{}", e.source_label, e.target_label);
2492    let a_causal_edge_keys: HashSet<String> =
2493        a.causal_edges.iter().map(|e| causal_edge_key(e)).collect();
2494    let b_causal_edge_keys: HashSet<String> =
2495        b.causal_edges.iter().map(|e| causal_edge_key(e)).collect();
2496
2497    let causal_edges_added = b_causal_edge_keys.difference(&a_causal_edge_keys).count();
2498    let causal_edges_removed = a_causal_edge_keys.difference(&b_causal_edge_keys).count();
2499
2500    let confidence_delta = b.confidence - a.confidence;
2501
2502    // Build summary.
2503    let mut parts = Vec::new();
2504    if confidence_delta.abs() > f64::EPSILON {
2505        parts.push(format!(
2506            "confidence {} by {:.3}",
2507            if confidence_delta > 0.0 {
2508                "increased"
2509            } else {
2510                "decreased"
2511            },
2512            confidence_delta.abs()
2513        ));
2514    }
2515    if causal_nodes_added > 0 || causal_nodes_removed > 0 {
2516        parts.push(format!(
2517            "{} causal nodes added, {} removed",
2518            causal_nodes_added, causal_nodes_removed
2519        ));
2520    }
2521    if causal_edges_added > 0 || causal_edges_removed > 0 {
2522        parts.push(format!(
2523            "{} causal edges added, {} removed",
2524            causal_edges_added, causal_edges_removed
2525        ));
2526    }
2527    if !nodes_only_a.is_empty() || !nodes_only_b.is_empty() {
2528        parts.push(format!(
2529            "{} node types only in A, {} only in B",
2530            nodes_only_a.len(),
2531            nodes_only_b.len()
2532        ));
2533    }
2534    let summary = if parts.is_empty() {
2535        "models are identical".to_string()
2536    } else {
2537        parts.join("; ")
2538    };
2539
2540    ModelDiff {
2541        model_a: a.domain.clone(),
2542        model_b: b.domain.clone(),
2543        confidence_delta,
2544        nodes_only_a,
2545        nodes_only_b,
2546        nodes_common,
2547        edges_only_a,
2548        edges_only_b,
2549        edges_common,
2550        causal_nodes_added,
2551        causal_nodes_removed,
2552        causal_edges_added,
2553        causal_edges_removed,
2554        summary,
2555    }
2556}
2557
2558// ---------------------------------------------------------------------------
2559// ModelMerge (K3c-G4c)
2560// ---------------------------------------------------------------------------
2561
2562/// Result of merging two models.
2563#[derive(Debug, Clone, Serialize, Deserialize)]
2564pub struct MergeResult {
2565    /// The merged model.
2566    pub merged: ExportedModel,
2567    /// Conflicts that were resolved.
2568    pub conflicts: Vec<MergeConflict>,
2569    /// Statistics about the merge.
2570    pub stats: MergeStats,
2571}
2572
2573/// A conflict encountered during model merge.
2574#[derive(Debug, Clone, Serialize, Deserialize)]
2575pub struct MergeConflict {
2576    /// What conflicted.
2577    pub item: String,
2578    /// Value from model A.
2579    pub value_a: String,
2580    /// Value from model B.
2581    pub value_b: String,
2582    /// How it was resolved.
2583    pub resolution: ConflictResolution,
2584}
2585
2586/// How a merge conflict was resolved.
2587#[non_exhaustive]
2588#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2589pub enum ConflictResolution {
2590    /// Kept the value from model A.
2591    KeepA,
2592    /// Kept the value from model B.
2593    KeepB,
2594    /// Merged both values.
2595    Merged,
2596    /// Used the higher confidence value.
2597    HigherConfidence,
2598}
2599
2600/// Statistics about a model merge operation.
2601#[derive(Debug, Clone, Serialize, Deserialize)]
2602pub struct MergeStats {
2603    /// Total node types in the merged model.
2604    pub total_node_types: usize,
2605    /// Total edge types in the merged model.
2606    pub total_edge_types: usize,
2607    /// Total causal nodes in the merged model.
2608    pub total_causal_nodes: usize,
2609    /// Total causal edges in the merged model.
2610    pub total_causal_edges: usize,
2611    /// Number of conflicts resolved.
2612    pub conflicts_resolved: usize,
2613    /// Node types from A only.
2614    pub nodes_from_a: usize,
2615    /// Node types from B only.
2616    pub nodes_from_b: usize,
2617    /// Node types shared between A and B.
2618    pub nodes_shared: usize,
2619}
2620
2621/// Merge two exported models into one.
2622///
2623/// Node types are unioned by name (higher-dimension embedding strategy wins
2624/// on conflict). Edge types are unioned by (from, to, type) key with higher
2625/// confidence kept. Causal nodes are unioned by label. Causal edges are
2626/// unioned by (source, target) key with weights averaged on overlap.
2627pub fn merge_models(a: &ExportedModel, b: &ExportedModel) -> MergeResult {
2628    let mut conflicts = Vec::new();
2629
2630    // ── Node types ──────────────────────────────────────────────
2631    let a_node_map: HashMap<&str, &NodeTypeSpec> =
2632        a.node_types.iter().map(|n| (n.name.as_str(), n)).collect();
2633    let b_node_map: HashMap<&str, &NodeTypeSpec> =
2634        b.node_types.iter().map(|n| (n.name.as_str(), n)).collect();
2635
2636    let all_node_names: HashSet<&str> =
2637        a_node_map.keys().chain(b_node_map.keys()).copied().collect();
2638
2639    let mut merged_node_types = Vec::new();
2640    let mut nodes_from_a = 0usize;
2641    let mut nodes_from_b = 0usize;
2642    let mut nodes_shared = 0usize;
2643
2644    for name in &all_node_names {
2645        match (a_node_map.get(name), b_node_map.get(name)) {
2646            (Some(na), None) => {
2647                merged_node_types.push((*na).clone());
2648                nodes_from_a += 1;
2649            }
2650            (None, Some(nb)) => {
2651                merged_node_types.push((*nb).clone());
2652                nodes_from_b += 1;
2653            }
2654            (Some(na), Some(nb)) => {
2655                nodes_shared += 1;
2656                if na.embedding_strategy != nb.embedding_strategy {
2657                    let (winner, resolution) = if na.dimensions >= nb.dimensions {
2658                        ((*na).clone(), ConflictResolution::KeepA)
2659                    } else {
2660                        ((*nb).clone(), ConflictResolution::KeepB)
2661                    };
2662                    conflicts.push(MergeConflict {
2663                        item: format!("node_type:{}", name),
2664                        value_a: na.embedding_strategy.clone(),
2665                        value_b: nb.embedding_strategy.clone(),
2666                        resolution,
2667                    });
2668                    merged_node_types.push(winner);
2669                } else {
2670                    merged_node_types.push((*na).clone());
2671                }
2672            }
2673            (None, None) => unreachable!(),
2674        }
2675    }
2676
2677    // ── Edge types ──────────────────────────────────────────────
2678    let edge_key =
2679        |e: &EdgeTypeSpec| format!("{}->{}:{}", e.from_type, e.to_type, e.edge_type);
2680    let a_edge_map: HashMap<String, &EdgeTypeSpec> =
2681        a.edge_types.iter().map(|e| (edge_key(e), e)).collect();
2682    let b_edge_map: HashMap<String, &EdgeTypeSpec> =
2683        b.edge_types.iter().map(|e| (edge_key(e), e)).collect();
2684
2685    let all_edge_keys: HashSet<&str> = a_edge_map
2686        .keys()
2687        .chain(b_edge_map.keys())
2688        .map(|s| s.as_str())
2689        .collect();
2690
2691    let mut merged_edge_types = Vec::new();
2692    for key in &all_edge_keys {
2693        match (a_edge_map.get(*key), b_edge_map.get(*key)) {
2694            (Some(ea), None) => merged_edge_types.push((*ea).clone()),
2695            (None, Some(eb)) => merged_edge_types.push((*eb).clone()),
2696            (Some(ea), Some(eb)) => {
2697                if (ea.confidence - eb.confidence).abs() > f64::EPSILON {
2698                    let (winner, resolution) = if ea.confidence >= eb.confidence {
2699                        ((*ea).clone(), ConflictResolution::HigherConfidence)
2700                    } else {
2701                        ((*eb).clone(), ConflictResolution::HigherConfidence)
2702                    };
2703                    conflicts.push(MergeConflict {
2704                        item: format!("edge_type:{}", key),
2705                        value_a: format!("{:.4}", ea.confidence),
2706                        value_b: format!("{:.4}", eb.confidence),
2707                        resolution,
2708                    });
2709                    merged_edge_types.push(winner);
2710                } else {
2711                    merged_edge_types.push((*ea).clone());
2712                }
2713            }
2714            (None, None) => unreachable!(),
2715        }
2716    }
2717
2718    // ── Causal nodes ────────────────────────────────────────────
2719    let a_cn_map: HashMap<&str, &ExportedCausalNode> =
2720        a.causal_nodes.iter().map(|n| (n.label.as_str(), n)).collect();
2721    let b_cn_map: HashMap<&str, &ExportedCausalNode> =
2722        b.causal_nodes.iter().map(|n| (n.label.as_str(), n)).collect();
2723
2724    let all_cn_labels: HashSet<&str> =
2725        a_cn_map.keys().chain(b_cn_map.keys()).copied().collect();
2726
2727    let mut merged_causal_nodes = Vec::new();
2728    for label in &all_cn_labels {
2729        match (a_cn_map.get(label), b_cn_map.get(label)) {
2730            (Some(na), None) => merged_causal_nodes.push((*na).clone()),
2731            (None, Some(nb)) => merged_causal_nodes.push((*nb).clone()),
2732            (Some(_na), Some(nb)) => {
2733                // Both have the node; prefer B (assumed later export).
2734                merged_causal_nodes.push((*nb).clone());
2735            }
2736            (None, None) => unreachable!(),
2737        }
2738    }
2739
2740    // ── Causal edges ────────────────────────────────────────────
2741    let ce_key =
2742        |e: &ExportedCausalEdge| format!("{}->{}", e.source_label, e.target_label);
2743    let a_ce_map: HashMap<String, &ExportedCausalEdge> =
2744        a.causal_edges.iter().map(|e| (ce_key(e), e)).collect();
2745    let b_ce_map: HashMap<String, &ExportedCausalEdge> =
2746        b.causal_edges.iter().map(|e| (ce_key(e), e)).collect();
2747
2748    let all_ce_keys: HashSet<&str> = a_ce_map
2749        .keys()
2750        .chain(b_ce_map.keys())
2751        .map(|s| s.as_str())
2752        .collect();
2753
2754    let mut merged_causal_edges = Vec::new();
2755    for key in &all_ce_keys {
2756        match (a_ce_map.get(*key), b_ce_map.get(*key)) {
2757            (Some(ea), None) => merged_causal_edges.push((*ea).clone()),
2758            (None, Some(eb)) => merged_causal_edges.push((*eb).clone()),
2759            (Some(ea), Some(eb)) => {
2760                let mut merged_edge = (*ea).clone();
2761                merged_edge.weight = (ea.weight + eb.weight) / 2.0;
2762                if ea.edge_type != eb.edge_type {
2763                    conflicts.push(MergeConflict {
2764                        item: format!("causal_edge:{}", key),
2765                        value_a: ea.edge_type.clone(),
2766                        value_b: eb.edge_type.clone(),
2767                        resolution: ConflictResolution::Merged,
2768                    });
2769                }
2770                merged_causal_edges.push(merged_edge);
2771            }
2772            (None, None) => unreachable!(),
2773        }
2774    }
2775
2776    // ── Merged metadata ─────────────────────────────────────────
2777    let mut merged_metadata = a.metadata.clone();
2778    for (k, v) in &b.metadata {
2779        merged_metadata.entry(k.clone()).or_insert_with(|| v.clone());
2780    }
2781
2782    // ── Merged confidence: weighted average by causal node count ──
2783    let a_weight = a.causal_nodes.len().max(1) as f64;
2784    let b_weight = b.causal_nodes.len().max(1) as f64;
2785    let merged_confidence =
2786        (a.confidence * a_weight + b.confidence * b_weight) / (a_weight + b_weight);
2787
2788    let merged = ExportedModel {
2789        version: "1.0".to_string(),
2790        domain: format!("{}+{}", a.domain, b.domain),
2791        exported_at: Utc::now(),
2792        confidence: merged_confidence,
2793        node_types: merged_node_types,
2794        edge_types: merged_edge_types,
2795        causal_nodes: merged_causal_nodes,
2796        causal_edges: merged_causal_edges,
2797        metadata: merged_metadata,
2798    };
2799
2800    let stats = MergeStats {
2801        total_node_types: merged.node_types.len(),
2802        total_edge_types: merged.edge_types.len(),
2803        total_causal_nodes: merged.causal_nodes.len(),
2804        total_causal_edges: merged.causal_edges.len(),
2805        conflicts_resolved: conflicts.len(),
2806        nodes_from_a,
2807        nodes_from_b,
2808        nodes_shared,
2809    };
2810
2811    MergeResult {
2812        merged,
2813        conflicts,
2814        stats,
2815    }
2816}
2817
2818// ---------------------------------------------------------------------------
2819// Knowledge Base Persistence (K3c-G5b)
2820// ---------------------------------------------------------------------------
2821
2822/// Serializable form of the knowledge base for JSON persistence.
2823#[derive(Debug, Clone, Serialize, Deserialize)]
2824pub struct SerializableKB {
2825    /// Schema version.
2826    pub version: u32,
2827    /// Learned strategy patterns.
2828    pub patterns: Vec<StrategyPattern>,
2829    /// Domains that have been modeled.
2830    pub domains_modeled: Vec<String>,
2831    /// Total modeling sessions conducted.
2832    pub total_sessions: u64,
2833    /// When the KB was last updated.
2834    pub last_updated: DateTime<Utc>,
2835}
2836
2837impl WeaverKnowledgeBase {
2838    /// Convert to a serializable representation.
2839    pub fn to_serializable(&self) -> SerializableKB {
2840        let patterns = self.list_strategies();
2841        let domains: Vec<String> = patterns
2842            .iter()
2843            .map(|p| p.context.clone())
2844            .collect::<HashSet<_>>()
2845            .into_iter()
2846            .collect();
2847        SerializableKB {
2848            version: 1,
2849            patterns,
2850            domains_modeled: domains,
2851            total_sessions: self.count(),
2852            last_updated: Utc::now(),
2853        }
2854    }
2855
2856    /// Reconstruct from a serializable representation.
2857    pub fn from_serializable(kb: SerializableKB) -> Self {
2858        let result = Self::new();
2859        for pattern in kb.patterns {
2860            result.record_strategy(pattern);
2861        }
2862        result
2863    }
2864
2865    /// Save the knowledge base to a JSON file.
2866    pub fn save_to_file(&self, path: &Path) -> Result<(), WeaverError> {
2867        if let Some(parent) = path.parent() {
2868            std::fs::create_dir_all(parent)?;
2869        }
2870        let json = serde_json::to_string_pretty(&self.to_serializable())?;
2871        std::fs::write(path, json)?;
2872        Ok(())
2873    }
2874
2875    /// Load the knowledge base from a JSON file.
2876    pub fn load_from_file(path: &Path) -> Result<Self, WeaverError> {
2877        let data = std::fs::read_to_string(path)?;
2878        let kb: SerializableKB = serde_json::from_str(&data)?;
2879        Ok(Self::from_serializable(kb))
2880    }
2881
2882    /// Add a strategy pattern learned from a modeling session.
2883    ///
2884    /// If a similar pattern exists (same decision_type and context),
2885    /// updates the existing pattern's improvement based on new evidence.
2886    /// Otherwise, adds it as a new pattern.
2887    pub fn learn_pattern(&self, pattern: StrategyPattern) {
2888        if let Ok(mut strategies) = self.strategies.write() {
2889            if let Some(existing) = strategies.iter_mut().find(|s| {
2890                s.decision_type == pattern.decision_type
2891                    && s.context == pattern.context
2892            }) {
2893                existing.improvement =
2894                    (existing.improvement + pattern.improvement) / 2.0;
2895                existing.timestamp = pattern.timestamp;
2896                return;
2897            }
2898            strategies.push(pattern);
2899            self.strategy_count.fetch_add(1, Ordering::Relaxed);
2900        }
2901    }
2902
2903    /// Find applicable patterns for a domain given its characteristics.
2904    ///
2905    /// Scores each pattern by how many of the provided characteristics
2906    /// appear in the pattern's context. Returns patterns sorted by
2907    /// relevance (highest match score first).
2908    pub fn find_patterns(
2909        &self,
2910        domain_characteristics: &[String],
2911    ) -> Vec<StrategyPattern> {
2912        let strategies = match self.strategies.read() {
2913            Ok(s) => s,
2914            Err(_) => return Vec::new(),
2915        };
2916
2917        let mut scored: Vec<(usize, &StrategyPattern)> = strategies
2918            .iter()
2919            .filter_map(|s| {
2920                let score = domain_characteristics
2921                    .iter()
2922                    .filter(|c| s.context.contains(c.as_str()))
2923                    .count();
2924                if score > 0 {
2925                    Some((score, s))
2926                } else {
2927                    None
2928                }
2929            })
2930            .collect();
2931
2932        scored.sort_by(|a, b| b.0.cmp(&a.0));
2933        scored.into_iter().map(|(_, p)| p.clone()).collect()
2934    }
2935
2936    /// Number of stored patterns.
2937    pub fn pattern_count(&self) -> usize {
2938        self.strategies.read().map(|s| s.len()).unwrap_or(0)
2939    }
2940}
2941
2942// ── Tests ─────────────────────────────────────────────────────────────────
2943
2944#[cfg(test)]
2945mod tests {
2946    use super::*;
2947    use crate::hnsw_service::HnswServiceConfig;
2948
2949    fn make_engine() -> WeaverEngine {
2950        let graph = Arc::new(CausalGraph::new());
2951        let hnsw = Arc::new(HnswService::new(HnswServiceConfig::default()));
2952        WeaverEngine::new_with_mock(graph, hnsw)
2953    }
2954
2955    #[test]
2956    fn start_session_creates_session() {
2957        let engine = make_engine();
2958        let sid = engine
2959            .start_session("test-domain", Some("test context"), None)
2960            .unwrap();
2961        assert!(!sid.is_empty());
2962        let session = engine.get_session("test-domain").unwrap();
2963        assert_eq!(session.domain, "test-domain");
2964        assert!(session.active);
2965        assert_eq!(session.confidence, 0.0);
2966    }
2967
2968    #[test]
2969    fn duplicate_session_start_fails() {
2970        let engine = make_engine();
2971        engine.start_session("dup", None, None).unwrap();
2972        let result = engine.start_session("dup", None, None);
2973        assert!(result.is_err());
2974    }
2975
2976    #[test]
2977    fn stop_and_resume_session() {
2978        let engine = make_engine();
2979        engine.start_session("lifecycle", None, None).unwrap();
2980        engine.stop_session("lifecycle").unwrap();
2981        assert!(!engine.get_session("lifecycle").unwrap().active);
2982        engine.resume_session("lifecycle").unwrap();
2983        assert!(engine.get_session("lifecycle").unwrap().active);
2984    }
2985
2986    #[test]
2987    fn add_source_records_ingestion() {
2988        let engine = make_engine();
2989        engine.start_session("src-test", None, None).unwrap();
2990        engine.add_source("src-test", "git_log", None).unwrap();
2991        let session = engine.get_session("src-test").unwrap();
2992        assert_eq!(session.sources_ingested, vec!["git_log"]);
2993    }
2994
2995    #[test]
2996    fn add_source_nonexistent_domain_fails() {
2997        let engine = make_engine();
2998        let result = engine.add_source("nope", "git_log", None);
2999        assert!(result.is_err());
3000    }
3001
3002    #[test]
3003    fn evaluate_confidence_basic() {
3004        let engine = make_engine();
3005        engine.start_session("conf", None, None).unwrap();
3006        let report = engine.evaluate_confidence("conf").unwrap();
3007        assert!(report.overall >= 0.0 && report.overall <= 1.0);
3008        // With no sources, should have gaps.
3009        assert!(!report.gaps.is_empty());
3010    }
3011
3012    #[test]
3013    fn evaluate_confidence_improves_with_sources() {
3014        let engine = make_engine();
3015        engine.start_session("improve", None, None).unwrap();
3016        let r1 = engine.evaluate_confidence("improve").unwrap();
3017        engine.add_source("improve", "git_log", None).unwrap();
3018        engine.add_source("improve", "file_tree", None).unwrap();
3019        let r2 = engine.evaluate_confidence("improve").unwrap();
3020        assert!(r2.overall >= r1.overall);
3021    }
3022
3023    #[test]
3024    fn tick_with_no_session_returns_idle() {
3025        let engine = make_engine();
3026        let result = engine.tick(Duration::from_secs(1));
3027        assert!(matches!(result, TickResult::Idle));
3028    }
3029
3030    #[test]
3031    fn tick_processes_active_session() {
3032        let engine = make_engine();
3033        engine.start_session("tick-test", None, None).unwrap();
3034        let result = engine.tick(Duration::from_secs(5));
3035        assert!(matches!(result, TickResult::Progress { .. }));
3036        let session = engine.get_session("tick-test").unwrap();
3037        assert_eq!(session.tick_count, 1);
3038    }
3039
3040    #[test]
3041    fn tick_increments_total_count() {
3042        let engine = make_engine();
3043        engine.start_session("ticks", None, None).unwrap();
3044        engine.tick(Duration::from_secs(5));
3045        engine.tick(Duration::from_secs(5));
3046        assert_eq!(engine.total_ticks(), 2);
3047    }
3048
3049    #[test]
3050    fn tick_skips_stopped_session() {
3051        let engine = make_engine();
3052        engine.start_session("stopped", None, None).unwrap();
3053        engine.stop_session("stopped").unwrap();
3054        let result = engine.tick(Duration::from_secs(5));
3055        assert!(matches!(result, TickResult::Idle));
3056    }
3057
3058    #[test]
3059    fn export_model_basic() {
3060        let engine = make_engine();
3061        engine.start_session("export", None, None).unwrap();
3062        engine.add_source("export", "git_log", None).unwrap();
3063        let model = engine.export_model("export", 0.0).unwrap();
3064        assert_eq!(model.domain, "export");
3065        assert_eq!(model.version, "1.0");
3066        assert!(!model.node_types.is_empty());
3067    }
3068
3069    #[test]
3070    fn export_model_filters_by_confidence() {
3071        let engine = make_engine();
3072        engine.start_session("filter", None, None).unwrap();
3073        engine.add_source("filter", "git", None).unwrap();
3074        engine.add_source("filter", "file", None).unwrap();
3075        let model_all = engine.export_model("filter", 0.0).unwrap();
3076        let model_high = engine.export_model("filter", 0.5).unwrap();
3077        assert!(model_all.edge_types.len() >= model_high.edge_types.len());
3078    }
3079
3080    #[test]
3081    fn import_model_creates_session() {
3082        let engine = make_engine();
3083        let model = ExportedModel {
3084            version: "1.0".into(),
3085            domain: "imported".into(),
3086            exported_at: Utc::now(),
3087            confidence: 0.75,
3088            node_types: vec![],
3089            edge_types: vec![],
3090            causal_nodes: vec![],
3091            causal_edges: vec![],
3092            metadata: HashMap::new(),
3093        };
3094        engine.import_model("imported", model).unwrap();
3095        let session = engine.get_session("imported").unwrap();
3096        assert_eq!(session.confidence, 0.75);
3097        assert!(session.active);
3098    }
3099
3100    #[test]
3101    fn import_model_version_check() {
3102        let engine = make_engine();
3103        let model = ExportedModel {
3104            version: "2.0".into(),
3105            domain: "bad".into(),
3106            exported_at: Utc::now(),
3107            confidence: 0.5,
3108            node_types: vec![],
3109            edge_types: vec![],
3110            causal_nodes: vec![],
3111            causal_edges: vec![],
3112            metadata: HashMap::new(),
3113        };
3114        let result = engine.import_model("bad", model);
3115        assert!(result.is_err());
3116        assert!(result.unwrap_err().contains("incompatible"));
3117    }
3118
3119    #[test]
3120    fn meta_loom_events_recorded_on_session_start() {
3121        let engine = make_engine();
3122        engine.start_session("meta", None, None).unwrap();
3123        let events = engine.meta_loom_events("meta");
3124        assert!(!events.is_empty());
3125        assert!(matches!(
3126            events[0].decision_type,
3127            MetaDecisionType::ModelVersionBumped { .. }
3128        ));
3129    }
3130
3131    #[test]
3132    fn meta_loom_events_recorded_on_source_add() {
3133        let engine = make_engine();
3134        engine.start_session("meta-src", None, None).unwrap();
3135        engine.add_source("meta-src", "git_log", None).unwrap();
3136        let events = engine.meta_loom_events("meta-src");
3137        assert!(events.len() >= 2); // session start + source add
3138        assert!(matches!(
3139            events.last().unwrap().decision_type,
3140            MetaDecisionType::SourceAdded { .. }
3141        ));
3142    }
3143
3144    #[test]
3145    fn knowledge_base_record_and_list() {
3146        let kb = WeaverKnowledgeBase::new();
3147        kb.record_strategy(StrategyPattern {
3148            decision_type: "SourceAdded".into(),
3149            context: "rust-project".into(),
3150            improvement: 0.15,
3151            timestamp: Utc::now(),
3152        });
3153        let strategies = kb.list_strategies();
3154        assert_eq!(strategies.len(), 1);
3155        assert_eq!(strategies[0].improvement, 0.15);
3156    }
3157
3158    #[test]
3159    fn knowledge_base_strategies_for_domain() {
3160        let kb = WeaverKnowledgeBase::new();
3161        kb.record_strategy(StrategyPattern {
3162            decision_type: "SourceAdded".into(),
3163            context: "rust".into(),
3164            improvement: 0.1,
3165            timestamp: Utc::now(),
3166        });
3167        kb.record_strategy(StrategyPattern {
3168            decision_type: "EdgeType".into(),
3169            context: "python".into(),
3170            improvement: 0.2,
3171            timestamp: Utc::now(),
3172        });
3173        assert_eq!(kb.strategies_for("rust").len(), 1);
3174        assert_eq!(kb.strategies_for("python").len(), 1);
3175        assert_eq!(kb.strategies_for("go").len(), 0);
3176    }
3177
3178    #[test]
3179    fn knowledge_base_export() {
3180        let kb = WeaverKnowledgeBase::new();
3181        kb.record_strategy(StrategyPattern {
3182            decision_type: "test".into(),
3183            context: "test".into(),
3184            improvement: 0.5,
3185            timestamp: Utc::now(),
3186        });
3187        let exported = kb.export();
3188        assert!(exported.is_array());
3189    }
3190
3191    #[test]
3192    fn handle_command_session_start() {
3193        let engine = make_engine();
3194        let resp = engine.handle_command(WeaverCommand::SessionStart {
3195            domain: "cmd-test".into(),
3196            git_path: None,
3197            context: None,
3198            goal: None,
3199        });
3200        assert!(matches!(resp, WeaverResponse::SessionStarted { .. }));
3201    }
3202
3203    #[test]
3204    fn handle_command_confidence() {
3205        let engine = make_engine();
3206        engine.start_session("cmd-conf", None, None).unwrap();
3207        let resp = engine.handle_command(WeaverCommand::Confidence {
3208            domain: "cmd-conf".into(),
3209            edge: None,
3210            verbose: false,
3211        });
3212        assert!(matches!(resp, WeaverResponse::ConfidenceReport(_)));
3213    }
3214
3215    #[test]
3216    fn handle_command_source_list() {
3217        let engine = make_engine();
3218        engine.start_session("cmd-src", None, None).unwrap();
3219        engine.add_source("cmd-src", "git_log", None).unwrap();
3220        let resp = engine.handle_command(WeaverCommand::SourceList {
3221            domain: "cmd-src".into(),
3222        });
3223        match resp {
3224            WeaverResponse::Sources(s) => assert_eq!(s, vec!["git_log"]),
3225            other => panic!("expected Sources, got {other:?}"),
3226        }
3227    }
3228
3229    #[test]
3230    fn handle_command_unknown_domain() {
3231        let engine = make_engine();
3232        let resp = engine.handle_command(WeaverCommand::Confidence {
3233            domain: "missing".into(),
3234            edge: None,
3235            verbose: false,
3236        });
3237        assert!(matches!(resp, WeaverResponse::Error(_)));
3238    }
3239
3240    #[test]
3241    fn list_sessions() {
3242        let engine = make_engine();
3243        engine.start_session("a", None, None).unwrap();
3244        engine.start_session("b", None, None).unwrap();
3245        let mut sessions = engine.list_sessions();
3246        sessions.sort();
3247        assert_eq!(sessions, vec!["a", "b"]);
3248    }
3249
3250    #[tokio::test]
3251    async fn system_service_impl() {
3252        let engine = make_engine();
3253        assert_eq!(engine.name(), "weaver");
3254        assert_eq!(engine.service_type(), ServiceType::Core);
3255        engine.start().await.unwrap();
3256        let health = engine.health_check().await;
3257        assert_eq!(health, HealthStatus::Healthy);
3258        engine.stop().await.unwrap();
3259    }
3260
3261    #[test]
3262    fn impulse_emitted_on_session_start() {
3263        let queue = Arc::new(ImpulseQueue::new());
3264        let graph = Arc::new(CausalGraph::new());
3265        let hnsw = Arc::new(HnswService::new(HnswServiceConfig::default()));
3266        let mut engine = WeaverEngine::new_with_mock(graph, hnsw);
3267        engine.set_impulse_queue(queue.clone());
3268        engine.start_session("impulse-test", None, None).unwrap();
3269        let impulses = queue.drain_ready();
3270        assert!(!impulses.is_empty());
3271        assert!(impulses.iter().any(|i| i.impulse_type == ImpulseType::Custom(0x32)));
3272    }
3273
3274    #[test]
3275    fn impulse_emitted_on_source_add() {
3276        let queue = Arc::new(ImpulseQueue::new());
3277        let graph = Arc::new(CausalGraph::new());
3278        let hnsw = Arc::new(HnswService::new(HnswServiceConfig::default()));
3279        let mut engine = WeaverEngine::new_with_mock(graph, hnsw);
3280        engine.set_impulse_queue(queue.clone());
3281        engine.start_session("impulse-src", None, None).unwrap();
3282        let _ = queue.drain_ready(); // clear session-start impulse
3283        engine.add_source("impulse-src", "git", None).unwrap();
3284        let impulses = queue.drain_ready();
3285        assert!(impulses.iter().any(|i| i.impulse_type == ImpulseType::Custom(0x33)));
3286    }
3287
3288    // ── Graph ingestion tests ────────────────────────────────────
3289
3290    #[test]
3291    fn ingest_graph_file_git_history() {
3292        let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
3293        let graph_path = PathBuf::from(&manifest)
3294            .join("../../.weftos/graph/git-history.json");
3295        if !graph_path.exists() {
3296            // Skip if running outside the project tree.
3297            return;
3298        }
3299        let engine = make_engine();
3300        let result = engine.ingest_graph_file(&graph_path).unwrap();
3301        assert!(result.nodes_added > 0, "should ingest at least one node");
3302        assert_eq!(result.source, "git-history");
3303        assert!(result.embeddings_created > 0, "should create embeddings");
3304        // Verify causal graph was populated.
3305        assert!(engine.causal_graph().node_count() > 0);
3306    }
3307
3308    #[test]
3309    fn ingest_graph_file_module_deps() {
3310        let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
3311        let graph_path = PathBuf::from(&manifest)
3312            .join("../../.weftos/graph/module-deps.json");
3313        if !graph_path.exists() {
3314            return;
3315        }
3316        let engine = make_engine();
3317        let result = engine.ingest_graph_file(&graph_path).unwrap();
3318        assert!(result.nodes_added > 0);
3319        assert_eq!(result.source, "module-dependencies");
3320    }
3321
3322    #[test]
3323    fn ingest_graph_file_decisions() {
3324        let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
3325        let graph_path = PathBuf::from(&manifest)
3326            .join("../../.weftos/graph/decisions.json");
3327        if !graph_path.exists() {
3328            return;
3329        }
3330        let engine = make_engine();
3331        let result = engine.ingest_graph_file(&graph_path).unwrap();
3332        assert!(result.nodes_added > 0);
3333        assert_eq!(result.source, "decisions-and-phases");
3334    }
3335
3336    #[test]
3337    fn ingest_graph_creates_edges() {
3338        let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
3339        let graph_path = PathBuf::from(&manifest)
3340            .join("../../.weftos/graph/git-history.json");
3341        if !graph_path.exists() {
3342            return;
3343        }
3344        let engine = make_engine();
3345        let result = engine.ingest_graph_file(&graph_path).unwrap();
3346        assert!(result.edges_added > 0, "git-history graph should have edges");
3347        assert!(engine.causal_graph().edge_count() > 0);
3348    }
3349
3350    #[test]
3351    fn ingest_graph_populates_hnsw() {
3352        let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
3353        let graph_path = PathBuf::from(&manifest)
3354            .join("../../.weftos/graph/module-deps.json");
3355        if !graph_path.exists() {
3356            return;
3357        }
3358        let engine = make_engine();
3359        let result = engine.ingest_graph_file(&graph_path).unwrap();
3360        assert!(result.embeddings_created > 0);
3361        assert!(engine.hnsw().insert_count() > 0);
3362    }
3363
3364    #[test]
3365    fn ingest_nonexistent_file_returns_error() {
3366        let engine = make_engine();
3367        let result = engine.ingest_graph_file(Path::new("/nonexistent/graph.json"));
3368        assert!(result.is_err());
3369    }
3370
3371    #[test]
3372    fn ingest_invalid_json_returns_error() {
3373        let dir = std::env::temp_dir().join("weaver_test_invalid");
3374        std::fs::create_dir_all(&dir).ok();
3375        let path = dir.join("bad.json");
3376        std::fs::write(&path, "not valid json {{{").unwrap();
3377        let engine = make_engine();
3378        let result = engine.ingest_graph_file(&path);
3379        assert!(result.is_err());
3380        std::fs::remove_dir_all(&dir).ok();
3381    }
3382
3383    #[test]
3384    fn ingest_empty_graph_returns_zero_counts() {
3385        let dir = std::env::temp_dir().join("weaver_test_empty");
3386        std::fs::create_dir_all(&dir).ok();
3387        let path = dir.join("empty.json");
3388        std::fs::write(
3389            &path,
3390            r#"{"source":"test","nodes":[],"edges":[]}"#,
3391        )
3392        .unwrap();
3393        let engine = make_engine();
3394        let result = engine.ingest_graph_file(&path).unwrap();
3395        assert_eq!(result.nodes_added, 0);
3396        assert_eq!(result.edges_added, 0);
3397        assert_eq!(result.embeddings_created, 0);
3398        assert_eq!(result.source, "test");
3399        std::fs::remove_dir_all(&dir).ok();
3400    }
3401
3402    // ── Confidence scoring tests ─────────────────────────────────
3403
3404    #[test]
3405    fn compute_confidence_empty_graph() {
3406        let engine = make_engine();
3407        let report = engine.compute_confidence();
3408        assert_eq!(report.overall, 0.0);
3409        assert!(!report.gaps.is_empty(), "should have gaps with empty graph");
3410        assert!(
3411            !report.suggestions.is_empty(),
3412            "should have suggestions with empty graph"
3413        );
3414    }
3415
3416    #[test]
3417    fn compute_confidence_with_nodes_only() {
3418        let engine = make_engine();
3419        // Add nodes without edges -- should be partially confident.
3420        for i in 0..10 {
3421            engine.causal_graph().add_node(
3422                format!("node-{i}"),
3423                serde_json::json!({"test": true}),
3424            );
3425        }
3426        let report = engine.compute_confidence();
3427        // All orphans: connectivity = 0, but volume > 0.
3428        assert!(report.overall > 0.0, "should have some confidence from volume");
3429        assert!(report.overall < 0.5, "should be low without edges");
3430    }
3431
3432    #[test]
3433    fn compute_confidence_with_connected_graph() {
3434        let engine = make_engine();
3435        // Create a small connected graph.
3436        let n1 = engine.causal_graph().add_node(
3437            "module-a".into(),
3438            serde_json::json!({}),
3439        );
3440        let n2 = engine.causal_graph().add_node(
3441            "module-b".into(),
3442            serde_json::json!({}),
3443        );
3444        let n3 = engine.causal_graph().add_node(
3445            "module-c".into(),
3446            serde_json::json!({}),
3447        );
3448        engine.causal_graph().link(n1, n2, CausalEdgeType::Enables, 1.0, 0, 0);
3449        engine.causal_graph().link(n2, n3, CausalEdgeType::Causes, 0.8, 0, 0);
3450
3451        let report = engine.compute_confidence();
3452        // At least 2 of 3 nodes have edges, so connectivity should be decent.
3453        assert!(report.overall > 0.0);
3454    }
3455
3456    #[test]
3457    fn compute_confidence_detects_orphans() {
3458        let engine = make_engine();
3459        let n1 = engine.causal_graph().add_node(
3460            "connected-a".into(),
3461            serde_json::json!({}),
3462        );
3463        let n2 = engine.causal_graph().add_node(
3464            "connected-b".into(),
3465            serde_json::json!({}),
3466        );
3467        engine.causal_graph().add_node(
3468            "orphan-x".into(),
3469            serde_json::json!({}),
3470        );
3471        engine.causal_graph().link(n1, n2, CausalEdgeType::Follows, 1.0, 0, 0);
3472
3473        let report = engine.compute_confidence();
3474        // The suggestion should mention orphan nodes.
3475        let has_orphan_suggestion = report.suggestions.iter().any(|s| match s {
3476            ModelingSuggestion::AddSource { reason, .. } => reason.contains("orphan"),
3477            _ => false,
3478        });
3479        assert!(has_orphan_suggestion, "should detect the orphan node");
3480    }
3481
3482    #[test]
3483    fn compute_confidence_improves_with_more_data() {
3484        let engine = make_engine();
3485        // Small graph.
3486        let n1 = engine.causal_graph().add_node("a".into(), serde_json::json!({}));
3487        let n2 = engine.causal_graph().add_node("b".into(), serde_json::json!({}));
3488        engine.causal_graph().link(n1, n2, CausalEdgeType::Enables, 1.0, 0, 0);
3489        let c1 = engine.compute_confidence().overall;
3490
3491        // Add more connected nodes.
3492        for i in 0..50 {
3493            let na = engine.causal_graph().add_node(format!("extra-{i}"), serde_json::json!({}));
3494            engine.causal_graph().link(n1, na, CausalEdgeType::Correlates, 0.5, 0, 0);
3495        }
3496        let c2 = engine.compute_confidence().overall;
3497        assert!(c2 > c1, "confidence should increase with more data ({c2} > {c1})");
3498    }
3499
3500    // ── Model export/import roundtrip tests ──────────────────────
3501
3502    #[test]
3503    fn export_model_includes_causal_data() {
3504        let engine = make_engine();
3505        engine.start_session("exp-causal", None, None).unwrap();
3506        // Add some nodes and edges.
3507        let n1 = engine.causal_graph().add_node("node-a".into(), serde_json::json!({}));
3508        let n2 = engine.causal_graph().add_node("node-b".into(), serde_json::json!({}));
3509        engine.causal_graph().link(n1, n2, CausalEdgeType::Causes, 0.9, 0, 0);
3510
3511        let model = engine.export_model("exp-causal", 0.0).unwrap();
3512        assert!(!model.causal_nodes.is_empty(), "exported model should have nodes");
3513        assert!(!model.causal_edges.is_empty(), "exported model should have edges");
3514    }
3515
3516    #[test]
3517    fn export_model_to_file_roundtrip() {
3518        let dir = std::env::temp_dir().join("weaver_test_export");
3519        std::fs::create_dir_all(&dir).ok();
3520        let path = dir.join("test-model.json");
3521
3522        let engine = make_engine();
3523        engine.start_session("roundtrip", None, None).unwrap();
3524        engine.add_source("roundtrip", "git_log", None).unwrap();
3525
3526        // Add some graph data.
3527        let n1 = engine.causal_graph().add_node("rt-a".into(), serde_json::json!({}));
3528        let n2 = engine.causal_graph().add_node("rt-b".into(), serde_json::json!({}));
3529        engine.causal_graph().link(n1, n2, CausalEdgeType::Enables, 1.0, 0, 0);
3530
3531        // Export.
3532        let exported = engine.export_model_to_file("roundtrip", 0.0, &path).unwrap();
3533        assert!(path.exists(), "export file should exist");
3534
3535        // Read back and verify JSON is valid.
3536        let data = std::fs::read_to_string(&path).unwrap();
3537        let reimported: ExportedModel = serde_json::from_str(&data).unwrap();
3538        assert_eq!(reimported.domain, "roundtrip");
3539        assert_eq!(reimported.version, exported.version);
3540        assert_eq!(reimported.causal_nodes.len(), exported.causal_nodes.len());
3541        assert_eq!(reimported.causal_edges.len(), exported.causal_edges.len());
3542
3543        std::fs::remove_dir_all(&dir).ok();
3544    }
3545
3546    #[test]
3547    fn import_model_from_file_works() {
3548        let dir = std::env::temp_dir().join("weaver_test_import");
3549        std::fs::create_dir_all(&dir).ok();
3550        let path = dir.join("import-model.json");
3551
3552        // Write a model file.
3553        let model = ExportedModel {
3554            version: "1.0".into(),
3555            domain: "file-import".into(),
3556            exported_at: Utc::now(),
3557            confidence: 0.82,
3558            node_types: vec![],
3559            edge_types: vec![],
3560            causal_nodes: vec![ExportedCausalNode {
3561                label: "test-node".into(),
3562                metadata: serde_json::json!({}),
3563            }],
3564            causal_edges: vec![],
3565            metadata: HashMap::new(),
3566        };
3567        let json = serde_json::to_string_pretty(&model).unwrap();
3568        std::fs::write(&path, json).unwrap();
3569
3570        let engine = make_engine();
3571        engine.import_model_from_file("file-import", &path).unwrap();
3572        let session = engine.get_session("file-import").unwrap();
3573        assert_eq!(session.confidence, 0.82);
3574        assert!(session.active);
3575
3576        std::fs::remove_dir_all(&dir).ok();
3577    }
3578
3579    // ── Edge type parsing tests ──────────────────────────────────
3580
3581    #[test]
3582    fn parse_edge_type_known_types() {
3583        assert_eq!(WeaverEngine::parse_edge_type("Causes"), CausalEdgeType::Causes);
3584        assert_eq!(WeaverEngine::parse_edge_type("Enables"), CausalEdgeType::Enables);
3585        assert_eq!(WeaverEngine::parse_edge_type("Follows"), CausalEdgeType::Follows);
3586        assert_eq!(WeaverEngine::parse_edge_type("Correlates"), CausalEdgeType::Correlates);
3587        assert_eq!(WeaverEngine::parse_edge_type("EvidenceFor"), CausalEdgeType::EvidenceFor);
3588        assert_eq!(WeaverEngine::parse_edge_type("Inhibits"), CausalEdgeType::Inhibits);
3589        assert_eq!(WeaverEngine::parse_edge_type("Contradicts"), CausalEdgeType::Contradicts);
3590        assert_eq!(WeaverEngine::parse_edge_type("TriggeredBy"), CausalEdgeType::TriggeredBy);
3591    }
3592
3593    #[test]
3594    fn parse_edge_type_unknown_defaults_to_correlates() {
3595        assert_eq!(WeaverEngine::parse_edge_type("FooBar"), CausalEdgeType::Correlates);
3596        assert_eq!(WeaverEngine::parse_edge_type(""), CausalEdgeType::Correlates);
3597    }
3598
3599    // ── WeaverError tests ────────────────────────────────────────
3600
3601    #[test]
3602    fn weaver_error_display() {
3603        let io_err = WeaverError::Io(std::io::Error::new(
3604            std::io::ErrorKind::NotFound,
3605            "file missing",
3606        ));
3607        assert!(io_err.to_string().contains("I/O"));
3608
3609        let domain_err = WeaverError::Domain("test failure".to_string());
3610        assert!(domain_err.to_string().contains("test failure"));
3611    }
3612
3613    // ── CognitiveTick integration tests ─────────────────────────
3614
3615    fn make_engine_mut() -> WeaverEngine {
3616        let graph = Arc::new(CausalGraph::new());
3617        let hnsw = Arc::new(HnswService::new(HnswServiceConfig::default()));
3618        WeaverEngine::new_with_mock(graph, hnsw)
3619    }
3620
3621    #[test]
3622    fn on_tick_respects_budget() {
3623        let mut engine = make_engine_mut();
3624        engine.start_session("tick-budget", None, None).unwrap();
3625        let result = engine.on_tick(500); // 500ms budget
3626        assert!(
3627            result.within_budget,
3628            "tick should complete within 500ms budget"
3629        );
3630        assert!(result.elapsed_ms <= 500, "elapsed should be within budget");
3631    }
3632
3633    #[test]
3634    fn on_tick_returns_correct_fields() {
3635        let mut engine = make_engine_mut();
3636        engine.start_session("tick-fields", None, None).unwrap();
3637        let result = engine.on_tick(100);
3638        assert_eq!(result.budget_ms, 100);
3639        assert_eq!(result.tick_number, 0); // first tick
3640        // No git poller or file watcher configured, so these should be 0.
3641        assert_eq!(result.git_commits_found, 0);
3642        assert_eq!(result.files_changed, 0);
3643    }
3644
3645    #[test]
3646    fn on_tick_increments_tick_count() {
3647        let mut engine = make_engine_mut();
3648        engine.start_session("tick-count", None, None).unwrap();
3649        engine.on_tick(100);
3650        engine.on_tick(100);
3651        engine.on_tick(100);
3652        // on_tick calls tick() internally which also increments, plus on_tick itself.
3653        // The on_tick method does fetch_add(1) each call.
3654        assert!(engine.total_ticks() >= 3, "should have at least 3 ticks");
3655    }
3656
3657    #[test]
3658    fn on_tick_confidence_update_after_100_ticks() {
3659        let mut engine = make_engine_mut();
3660        engine.start_session("conf-update", None, None).unwrap();
3661        // Simulate 101 ticks to trigger confidence update.
3662        engine.ticks_since_confidence_update = 101;
3663        let result = engine.on_tick(1000);
3664        assert!(
3665            result.confidence_updated,
3666            "confidence should be updated after 100+ ticks"
3667        );
3668        assert!(
3669            engine.cached_confidence().is_some(),
3670            "cached confidence should be set"
3671        );
3672        assert_eq!(
3673            engine.ticks_since_confidence_update, 1,
3674            "counter should reset to 1 (incremented after reset)"
3675        );
3676    }
3677
3678    #[test]
3679    fn on_tick_no_confidence_update_before_100_ticks() {
3680        let mut engine = make_engine_mut();
3681        engine.start_session("no-conf-update", None, None).unwrap();
3682        let result = engine.on_tick(100);
3683        assert!(
3684            !result.confidence_updated,
3685            "should not update confidence on first tick"
3686        );
3687    }
3688
3689    #[test]
3690    fn on_tick_git_and_file_watcher_disabled_by_default() {
3691        let engine = make_engine_mut();
3692        assert!(
3693            engine.git_poller().is_none(),
3694            "git poller should be None by default"
3695        );
3696        assert!(
3697            engine.file_watcher().is_none(),
3698            "file watcher should be None by default"
3699        );
3700    }
3701
3702    // ── GitPoller tests ─────────────────────────────────────────
3703
3704    #[test]
3705    fn git_poller_poll_detects_commits_in_real_repo() {
3706        // Use the actual project repo for this test.
3707        let manifest =
3708            std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
3709        let repo_path = PathBuf::from(&manifest).join("../..");
3710        if !repo_path.join(".git").exists() {
3711            return; // skip if not in a git repo
3712        }
3713        let mut poller = GitPoller::new(repo_path, "HEAD".to_string());
3714        // First poll should detect at least 1 commit.
3715        let count = poller.poll();
3716        assert!(count >= 1, "first poll should find at least 1 commit");
3717        assert!(poller.last_hash().is_some(), "last hash should be set");
3718    }
3719
3720    #[test]
3721    fn git_poller_poll_returns_zero_on_no_changes() {
3722        let manifest =
3723            std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
3724        let repo_path = PathBuf::from(&manifest).join("../..");
3725        if !repo_path.join(".git").exists() {
3726            return;
3727        }
3728        let mut poller = GitPoller::new(repo_path, "HEAD".to_string());
3729        poller.poll(); // first poll sets the baseline
3730        let count = poller.poll(); // second poll, no new commits
3731        assert_eq!(count, 0, "second poll should find 0 new commits");
3732    }
3733
3734    #[test]
3735    fn git_poller_disabled_returns_zero() {
3736        let mut poller = GitPoller::new(PathBuf::from("/tmp"), "main".to_string());
3737        poller.set_enabled(false);
3738        assert_eq!(poller.poll(), 0);
3739        assert!(!poller.is_enabled());
3740    }
3741
3742    #[test]
3743    fn git_poller_nonexistent_repo_returns_zero() {
3744        let mut poller = GitPoller::new(
3745            PathBuf::from("/nonexistent/path/to/repo"),
3746            "main".to_string(),
3747        );
3748        let count = poller.poll();
3749        assert_eq!(count, 0, "should return 0 for nonexistent repo");
3750        assert!(poller.last_hash().is_none());
3751    }
3752
3753    #[test]
3754    fn git_poller_branch_accessor() {
3755        let poller = GitPoller::new(PathBuf::from("/tmp"), "develop".to_string());
3756        assert_eq!(poller.branch(), "develop");
3757    }
3758
3759    // ── FileWatcher tests ───────────────────────────────────────
3760
3761    #[test]
3762    fn file_watcher_watch_and_poll_detects_mtime_change() {
3763        let dir = std::env::temp_dir().join("weaver_fw_test_mtime");
3764        std::fs::create_dir_all(&dir).ok();
3765        let path = dir.join("test.rs");
3766        std::fs::write(&path, "fn main() {}").unwrap();
3767
3768        let mut watcher = FileWatcher::new(dir.clone(), vec!["*.rs".to_string()]);
3769        watcher.watch(path.clone());
3770        assert_eq!(watcher.watched_count(), 1);
3771
3772        // First poll: no changes (mtime matches).
3773        let changed = watcher.poll_changes();
3774        assert!(changed.is_empty(), "no changes on first poll");
3775
3776        // Simulate mtime change by sleeping briefly and rewriting.
3777        std::thread::sleep(std::time::Duration::from_millis(50));
3778        std::fs::write(&path, "fn main() { println!(\"changed\"); }").unwrap();
3779
3780        let changed = watcher.poll_changes();
3781        assert_eq!(changed.len(), 1, "should detect the changed file");
3782        assert_eq!(changed[0], path);
3783
3784        std::fs::remove_dir_all(&dir).ok();
3785    }
3786
3787    #[test]
3788    fn file_watcher_watch_directory_registers_files() {
3789        let dir = std::env::temp_dir().join("weaver_fw_test_dir");
3790        std::fs::create_dir_all(&dir).ok();
3791        std::fs::write(dir.join("lib.rs"), "// lib").unwrap();
3792        std::fs::write(dir.join("main.rs"), "// main").unwrap();
3793        std::fs::write(dir.join("readme.md"), "# readme").unwrap();
3794
3795        let mut watcher = FileWatcher::new(dir.clone(), vec!["*.rs".to_string()]);
3796        watcher.watch_directory();
3797        assert_eq!(watcher.watched_count(), 2, "should only watch .rs files");
3798
3799        std::fs::remove_dir_all(&dir).ok();
3800    }
3801
3802    #[test]
3803    fn file_watcher_detects_deleted_file() {
3804        let dir = std::env::temp_dir().join("weaver_fw_test_delete");
3805        std::fs::create_dir_all(&dir).ok();
3806        let path = dir.join("temp.rs");
3807        std::fs::write(&path, "// temp").unwrap();
3808
3809        let mut watcher = FileWatcher::new(dir.clone(), vec!["*.rs".to_string()]);
3810        watcher.watch(path.clone());
3811
3812        // Delete the file.
3813        std::fs::remove_file(&path).unwrap();
3814        let changed = watcher.poll_changes();
3815        assert_eq!(changed.len(), 1, "should detect deleted file");
3816        assert_eq!(watcher.watched_count(), 0, "deleted file should be unregistered");
3817
3818        std::fs::remove_dir_all(&dir).ok();
3819    }
3820
3821    #[test]
3822    fn file_watcher_disabled_returns_empty() {
3823        let mut watcher = FileWatcher::new(
3824            PathBuf::from("/tmp"),
3825            vec!["*.rs".to_string()],
3826        );
3827        watcher.set_enabled(false);
3828        assert!(watcher.poll_changes().is_empty());
3829        assert!(!watcher.is_enabled());
3830    }
3831
3832    // ── WeaverEngine git/file integration tests ─────────────────
3833
3834    #[test]
3835    fn enable_git_polling_sets_poller() {
3836        let mut engine = make_engine_mut();
3837        assert!(engine.git_poller().is_none());
3838        engine.enable_git_polling(PathBuf::from("/tmp"), "main".to_string());
3839        assert!(engine.git_poller().is_some());
3840        assert_eq!(engine.git_poller().unwrap().branch(), "main");
3841    }
3842
3843    #[test]
3844    fn enable_file_watching_sets_watcher() {
3845        let dir = std::env::temp_dir().join("weaver_fw_test_enable");
3846        std::fs::create_dir_all(&dir).ok();
3847        std::fs::write(dir.join("test.rs"), "// test").unwrap();
3848
3849        let mut engine = make_engine_mut();
3850        assert!(engine.file_watcher().is_none());
3851        engine.enable_file_watching(dir.clone(), vec!["*.rs".to_string()]);
3852        assert!(engine.file_watcher().is_some());
3853        assert_eq!(engine.file_watcher().unwrap().watched_count(), 1);
3854
3855        std::fs::remove_dir_all(&dir).ok();
3856    }
3857
3858    #[test]
3859    fn on_tick_with_git_polling_enabled() {
3860        let manifest =
3861            std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
3862        let repo_path = PathBuf::from(&manifest).join("../..");
3863        if !repo_path.join(".git").exists() {
3864            return;
3865        }
3866        let mut engine = make_engine_mut();
3867        engine.start_session("git-tick", None, None).unwrap();
3868        engine.enable_git_polling(repo_path, "HEAD".to_string());
3869        let result = engine.on_tick(500);
3870        // First tick should detect at least 1 commit (initial baseline).
3871        assert!(
3872            result.git_commits_found >= 1,
3873            "first on_tick with git polling should find commits"
3874        );
3875    }
3876
3877    #[test]
3878    fn cognitive_tick_result_default() {
3879        let result = CognitiveTickResult::default();
3880        assert_eq!(result.tick_number, 0);
3881        assert_eq!(result.elapsed_ms, 0);
3882        assert_eq!(result.budget_ms, 0);
3883        assert_eq!(result.git_commits_found, 0);
3884        assert_eq!(result.files_changed, 0);
3885        assert_eq!(result.nodes_processed, 0);
3886        assert!(!result.confidence_updated);
3887        assert!(!result.within_budget);
3888    }
3889
3890    #[test]
3891    fn cognitive_tick_result_serde_roundtrip() {
3892        let result = CognitiveTickResult {
3893            tick_number: 42,
3894            elapsed_ms: 15,
3895            budget_ms: 50,
3896            git_commits_found: 3,
3897            files_changed: 2,
3898            nodes_processed: 10,
3899            confidence_updated: true,
3900            within_budget: true,
3901        };
3902        let json = serde_json::to_string(&result).unwrap();
3903        let restored: CognitiveTickResult = serde_json::from_str(&json).unwrap();
3904        assert_eq!(restored.tick_number, 42);
3905        assert_eq!(restored.git_commits_found, 3);
3906        assert!(restored.confidence_updated);
3907    }
3908
3909    // ── ConfidenceHistory tests (Item 1) ─────────────────────────
3910
3911    #[test]
3912    fn confidence_history_record_and_latest() {
3913        let mut history = ConfidenceHistory::new(10);
3914        assert!(history.is_empty());
3915        assert!(history.latest().is_none());
3916
3917        history.record(ConfidenceSnapshot {
3918            timestamp: Utc::now(),
3919            tick_number: 1,
3920            confidence: 0.5,
3921            node_count: 10,
3922            edge_count: 5,
3923            gap_count: 2,
3924            trigger: ConfidenceTrigger::Periodic,
3925        });
3926        assert_eq!(history.len(), 1);
3927        assert_eq!(history.latest().unwrap().confidence, 0.5);
3928    }
3929
3930    #[test]
3931    fn confidence_history_ring_buffer_eviction() {
3932        let mut history = ConfidenceHistory::new(3);
3933        for i in 0..5 {
3934            history.record(ConfidenceSnapshot {
3935                timestamp: Utc::now(),
3936                tick_number: i,
3937                confidence: i as f64 * 0.1,
3938                node_count: 0,
3939                edge_count: 0,
3940                gap_count: 0,
3941                trigger: ConfidenceTrigger::Periodic,
3942            });
3943        }
3944        assert_eq!(history.len(), 3);
3945        // Oldest should have been evicted; first remaining is tick 2.
3946        assert_eq!(history.all().front().unwrap().tick_number, 2);
3947        assert_eq!(history.latest().unwrap().tick_number, 4);
3948    }
3949
3950    #[test]
3951    fn confidence_history_trend_improving() {
3952        let mut history = ConfidenceHistory::new(10);
3953        for i in 0..5 {
3954            history.record(ConfidenceSnapshot {
3955                timestamp: Utc::now(),
3956                tick_number: i,
3957                confidence: 0.3 + i as f64 * 0.1,
3958                node_count: 0,
3959                edge_count: 0,
3960                gap_count: 0,
3961                trigger: ConfidenceTrigger::Periodic,
3962            });
3963        }
3964        let trend = history.trend(5);
3965        assert_eq!(trend.direction, TrendDirection::Improving);
3966        assert!(trend.delta > 0.01);
3967        assert_eq!(trend.samples, 5);
3968    }
3969
3970    #[test]
3971    fn confidence_history_trend_declining() {
3972        let mut history = ConfidenceHistory::new(10);
3973        for i in 0..4 {
3974            history.record(ConfidenceSnapshot {
3975                timestamp: Utc::now(),
3976                tick_number: i,
3977                confidence: 0.8 - i as f64 * 0.1,
3978                node_count: 0,
3979                edge_count: 0,
3980                gap_count: 0,
3981                trigger: ConfidenceTrigger::Periodic,
3982            });
3983        }
3984        let trend = history.trend(4);
3985        assert_eq!(trend.direction, TrendDirection::Declining);
3986        assert!(trend.delta < -0.01);
3987    }
3988
3989    #[test]
3990    fn confidence_history_trend_stable() {
3991        let mut history = ConfidenceHistory::new(10);
3992        for i in 0..5 {
3993            history.record(ConfidenceSnapshot {
3994                timestamp: Utc::now(),
3995                tick_number: i,
3996                confidence: 0.75,
3997                node_count: 0,
3998                edge_count: 0,
3999                gap_count: 0,
4000                trigger: ConfidenceTrigger::Periodic,
4001            });
4002        }
4003        let trend = history.trend(5);
4004        assert_eq!(trend.direction, TrendDirection::Stable);
4005        assert!(trend.delta.abs() <= 0.01);
4006        assert!((trend.avg_confidence - 0.75).abs() < 0.001);
4007    }
4008
4009    #[test]
4010    fn confidence_history_trend_empty() {
4011        let history = ConfidenceHistory::new(10);
4012        let trend = history.trend(5);
4013        assert_eq!(trend.direction, TrendDirection::Stable);
4014        assert_eq!(trend.samples, 0);
4015    }
4016
4017    #[test]
4018    fn confidence_history_trend_window_clamp() {
4019        let mut history = ConfidenceHistory::new(10);
4020        history.record(ConfidenceSnapshot {
4021            timestamp: Utc::now(),
4022            tick_number: 0,
4023            confidence: 0.5,
4024            node_count: 0,
4025            edge_count: 0,
4026            gap_count: 0,
4027            trigger: ConfidenceTrigger::Manual,
4028        });
4029        // Ask for 100 but only 1 exists.
4030        let trend = history.trend(100);
4031        assert_eq!(trend.samples, 1);
4032    }
4033
4034    // ── StrategyTracker tests (Item 4) ──────────────────────────
4035
4036    #[test]
4037    fn strategy_tracker_begin_complete() {
4038        let mut tracker = StrategyTracker::new(50);
4039        assert!(tracker.is_empty());
4040        let handle = tracker.begin_strategy("add_git", "Add git log", 0.4);
4041        tracker.complete_strategy(handle, 0.55);
4042        assert_eq!(tracker.len(), 1);
4043        let outcome = &tracker.outcomes()[0];
4044        assert_eq!(outcome.strategy, "add_git");
4045        assert!((outcome.delta - 0.15).abs() < 0.001);
4046        assert!(outcome.beneficial);
4047    }
4048
4049    #[test]
4050    fn strategy_tracker_most_effective() {
4051        let mut tracker = StrategyTracker::new(50);
4052        let h1 = tracker.begin_strategy("a", "desc a", 0.3);
4053        tracker.complete_strategy(h1, 0.5); // +0.2
4054        let h2 = tracker.begin_strategy("b", "desc b", 0.5);
4055        tracker.complete_strategy(h2, 0.9); // +0.4
4056        let h3 = tracker.begin_strategy("c", "desc c", 0.9);
4057        tracker.complete_strategy(h3, 0.85); // -0.05
4058
4059        let top = tracker.most_effective(2);
4060        assert_eq!(top.len(), 2);
4061        assert_eq!(top[0].strategy, "b"); // highest delta
4062        assert_eq!(top[1].strategy, "a");
4063    }
4064
4065    #[test]
4066    fn strategy_tracker_harmful_strategies() {
4067        let mut tracker = StrategyTracker::new(50);
4068        let h1 = tracker.begin_strategy("good", "good move", 0.5);
4069        tracker.complete_strategy(h1, 0.7);
4070        let h2 = tracker.begin_strategy("bad", "bad move", 0.7);
4071        tracker.complete_strategy(h2, 0.5);
4072
4073        let harmful = tracker.harmful_strategies();
4074        assert_eq!(harmful.len(), 1);
4075        assert_eq!(harmful[0].strategy, "bad");
4076        assert!(!harmful[0].beneficial);
4077    }
4078
4079    #[test]
4080    fn strategy_tracker_recommend() {
4081        let mut tracker = StrategyTracker::new(50);
4082        assert!(tracker.recommend().is_none());
4083
4084        let h1 = tracker.begin_strategy("small_win", "desc", 0.5);
4085        tracker.complete_strategy(h1, 0.55);
4086        let h2 = tracker.begin_strategy("big_win", "desc", 0.55);
4087        tracker.complete_strategy(h2, 0.85);
4088
4089        assert_eq!(tracker.recommend(), Some("big_win".to_string()));
4090    }
4091
4092    #[test]
4093    fn strategy_tracker_recommend_ignores_harmful() {
4094        let mut tracker = StrategyTracker::new(50);
4095        let h = tracker.begin_strategy("harmful", "desc", 0.5);
4096        tracker.complete_strategy(h, 0.3);
4097        // Only harmful strategies => no recommendation.
4098        assert!(tracker.recommend().is_none());
4099    }
4100
4101    #[test]
4102    fn strategy_tracker_eviction() {
4103        let mut tracker = StrategyTracker::new(2);
4104        let h1 = tracker.begin_strategy("first", "d", 0.1);
4105        tracker.complete_strategy(h1, 0.2);
4106        let h2 = tracker.begin_strategy("second", "d", 0.2);
4107        tracker.complete_strategy(h2, 0.3);
4108        let h3 = tracker.begin_strategy("third", "d", 0.3);
4109        tracker.complete_strategy(h3, 0.4);
4110
4111        assert_eq!(tracker.len(), 2);
4112        assert_eq!(tracker.outcomes()[0].strategy, "second");
4113        assert_eq!(tracker.outcomes()[1].strategy, "third");
4114    }
4115
4116    // ── TickHistory tests (Item 6) ──────────────────────────────
4117
4118    #[test]
4119    fn tick_history_record_and_len() {
4120        let mut history = TickHistory::new(10);
4121        assert!(history.is_empty());
4122        history.record(CognitiveTickResult {
4123            tick_number: 0,
4124            elapsed_ms: 10,
4125            budget_ms: 50,
4126            ..Default::default()
4127        });
4128        assert_eq!(history.len(), 1);
4129    }
4130
4131    #[test]
4132    fn tick_history_eviction() {
4133        let mut history = TickHistory::new(3);
4134        for i in 0..5 {
4135            history.record(CognitiveTickResult {
4136                tick_number: i,
4137                elapsed_ms: 10,
4138                budget_ms: 50,
4139                ..Default::default()
4140            });
4141        }
4142        assert_eq!(history.len(), 3);
4143        assert_eq!(history.all().front().unwrap().tick_number, 2);
4144    }
4145
4146    #[test]
4147    fn tick_history_changes_per_minute() {
4148        let mut history = TickHistory::new(100);
4149        // 10 ticks, each 100ms elapsed, each with 1 git commit.
4150        for i in 0..10 {
4151            history.record(CognitiveTickResult {
4152                tick_number: i,
4153                elapsed_ms: 100,
4154                budget_ms: 200,
4155                git_commits_found: 1,
4156                files_changed: 0,
4157                ..Default::default()
4158            });
4159        }
4160        // Total: 10 changes in 1000ms = 1 second.
4161        // Changes per minute = 10 / (1000/60000) = 600.
4162        let cpm = history.changes_per_minute();
4163        assert!(cpm > 100.0, "expected high cpm, got {cpm}");
4164    }
4165
4166    #[test]
4167    fn tick_history_changes_per_minute_no_changes() {
4168        let mut history = TickHistory::new(100);
4169        for i in 0..10 {
4170            history.record(CognitiveTickResult {
4171                tick_number: i,
4172                elapsed_ms: 100,
4173                budget_ms: 200,
4174                ..Default::default()
4175            });
4176        }
4177        assert_eq!(history.changes_per_minute(), 0.0);
4178    }
4179
4180    #[test]
4181    fn tick_history_changes_per_minute_insufficient_data() {
4182        let history = TickHistory::new(10);
4183        assert_eq!(history.changes_per_minute(), 0.0);
4184
4185        let mut history2 = TickHistory::new(10);
4186        history2.record(CognitiveTickResult::default());
4187        assert_eq!(history2.changes_per_minute(), 0.0);
4188    }
4189
4190    #[test]
4191    fn tick_history_avg_budget_usage() {
4192        let mut history = TickHistory::new(100);
4193        // 50% usage each tick.
4194        for i in 0..5 {
4195            history.record(CognitiveTickResult {
4196                tick_number: i,
4197                elapsed_ms: 50,
4198                budget_ms: 100,
4199                ..Default::default()
4200            });
4201        }
4202        let usage = history.avg_budget_usage();
4203        assert!((usage - 0.5).abs() < 0.01);
4204    }
4205
4206    #[test]
4207    fn tick_history_avg_budget_usage_empty() {
4208        let history = TickHistory::new(10);
4209        assert_eq!(history.avg_budget_usage(), 0.0);
4210    }
4211
4212    #[test]
4213    fn tick_history_idle_ticks() {
4214        let mut history = TickHistory::new(100);
4215        // 3 active ticks followed by 5 idle ticks.
4216        for i in 0..3 {
4217            history.record(CognitiveTickResult {
4218                tick_number: i,
4219                git_commits_found: 1,
4220                ..Default::default()
4221            });
4222        }
4223        for i in 3..8 {
4224            history.record(CognitiveTickResult {
4225                tick_number: i,
4226                ..Default::default()
4227            });
4228        }
4229        assert_eq!(history.idle_ticks(), 5);
4230    }
4231
4232    #[test]
4233    fn tick_history_idle_ticks_none_idle() {
4234        let mut history = TickHistory::new(100);
4235        history.record(CognitiveTickResult {
4236            tick_number: 0,
4237            files_changed: 1,
4238            ..Default::default()
4239        });
4240        assert_eq!(history.idle_ticks(), 0);
4241    }
4242
4243    // ── TickRecommendation tests (Item 6) ────────────────────────
4244
4245    #[test]
4246    fn tick_recommend_insufficient_data() {
4247        let engine = make_engine_mut();
4248        let rec = engine.recommend_tick_interval();
4249        assert_eq!(rec.recommended_ms, engine.current_tick_interval_ms);
4250        assert!(rec.reason.contains("Insufficient"));
4251        assert!(rec.recommendation_confidence < 0.5);
4252    }
4253
4254    #[test]
4255    fn tick_recommend_idle_mode() {
4256        let mut engine = make_engine_mut();
4257        // Fill with 110 idle ticks.
4258        for i in 0..110 {
4259            engine.tick_history.record(CognitiveTickResult {
4260                tick_number: i,
4261                elapsed_ms: 10,
4262                budget_ms: 100,
4263                ..Default::default()
4264            });
4265        }
4266        let rec = engine.recommend_tick_interval();
4267        assert_eq!(rec.recommended_ms, 5000);
4268        assert!(rec.reason.contains("idle"));
4269    }
4270
4271    #[test]
4272    fn tick_recommend_high_frequency() {
4273        let mut engine = make_engine_mut();
4274        // 20 ticks, each 100ms, each with 5 git commits.
4275        for i in 0..20 {
4276            engine.tick_history.record(CognitiveTickResult {
4277                tick_number: i,
4278                elapsed_ms: 100,
4279                budget_ms: 200,
4280                git_commits_found: 5,
4281                ..Default::default()
4282            });
4283        }
4284        let rec = engine.recommend_tick_interval();
4285        assert_eq!(rec.recommended_ms, 200);
4286        assert!(rec.changes_per_minute > 10.0);
4287    }
4288
4289    #[test]
4290    fn tick_recommend_low_frequency() {
4291        let mut engine = make_engine_mut();
4292        // 20 ticks, each 6000ms (6s), 1 change total.
4293        for i in 0..20 {
4294            engine.tick_history.record(CognitiveTickResult {
4295                tick_number: i,
4296                elapsed_ms: 6000,
4297                budget_ms: 10000,
4298                git_commits_found: if i == 0 { 1 } else { 0 },
4299                ..Default::default()
4300            });
4301        }
4302        let rec = engine.recommend_tick_interval();
4303        assert_eq!(rec.recommended_ms, 3000);
4304        assert!(rec.changes_per_minute < 1.0);
4305    }
4306
4307    #[test]
4308    fn tick_recommend_moderate_frequency() {
4309        let mut engine = make_engine_mut();
4310        // 10 ticks, each 1000ms, each with 1 change => ~60 cpm, but
4311        // we need a moderate rate. Let's do 1 change per 10s.
4312        for i in 0..10 {
4313            engine.tick_history.record(CognitiveTickResult {
4314                tick_number: i,
4315                elapsed_ms: 10000,
4316                budget_ms: 15000,
4317                git_commits_found: if i % 5 == 0 { 1 } else { 0 },
4318                files_changed: if i % 3 == 0 { 1 } else { 0 },
4319                ..Default::default()
4320            });
4321        }
4322        let rec = engine.recommend_tick_interval();
4323        // With ~6 changes in 100s => ~3.6 cpm => moderate.
4324        assert!(
4325            rec.recommended_ms == 1000,
4326            "expected 1000ms for moderate, got {}ms (cpm={:.2})",
4327            rec.recommended_ms,
4328            rec.changes_per_minute
4329        );
4330    }
4331
4332    // ── Integration: on_tick records tick_history ─────────────────
4333
4334    #[test]
4335    fn on_tick_records_tick_history() {
4336        let mut engine = make_engine_mut();
4337        engine.start_session("hist", None, None).unwrap();
4338        engine.on_tick(100);
4339        engine.on_tick(100);
4340        assert_eq!(engine.tick_history().len(), 2);
4341    }
4342
4343    // ── Integration: on_tick records confidence snapshot ──────────
4344
4345    #[test]
4346    fn on_tick_records_confidence_snapshot_on_periodic() {
4347        let mut engine = make_engine_mut();
4348        engine.start_session("snap", None, None).unwrap();
4349        engine.ticks_since_confidence_update = 101;
4350        engine.on_tick(1000);
4351        assert!(
4352            !engine.confidence_history().is_empty(),
4353            "should have recorded a confidence snapshot"
4354        );
4355        let snap = engine.confidence_history().latest().unwrap();
4356        assert!(matches!(snap.trigger, ConfidenceTrigger::Periodic));
4357    }
4358
4359    // ── Integration: ingest_graph_file_tracked ───────────────────
4360
4361    #[test]
4362    fn ingest_graph_file_tracked_records_strategy_and_snapshot() {
4363        let dir = std::env::temp_dir().join("weaver_test_tracked");
4364        std::fs::create_dir_all(&dir).ok();
4365        let path = dir.join("small.json");
4366        std::fs::write(
4367            &path,
4368            r#"{"source":"test","nodes":[{"id":"n1","title":"Node 1"}],"edges":[]}"#,
4369        )
4370        .unwrap();
4371
4372        let mut engine = make_engine_mut();
4373        let result = engine.ingest_graph_file_tracked(&path).unwrap();
4374        assert_eq!(result.nodes_added, 1);
4375
4376        // Strategy tracker should have one outcome.
4377        assert_eq!(engine.strategy_tracker().len(), 1);
4378        assert_eq!(engine.strategy_tracker().outcomes()[0].strategy, "ingest:small");
4379
4380        // Confidence history should have at least one PostIngestion snapshot.
4381        assert!(!engine.confidence_history().is_empty());
4382        let has_post_ingestion = engine
4383            .confidence_history()
4384            .all()
4385            .iter()
4386            .any(|s| matches!(s.trigger, ConfidenceTrigger::PostIngestion));
4387        assert!(has_post_ingestion);
4388
4389        std::fs::remove_dir_all(&dir).ok();
4390    }
4391
4392    // ── ConfidenceSnapshot / StrategyOutcome serde tests ─────────
4393
4394    #[test]
4395    fn confidence_snapshot_serde_roundtrip() {
4396        let snap = ConfidenceSnapshot {
4397            timestamp: Utc::now(),
4398            tick_number: 42,
4399            confidence: 0.78,
4400            node_count: 100,
4401            edge_count: 200,
4402            gap_count: 3,
4403            trigger: ConfidenceTrigger::PostIngestion,
4404        };
4405        let json = serde_json::to_string(&snap).unwrap();
4406        let restored: ConfidenceSnapshot = serde_json::from_str(&json).unwrap();
4407        assert_eq!(restored.tick_number, 42);
4408        assert!((restored.confidence - 0.78).abs() < 0.001);
4409    }
4410
4411    #[test]
4412    fn strategy_outcome_serde_roundtrip() {
4413        let outcome = StrategyOutcome {
4414            strategy: "add_git".to_string(),
4415            description: "desc".to_string(),
4416            confidence_before: 0.4,
4417            confidence_after: 0.6,
4418            delta: 0.2,
4419            timestamp: Utc::now(),
4420            beneficial: true,
4421        };
4422        let json = serde_json::to_string(&outcome).unwrap();
4423        let restored: StrategyOutcome = serde_json::from_str(&json).unwrap();
4424        assert_eq!(restored.strategy, "add_git");
4425        assert!(restored.beneficial);
4426    }
4427
4428    #[test]
4429    fn tick_recommendation_serde_roundtrip() {
4430        let rec = TickRecommendation {
4431            recommended_ms: 200,
4432            current_ms: 1000,
4433            reason: "fast".to_string(),
4434            changes_per_minute: 50.0,
4435            recommendation_confidence: 0.8,
4436        };
4437        let json = serde_json::to_string(&rec).unwrap();
4438        let restored: TickRecommendation = serde_json::from_str(&json).unwrap();
4439        assert_eq!(restored.recommended_ms, 200);
4440    }
4441
4442    // ── diff_models tests ───────────────────────────────────────
4443
4444    fn make_model(domain: &str, confidence: f64) -> ExportedModel {
4445        ExportedModel {
4446            version: "1.0".into(),
4447            domain: domain.into(),
4448            exported_at: Utc::now(),
4449            confidence,
4450            node_types: vec![],
4451            edge_types: vec![],
4452            causal_nodes: vec![],
4453            causal_edges: vec![],
4454            metadata: HashMap::new(),
4455        }
4456    }
4457
4458    #[test]
4459    fn diff_models_identical_produces_empty_diff() {
4460        let a = make_model("alpha", 0.8);
4461        let b = a.clone();
4462        let diff = diff_models(&a, &b);
4463        assert!(diff.nodes_only_a.is_empty());
4464        assert!(diff.nodes_only_b.is_empty());
4465        assert!(diff.edges_only_a.is_empty());
4466        assert!(diff.edges_only_b.is_empty());
4467        assert_eq!(diff.causal_nodes_added, 0);
4468        assert_eq!(diff.causal_nodes_removed, 0);
4469        assert_eq!(diff.causal_edges_added, 0);
4470        assert_eq!(diff.causal_edges_removed, 0);
4471        assert_eq!(diff.summary, "models are identical");
4472    }
4473
4474    #[test]
4475    fn diff_models_different_node_types_detected() {
4476        let mut a = make_model("alpha", 0.5);
4477        a.node_types.push(NodeTypeSpec {
4478            name: "module".into(),
4479            embedding_strategy: "hash".into(),
4480            dimensions: 64,
4481        });
4482        a.node_types.push(NodeTypeSpec {
4483            name: "shared".into(),
4484            embedding_strategy: "hash".into(),
4485            dimensions: 64,
4486        });
4487
4488        let mut b = make_model("beta", 0.5);
4489        b.node_types.push(NodeTypeSpec {
4490            name: "commit".into(),
4491            embedding_strategy: "hash".into(),
4492            dimensions: 64,
4493        });
4494        b.node_types.push(NodeTypeSpec {
4495            name: "shared".into(),
4496            embedding_strategy: "hash".into(),
4497            dimensions: 64,
4498        });
4499
4500        let diff = diff_models(&a, &b);
4501        assert_eq!(diff.nodes_only_a, vec!["module"]);
4502        assert_eq!(diff.nodes_only_b, vec!["commit"]);
4503        assert_eq!(diff.nodes_common, vec!["shared"]);
4504    }
4505
4506    #[test]
4507    fn diff_models_causal_additions_removals_counted() {
4508        let mut a = make_model("alpha", 0.5);
4509        a.causal_nodes.push(ExportedCausalNode {
4510            label: "A".into(),
4511            metadata: serde_json::json!({}),
4512        });
4513        a.causal_nodes.push(ExportedCausalNode {
4514            label: "shared".into(),
4515            metadata: serde_json::json!({}),
4516        });
4517
4518        let mut b = make_model("beta", 0.5);
4519        b.causal_nodes.push(ExportedCausalNode {
4520            label: "B".into(),
4521            metadata: serde_json::json!({}),
4522        });
4523        b.causal_nodes.push(ExportedCausalNode {
4524            label: "shared".into(),
4525            metadata: serde_json::json!({}),
4526        });
4527
4528        let diff = diff_models(&a, &b);
4529        assert_eq!(diff.causal_nodes_added, 1); // B
4530        assert_eq!(diff.causal_nodes_removed, 1); // A
4531    }
4532
4533    #[test]
4534    fn diff_models_causal_edge_changes() {
4535        let mut a = make_model("alpha", 0.5);
4536        a.causal_edges.push(ExportedCausalEdge {
4537            source_label: "X".into(),
4538            target_label: "Y".into(),
4539            edge_type: "Causes".into(),
4540            weight: 1.0,
4541        });
4542
4543        let mut b = make_model("beta", 0.5);
4544        b.causal_edges.push(ExportedCausalEdge {
4545            source_label: "Y".into(),
4546            target_label: "Z".into(),
4547            edge_type: "Enables".into(),
4548            weight: 0.5,
4549        });
4550
4551        let diff = diff_models(&a, &b);
4552        assert_eq!(diff.causal_edges_added, 1);
4553        assert_eq!(diff.causal_edges_removed, 1);
4554    }
4555
4556    #[test]
4557    fn diff_models_confidence_delta_in_summary() {
4558        let a = make_model("alpha", 0.5);
4559        let b = make_model("beta", 0.8);
4560        let diff = diff_models(&a, &b);
4561        assert!((diff.confidence_delta - 0.3).abs() < 1e-10);
4562        assert!(diff.summary.contains("increased"));
4563    }
4564
4565    #[test]
4566    fn diff_models_summary_shows_decrease() {
4567        let a = make_model("alpha", 0.9);
4568        let b = make_model("beta", 0.6);
4569        let diff = diff_models(&a, &b);
4570        assert!(diff.summary.contains("decreased"));
4571    }
4572
4573    #[test]
4574    fn diff_models_edge_type_differences() {
4575        let mut a = make_model("a", 0.5);
4576        a.edge_types.push(EdgeTypeSpec {
4577            from_type: "mod".into(),
4578            to_type: "mod".into(),
4579            edge_type: "uses".into(),
4580            confidence: 0.8,
4581        });
4582
4583        let mut b = make_model("b", 0.5);
4584        b.edge_types.push(EdgeTypeSpec {
4585            from_type: "mod".into(),
4586            to_type: "test".into(),
4587            edge_type: "tests".into(),
4588            confidence: 0.7,
4589        });
4590
4591        let diff = diff_models(&a, &b);
4592        assert_eq!(diff.edges_only_a.len(), 1);
4593        assert_eq!(diff.edges_only_b.len(), 1);
4594        assert!(diff.edges_common.is_empty());
4595    }
4596
4597    // ── merge_models tests ──────────────────────────────────────
4598
4599    #[test]
4600    fn merge_models_disjoint_produces_union() {
4601        let mut a = make_model("alpha", 0.6);
4602        a.node_types.push(NodeTypeSpec {
4603            name: "mod_a".into(),
4604            embedding_strategy: "hash".into(),
4605            dimensions: 64,
4606        });
4607        a.causal_nodes.push(ExportedCausalNode {
4608            label: "A1".into(),
4609            metadata: serde_json::json!({}),
4610        });
4611
4612        let mut b = make_model("beta", 0.8);
4613        b.node_types.push(NodeTypeSpec {
4614            name: "mod_b".into(),
4615            embedding_strategy: "hash".into(),
4616            dimensions: 64,
4617        });
4618        b.causal_nodes.push(ExportedCausalNode {
4619            label: "B1".into(),
4620            metadata: serde_json::json!({}),
4621        });
4622
4623        let result = merge_models(&a, &b);
4624        assert_eq!(result.stats.total_node_types, 2);
4625        assert_eq!(result.stats.total_causal_nodes, 2);
4626        assert_eq!(result.stats.nodes_from_a, 1);
4627        assert_eq!(result.stats.nodes_from_b, 1);
4628        assert_eq!(result.stats.nodes_shared, 0);
4629        assert_eq!(result.conflicts.len(), 0);
4630    }
4631
4632    #[test]
4633    fn merge_models_overlapping_nodes_higher_confidence() {
4634        let mut a = make_model("alpha", 0.6);
4635        a.node_types.push(NodeTypeSpec {
4636            name: "shared".into(),
4637            embedding_strategy: "hash_v1".into(),
4638            dimensions: 64,
4639        });
4640
4641        let mut b = make_model("beta", 0.8);
4642        b.node_types.push(NodeTypeSpec {
4643            name: "shared".into(),
4644            embedding_strategy: "hash_v2".into(),
4645            dimensions: 128,
4646        });
4647
4648        let result = merge_models(&a, &b);
4649        assert_eq!(result.stats.nodes_shared, 1);
4650        assert_eq!(result.conflicts.len(), 1);
4651        // B has higher dimensions so KeepB.
4652        assert_eq!(result.conflicts[0].resolution, ConflictResolution::KeepB);
4653        assert_eq!(
4654            result.merged.node_types[0].embedding_strategy,
4655            "hash_v2"
4656        );
4657    }
4658
4659    #[test]
4660    fn merge_models_causal_edges_merged_by_id() {
4661        let mut a = make_model("alpha", 0.5);
4662        a.causal_edges.push(ExportedCausalEdge {
4663            source_label: "X".into(),
4664            target_label: "Y".into(),
4665            edge_type: "Causes".into(),
4666            weight: 1.0,
4667        });
4668
4669        let mut b = make_model("beta", 0.5);
4670        b.causal_edges.push(ExportedCausalEdge {
4671            source_label: "X".into(),
4672            target_label: "Y".into(),
4673            edge_type: "Causes".into(),
4674            weight: 0.5,
4675        });
4676
4677        let result = merge_models(&a, &b);
4678        assert_eq!(result.stats.total_causal_edges, 1);
4679        // Weight should be averaged: (1.0 + 0.5) / 2.0 = 0.75
4680        assert!((result.merged.causal_edges[0].weight - 0.75).abs() < 1e-5);
4681    }
4682
4683    #[test]
4684    fn merge_models_conflict_resolution_recorded() {
4685        let mut a = make_model("alpha", 0.5);
4686        a.edge_types.push(EdgeTypeSpec {
4687            from_type: "m".into(),
4688            to_type: "m".into(),
4689            edge_type: "uses".into(),
4690            confidence: 0.3,
4691        });
4692
4693        let mut b = make_model("beta", 0.5);
4694        b.edge_types.push(EdgeTypeSpec {
4695            from_type: "m".into(),
4696            to_type: "m".into(),
4697            edge_type: "uses".into(),
4698            confidence: 0.9,
4699        });
4700
4701        let result = merge_models(&a, &b);
4702        assert_eq!(result.conflicts.len(), 1);
4703        assert_eq!(
4704            result.conflicts[0].resolution,
4705            ConflictResolution::HigherConfidence
4706        );
4707        // The higher confidence edge (0.9) should win.
4708        assert!((result.merged.edge_types[0].confidence - 0.9).abs() < 1e-10);
4709    }
4710
4711    #[test]
4712    fn merge_models_confidence_is_weighted_average() {
4713        let mut a = make_model("alpha", 0.4);
4714        a.causal_nodes.push(ExportedCausalNode {
4715            label: "A1".into(),
4716            metadata: serde_json::json!({}),
4717        });
4718        // A has 1 node
4719
4720        let mut b = make_model("beta", 0.8);
4721        b.causal_nodes.push(ExportedCausalNode {
4722            label: "B1".into(),
4723            metadata: serde_json::json!({}),
4724        });
4725        b.causal_nodes.push(ExportedCausalNode {
4726            label: "B2".into(),
4727            metadata: serde_json::json!({}),
4728        });
4729        b.causal_nodes.push(ExportedCausalNode {
4730            label: "B3".into(),
4731            metadata: serde_json::json!({}),
4732        });
4733        // B has 3 nodes
4734
4735        let result = merge_models(&a, &b);
4736        // Weighted: (0.4*1 + 0.8*3) / (1+3) = 2.8/4 = 0.7
4737        assert!((result.merged.confidence - 0.7).abs() < 1e-10);
4738    }
4739
4740    #[test]
4741    fn merge_models_domain_combined() {
4742        let a = make_model("alpha", 0.5);
4743        let b = make_model("beta", 0.5);
4744        let result = merge_models(&a, &b);
4745        assert_eq!(result.merged.domain, "alpha+beta");
4746    }
4747
4748    #[test]
4749    fn merge_models_causal_edge_type_conflict() {
4750        let mut a = make_model("a", 0.5);
4751        a.causal_edges.push(ExportedCausalEdge {
4752            source_label: "X".into(),
4753            target_label: "Y".into(),
4754            edge_type: "Causes".into(),
4755            weight: 1.0,
4756        });
4757
4758        let mut b = make_model("b", 0.5);
4759        b.causal_edges.push(ExportedCausalEdge {
4760            source_label: "X".into(),
4761            target_label: "Y".into(),
4762            edge_type: "Enables".into(),
4763            weight: 0.5,
4764        });
4765
4766        let result = merge_models(&a, &b);
4767        assert!(result.conflicts.iter().any(|c| {
4768            c.item.starts_with("causal_edge:")
4769                && c.resolution == ConflictResolution::Merged
4770        }));
4771    }
4772
4773    // ── knowledge_base persistence tests ────────────────────────
4774
4775    #[test]
4776    fn knowledge_base_save_load_roundtrip() {
4777        let dir = std::env::temp_dir().join("weaver_kb_test_roundtrip");
4778        std::fs::create_dir_all(&dir).ok();
4779        let path = dir.join("kb.json");
4780
4781        let kb = WeaverKnowledgeBase::new();
4782        kb.record_strategy(StrategyPattern {
4783            decision_type: "SourceAdded".into(),
4784            context: "rust-project".into(),
4785            improvement: 0.15,
4786            timestamp: Utc::now(),
4787        });
4788        kb.record_strategy(StrategyPattern {
4789            decision_type: "EdgeType".into(),
4790            context: "python-project".into(),
4791            improvement: 0.25,
4792            timestamp: Utc::now(),
4793        });
4794
4795        kb.save_to_file(&path).unwrap();
4796        let loaded = WeaverKnowledgeBase::load_from_file(&path).unwrap();
4797        assert_eq!(loaded.pattern_count(), 2);
4798
4799        let strategies = loaded.list_strategies();
4800        assert!(strategies
4801            .iter()
4802            .any(|s| s.decision_type == "SourceAdded" && s.context == "rust-project"));
4803        assert!(strategies
4804            .iter()
4805            .any(|s| s.decision_type == "EdgeType" && s.context == "python-project"));
4806
4807        std::fs::remove_dir_all(&dir).ok();
4808    }
4809
4810    #[test]
4811    fn knowledge_base_learn_pattern_adds_new() {
4812        let kb = WeaverKnowledgeBase::new();
4813        kb.learn_pattern(StrategyPattern {
4814            decision_type: "SourceAdded".into(),
4815            context: "rust".into(),
4816            improvement: 0.1,
4817            timestamp: Utc::now(),
4818        });
4819        assert_eq!(kb.pattern_count(), 1);
4820
4821        kb.learn_pattern(StrategyPattern {
4822            decision_type: "EdgeType".into(),
4823            context: "python".into(),
4824            improvement: 0.2,
4825            timestamp: Utc::now(),
4826        });
4827        assert_eq!(kb.pattern_count(), 2);
4828    }
4829
4830    #[test]
4831    fn knowledge_base_learn_pattern_updates_existing() {
4832        let kb = WeaverKnowledgeBase::new();
4833        kb.learn_pattern(StrategyPattern {
4834            decision_type: "SourceAdded".into(),
4835            context: "rust".into(),
4836            improvement: 0.1,
4837            timestamp: Utc::now(),
4838        });
4839
4840        // Same decision_type + context should update, not add.
4841        kb.learn_pattern(StrategyPattern {
4842            decision_type: "SourceAdded".into(),
4843            context: "rust".into(),
4844            improvement: 0.3,
4845            timestamp: Utc::now(),
4846        });
4847
4848        assert_eq!(kb.pattern_count(), 1);
4849        let strategies = kb.list_strategies();
4850        // Average of 0.1 and 0.3 = 0.2
4851        assert!((strategies[0].improvement - 0.2).abs() < 1e-10);
4852    }
4853
4854    #[test]
4855    fn knowledge_base_find_patterns_returns_matching() {
4856        let kb = WeaverKnowledgeBase::new();
4857        kb.learn_pattern(StrategyPattern {
4858            decision_type: "SourceAdded".into(),
4859            context: "rust-backend".into(),
4860            improvement: 0.1,
4861            timestamp: Utc::now(),
4862        });
4863        kb.learn_pattern(StrategyPattern {
4864            decision_type: "EdgeType".into(),
4865            context: "python-ml".into(),
4866            improvement: 0.2,
4867            timestamp: Utc::now(),
4868        });
4869        kb.learn_pattern(StrategyPattern {
4870            decision_type: "Tick".into(),
4871            context: "go-service".into(),
4872            improvement: 0.15,
4873            timestamp: Utc::now(),
4874        });
4875
4876        let matches = kb.find_patterns(&["rust".to_string()]);
4877        assert_eq!(matches.len(), 1);
4878        assert_eq!(matches[0].context, "rust-backend");
4879    }
4880
4881    #[test]
4882    fn knowledge_base_find_patterns_sorted_by_relevance() {
4883        let kb = WeaverKnowledgeBase::new();
4884        kb.learn_pattern(StrategyPattern {
4885            decision_type: "A".into(),
4886            context: "rust".into(),
4887            improvement: 0.1,
4888            timestamp: Utc::now(),
4889        });
4890        kb.learn_pattern(StrategyPattern {
4891            decision_type: "B".into(),
4892            context: "rust-backend-api".into(),
4893            improvement: 0.2,
4894            timestamp: Utc::now(),
4895        });
4896        kb.learn_pattern(StrategyPattern {
4897            decision_type: "C".into(),
4898            context: "python".into(),
4899            improvement: 0.3,
4900            timestamp: Utc::now(),
4901        });
4902
4903        let matches = kb.find_patterns(&[
4904            "rust".to_string(),
4905            "backend".to_string(),
4906            "api".to_string(),
4907        ]);
4908        // B should rank first (matches rust, backend, api = 3 hits).
4909        // A should rank second (matches rust = 1 hit).
4910        // C should not appear.
4911        assert_eq!(matches.len(), 2);
4912        assert_eq!(matches[0].decision_type, "B");
4913        assert_eq!(matches[1].decision_type, "A");
4914    }
4915
4916    #[test]
4917    fn knowledge_base_empty_handles_gracefully() {
4918        let kb = WeaverKnowledgeBase::new();
4919        assert_eq!(kb.pattern_count(), 0);
4920        assert!(kb.find_patterns(&["rust".to_string()]).is_empty());
4921        assert!(kb.list_strategies().is_empty());
4922
4923        // save/load empty KB.
4924        let dir = std::env::temp_dir().join("weaver_kb_empty_test");
4925        std::fs::create_dir_all(&dir).ok();
4926        let path = dir.join("empty-kb.json");
4927        kb.save_to_file(&path).unwrap();
4928        let loaded = WeaverKnowledgeBase::load_from_file(&path).unwrap();
4929        assert_eq!(loaded.pattern_count(), 0);
4930        std::fs::remove_dir_all(&dir).ok();
4931    }
4932
4933    #[test]
4934    fn knowledge_base_serializable_kb_fields() {
4935        let kb = WeaverKnowledgeBase::new();
4936        kb.record_strategy(StrategyPattern {
4937            decision_type: "SourceAdded".into(),
4938            context: "rust".into(),
4939            improvement: 0.1,
4940            timestamp: Utc::now(),
4941        });
4942        let ser = kb.to_serializable();
4943        assert_eq!(ser.version, 1);
4944        assert_eq!(ser.patterns.len(), 1);
4945        assert!(ser.domains_modeled.contains(&"rust".to_string()));
4946    }
4947
4948    #[test]
4949    fn knowledge_base_load_nonexistent_file_errors() {
4950        let result = WeaverKnowledgeBase::load_from_file(
4951            Path::new("/nonexistent/path/kb.json"),
4952        );
4953        assert!(result.is_err());
4954    }
4955
4956    #[test]
4957    fn knowledge_base_find_patterns_no_match_returns_empty() {
4958        let kb = WeaverKnowledgeBase::new();
4959        kb.learn_pattern(StrategyPattern {
4960            decision_type: "X".into(),
4961            context: "rust".into(),
4962            improvement: 0.1,
4963            timestamp: Utc::now(),
4964        });
4965        let matches = kb.find_patterns(&["java".to_string()]);
4966        assert!(matches.is_empty());
4967    }
4968}