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