1use 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#[non_exhaustive]
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub enum WeaverCommand {
38 SessionStart {
40 domain: String,
41 git_path: Option<PathBuf>,
42 context: Option<String>,
43 goal: Option<String>,
44 },
45 SessionResume { domain: String },
47 SessionStop { domain: String },
49 SessionWatch { domain: String },
51 SourceAdd {
53 domain: String,
54 source_type: String,
55 root: Option<PathBuf>,
56 watch: bool,
57 },
58 SourceList { domain: String },
60 Confidence {
62 domain: String,
63 edge: Option<String>,
64 verbose: bool,
65 },
66 Export {
68 domain: String,
69 min_confidence: f64,
70 output: PathBuf,
71 },
72 Import { domain: String, input: PathBuf },
74 MetaStatus { domain: String },
76 MetaStrategies,
78 MetaExportKb { output: PathBuf },
80 Stitch {
82 source: String,
83 target: String,
84 output: String,
85 },
86}
87
88#[non_exhaustive]
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub enum WeaverResponse {
96 SessionStarted { domain: String, session_id: String },
98 SessionStopped { domain: String },
100 SessionResumed { domain: String },
102 ConfidenceReport(ConfidenceReport),
104 SourceAdded { domain: String, source_type: String },
106 Sources(Vec<String>),
108 Exported { path: PathBuf, edges: usize },
110 Imported { domain: String },
112 Strategies(Vec<StrategyPattern>),
114 KbExported { path: PathBuf },
116 Error(String),
118}
119
120#[non_exhaustive]
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub enum DataSource {
128 GitLog { path: PathBuf },
130 FileTree { root: PathBuf },
132 CiPipeline { url: String },
134 IssueTracker { url: String },
136 Documentation { root: PathBuf },
138 SparcPlan { root: PathBuf },
140 CustomStream { name: String },
142}
143
144impl DataSource {
145 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#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct ModelingSession {
166 pub id: String,
168 pub domain: String,
170 pub started_at: DateTime<Utc>,
172 pub confidence: f64,
174 pub gaps: Vec<ConfidenceGap>,
176 pub sources_ingested: Vec<String>,
178 pub tick_count: u64,
180 pub budget_remaining_ms: u64,
182 pub active: bool,
184 pub metadata: HashMap<String, serde_json::Value>,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct ConfidenceGap {
195 pub domain: String,
197 pub current_confidence: f64,
199 pub target_confidence: f64,
201 pub suggested_sources: Vec<String>,
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct ConfidenceReport {
208 pub overall: f64,
210 pub gaps: Vec<ConfidenceGap>,
212 pub suggestions: Vec<ModelingSuggestion>,
214}
215
216#[non_exhaustive]
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub enum ModelingSuggestion {
220 AddSource { source_type: String, reason: String },
222 RefineEdgeType { from: String, to: String },
224 SplitCategory { category: String },
226 ExtendObservation { domain: String },
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct ExportedModel {
237 pub version: String,
239 pub domain: String,
241 pub exported_at: DateTime<Utc>,
243 pub confidence: f64,
245 pub node_types: Vec<NodeTypeSpec>,
247 pub edge_types: Vec<EdgeTypeSpec>,
249 pub causal_nodes: Vec<ExportedCausalNode>,
251 pub causal_edges: Vec<ExportedCausalEdge>,
253 pub metadata: HashMap<String, serde_json::Value>,
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct NodeTypeSpec {
260 pub name: String,
262 pub embedding_strategy: String,
264 pub dimensions: usize,
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct EdgeTypeSpec {
271 pub from_type: String,
273 pub to_type: String,
275 pub edge_type: String,
277 pub confidence: f64,
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct ExportedCausalNode {
284 pub label: String,
286 pub metadata: serde_json::Value,
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct ExportedCausalEdge {
293 pub source_label: String,
295 pub target_label: String,
297 pub edge_type: String,
299 pub weight: f32,
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct MetaLoomEvent {
310 pub session_domain: String,
312 pub decision_type: MetaDecisionType,
314 pub confidence_before: f64,
316 pub confidence_after: Option<f64>,
318 pub rationale: String,
320 pub timestamp: DateTime<Utc>,
322}
323
324#[non_exhaustive]
326#[derive(Debug, Clone, Serialize, Deserialize)]
327pub enum MetaDecisionType {
328 SourceAdded { source_type: String },
330 EdgeTypeCreated {
332 from: String,
333 to: String,
334 edge_type: String,
335 },
336 EdgeTypeRemoved { from: String, to: String },
338 EmbeddingStrategyChanged {
340 node_type: String,
341 old: String,
342 new: String,
343 },
344 TickIntervalAdjusted { old_ms: u64, new_ms: u64 },
346 ModelVersionBumped { from: u32, to: u32 },
348 StrategyLearned { pattern: String },
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct StrategyPattern {
359 pub decision_type: String,
361 pub context: String,
363 pub improvement: f64,
365 pub timestamp: DateTime<Utc>,
367}
368
369pub struct WeaverKnowledgeBase {
371 strategies: RwLock<Vec<StrategyPattern>>,
373 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 pub fn new() -> Self {
389 Self::default()
390 }
391
392 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 pub fn list_strategies(&self) -> Vec<StrategyPattern> {
402 self.strategies
403 .read()
404 .map(|s| s.clone())
405 .unwrap_or_default()
406 }
407
408 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 pub fn export(&self) -> serde_json::Value {
423 serde_json::to_value(self.list_strategies()).unwrap_or_default()
424 }
425
426 pub fn count(&self) -> u64 {
428 self.strategy_count.load(Ordering::Relaxed)
429 }
430}
431
432#[non_exhaustive]
438#[derive(Debug)]
439pub enum TickResult {
440 Idle,
442 BudgetExhausted,
444 Progress {
446 confidence: f64,
448 gaps_remaining: usize,
450 },
451}
452
453#[non_exhaustive]
459#[derive(Debug)]
460pub enum WeaverError {
461 Io(std::io::Error),
463 Json(serde_json::Error),
465 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#[derive(Debug, Clone, Serialize, Deserialize)]
499pub struct IngestResult {
500 pub nodes_added: usize,
502 pub edges_added: usize,
504 pub embeddings_created: usize,
506 pub source: String,
508}
509
510pub struct GitPoller {
516 repo_path: PathBuf,
518 last_known_hash: Option<String>,
520 branch: String,
522 enabled: bool,
524}
525
526impl GitPoller {
527 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 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(¤t_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 };
576
577 self.last_known_hash = Some(current_hash);
578 count
579 }
580 _ => 0,
581 }
582 }
583
584 pub fn last_hash(&self) -> Option<&str> {
586 self.last_known_hash.as_deref()
587 }
588
589 pub fn branch(&self) -> &str {
591 &self.branch
592 }
593
594 pub fn is_enabled(&self) -> bool {
596 self.enabled
597 }
598
599 pub fn set_enabled(&mut self, enabled: bool) {
601 self.enabled = enabled;
602 }
603}
604
605pub struct FileWatcher {
614 watched: HashMap<PathBuf, SystemTime>,
616 root: PathBuf,
618 patterns: Vec<String>,
620 enabled: bool,
622}
623
624impl FileWatcher {
625 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 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 changed.push(path.clone());
657 self.watched.remove(&path);
658 }
659 }
660
661 changed
662 }
663
664 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 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 pub fn watched_count(&self) -> usize {
697 self.watched.len()
698 }
699
700 pub fn is_enabled(&self) -> bool {
702 self.enabled
703 }
704
705 pub fn set_enabled(&mut self, enabled: bool) {
707 self.enabled = enabled;
708 }
709}
710
711#[derive(Debug, Clone, Default, Serialize, Deserialize)]
720pub struct CognitiveTickResult {
721 pub tick_number: u64,
723 pub elapsed_ms: u32,
725 pub budget_ms: u32,
727 pub git_commits_found: usize,
729 pub files_changed: usize,
731 pub nodes_processed: usize,
733 pub confidence_updated: bool,
735 pub within_budget: bool,
737}
738
739#[non_exhaustive]
745#[derive(Debug, Clone, Serialize, Deserialize)]
746pub enum ConfidenceTrigger {
747 Periodic,
749 PostIngestion,
751 Manual,
753 StrategyChange,
755}
756
757#[derive(Debug, Clone, Serialize, Deserialize)]
759pub struct ConfidenceSnapshot {
760 pub timestamp: DateTime<Utc>,
762 pub tick_number: u64,
764 pub confidence: f64,
766 pub node_count: usize,
768 pub edge_count: usize,
770 pub gap_count: usize,
772 pub trigger: ConfidenceTrigger,
774}
775
776#[non_exhaustive]
778#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
779pub enum TrendDirection {
780 Improving,
782 Stable,
784 Declining,
786}
787
788#[derive(Debug, Clone, Serialize, Deserialize)]
790pub struct ConfidenceTrend {
791 pub direction: TrendDirection,
793 pub delta: f64,
795 pub avg_confidence: f64,
797 pub samples: usize,
799}
800
801pub struct ConfidenceHistory {
803 snapshots: VecDeque<ConfidenceSnapshot>,
804 max_entries: usize,
805}
806
807impl ConfidenceHistory {
808 pub fn new(max_entries: usize) -> Self {
810 Self {
811 snapshots: VecDeque::with_capacity(max_entries),
812 max_entries,
813 }
814 }
815
816 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 pub fn latest(&self) -> Option<&ConfidenceSnapshot> {
826 self.snapshots.back()
827 }
828
829 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 pub fn all(&self) -> &VecDeque<ConfidenceSnapshot> {
868 &self.snapshots
869 }
870
871 pub fn len(&self) -> usize {
873 self.snapshots.len()
874 }
875
876 pub fn is_empty(&self) -> bool {
878 self.snapshots.is_empty()
879 }
880}
881
882#[derive(Debug)]
888pub struct StrategyHandle {
889 pub name: String,
891 pub description: String,
893 pub confidence_before: f64,
895 pub started_at: DateTime<Utc>,
897}
898
899#[derive(Debug, Clone, Serialize, Deserialize)]
901pub struct StrategyOutcome {
902 pub strategy: String,
904 pub description: String,
906 pub confidence_before: f64,
908 pub confidence_after: f64,
910 pub delta: f64,
912 pub timestamp: DateTime<Utc>,
914 pub beneficial: bool,
916}
917
918pub struct StrategyTracker {
920 outcomes: Vec<StrategyOutcome>,
921 max_outcomes: usize,
922}
923
924impl StrategyTracker {
925 pub fn new(max_outcomes: usize) -> Self {
927 Self {
928 outcomes: Vec::new(),
929 max_outcomes,
930 }
931 }
932
933 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 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 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 pub fn harmful_strategies(&self) -> Vec<&StrategyOutcome> {
981 self.outcomes.iter().filter(|o| o.delta < -0.01).collect()
982 }
983
984 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 pub fn outcomes(&self) -> &[StrategyOutcome] {
1002 &self.outcomes
1003 }
1004
1005 pub fn len(&self) -> usize {
1007 self.outcomes.len()
1008 }
1009
1010 pub fn is_empty(&self) -> bool {
1012 self.outcomes.is_empty()
1013 }
1014}
1015
1016#[derive(Debug, Clone, Serialize, Deserialize)]
1022pub struct TickRecommendation {
1023 pub recommended_ms: u32,
1025 pub current_ms: u32,
1027 pub reason: String,
1029 pub changes_per_minute: f64,
1031 pub recommendation_confidence: f64,
1033}
1034
1035pub struct TickHistory {
1037 results: VecDeque<CognitiveTickResult>,
1038 max_entries: usize,
1039}
1040
1041impl TickHistory {
1042 pub fn new(max_entries: usize) -> Self {
1044 Self {
1045 results: VecDeque::with_capacity(max_entries),
1046 max_entries,
1047 }
1048 }
1049
1050 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 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 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 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 pub fn len(&self) -> usize {
1120 self.results.len()
1121 }
1122
1123 pub fn is_empty(&self) -> bool {
1125 self.results.is_empty()
1126 }
1127
1128 pub fn all(&self) -> &VecDeque<CognitiveTickResult> {
1130 &self.results
1131 }
1132}
1133
1134pub struct WeaverEngine {
1143 sessions: RwLock<HashMap<String, ModelingSession>>,
1145 knowledge_base: Arc<WeaverKnowledgeBase>,
1147 embedding_provider: Arc<dyn EmbeddingProvider>,
1149 causal_graph: Arc<CausalGraph>,
1151 #[allow(dead_code)]
1153 hnsw: Arc<HnswService>,
1154 impulse_queue: Option<Arc<ImpulseQueue>>,
1156 meta_loom_events: RwLock<HashMap<String, Vec<MetaLoomEvent>>>,
1158 tick_count: AtomicU64,
1160 git_poller: Option<GitPoller>,
1162 file_watcher: Option<FileWatcher>,
1164 ticks_since_confidence_update: u64,
1166 last_confidence: Option<ConfidenceReport>,
1168 confidence_history: ConfidenceHistory,
1170 strategy_tracker: StrategyTracker,
1172 tick_history: TickHistory,
1174 current_tick_interval_ms: u32,
1176 tick_interval_model: Option<crate::eml_kernel::TickIntervalModel>,
1181}
1182
1183impl WeaverEngine {
1184 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 pub fn set_tick_interval_model(&mut self, model: crate::eml_kernel::TickIntervalModel) {
1221 self.tick_interval_model = Some(model);
1222 }
1223
1224 pub fn tick_interval_model(&self) -> Option<&crate::eml_kernel::TickIntervalModel> {
1226 self.tick_interval_model.as_ref()
1227 }
1228
1229 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 pub fn set_impulse_queue(&mut self, queue: Arc<ImpulseQueue>) {
1236 self.impulse_queue = Some(queue);
1237 }
1238
1239 pub fn knowledge_base(&self) -> &Arc<WeaverKnowledgeBase> {
1241 &self.knowledge_base
1242 }
1243
1244 pub fn embedding_provider(&self) -> &Arc<dyn EmbeddingProvider> {
1246 &self.embedding_provider
1247 }
1248
1249 pub fn causal_graph(&self) -> &Arc<CausalGraph> {
1251 &self.causal_graph
1252 }
1253
1254 pub fn hnsw(&self) -> &Arc<HnswService> {
1256 &self.hnsw
1257 }
1258
1259 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 let mut id_map: HashMap<String, u64> = HashMap::with_capacity(nodes.len());
1282
1283 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 let embed_text = Self::node_to_embed_text(node, &source);
1304 if !embed_text.is_empty() {
1305 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 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 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 self.strategy_tracker
1372 .complete_strategy(handle, confidence_after);
1373
1374 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 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 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 serde_json::to_string(node).unwrap_or_default()
1443 }
1444 }
1445 }
1446
1447 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 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 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 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 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 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 let volume_score = (node_count as f64 / 100.0).min(1.0);
1551 let density_capped = (edge_density * 50.0).min(1.0); 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 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 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 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 (total_sources as f64 / 3.0).min(1.0)
1608 }
1609
1610 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 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 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, 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 self.record_meta_loom(
1682 domain,
1683 MetaDecisionType::ModelVersionBumped { from: 0, to: 1 },
1684 "Session initialized",
1685 0.0,
1686 );
1687
1688 self.emit_impulse(ImpulseType::Custom(0x32));
1690
1691 info!(domain, session_id = %session_id, "weaver session started");
1692 Ok(session_id)
1693 }
1694
1695 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 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 pub fn get_session(&self, domain: &str) -> Option<ModelingSession> {
1719 self.sessions.read().ok()?.get(domain).cloned()
1720 }
1721
1722 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 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 drop(sessions); 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 self.emit_impulse(ImpulseType::Custom(0x33));
1760
1761 Ok(())
1762 }
1763
1764 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 let source_count = session.sources_ingested.len() as f64;
1775 let node_count = self.causal_graph.node_count() as f64;
1776
1777 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 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 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 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 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 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 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 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 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 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 pub fn import_model(&self, domain: &str, model: ExportedModel) -> Result<(), String> {
1944 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 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 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 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 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 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 let label = format!("meta-loom/{}", domain);
2081 self.causal_graph
2082 .add_node(label, serde_json::to_value(&event).unwrap_or_default());
2083
2084 if let Ok(mut events) = self.meta_loom_events.write() {
2086 events.entry(domain.to_string()).or_default().push(event);
2087 }
2088 }
2089
2090 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 fn emit_impulse(&self, impulse_type: ImpulseType) {
2101 if let Some(queue) = &self.impulse_queue {
2102 queue.emit(
2103 0x03, [0u8; 32],
2105 0x03, impulse_type,
2107 serde_json::Value::Null,
2108 0,
2109 );
2110 }
2111 }
2112
2113 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 if start.elapsed() < budget
2131 && let Some(new_commits) = self.poll_git()
2132 {
2133 result.git_commits_found = new_commits;
2134 }
2135
2136 if start.elapsed() < budget
2138 && let Some(changed_files) = self.poll_file_changes()
2139 {
2140 result.files_changed = changed_files;
2141 }
2142
2143 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 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 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 self.tick_history.record(result.clone());
2179
2180 result
2181 }
2182
2183 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 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 fn poll_git(&mut self) -> Option<usize> {
2197 self.git_poller.as_mut().map(|p| p.poll())
2198 }
2199
2200 fn poll_file_changes(&mut self) -> Option<usize> {
2202 self.file_watcher.as_mut().map(|w| w.poll_changes().len())
2203 }
2204
2205 pub fn cached_confidence(&self) -> Option<&ConfidenceReport> {
2207 self.last_confidence.as_ref()
2208 }
2209
2210 pub fn git_poller(&self) -> Option<&GitPoller> {
2212 self.git_poller.as_ref()
2213 }
2214
2215 pub fn file_watcher(&self) -> Option<&FileWatcher> {
2217 self.file_watcher.as_ref()
2218 }
2219
2220 pub fn total_ticks(&self) -> u64 {
2222 self.tick_count.load(Ordering::Relaxed)
2223 }
2224
2225 pub fn confidence_history(&self) -> &ConfidenceHistory {
2229 &self.confidence_history
2230 }
2231
2232 pub fn confidence_history_mut(&mut self) -> &mut ConfidenceHistory {
2234 &mut self.confidence_history
2235 }
2236
2237 pub fn strategy_tracker(&self) -> &StrategyTracker {
2241 &self.strategy_tracker
2242 }
2243
2244 pub fn strategy_tracker_mut(&mut self) -> &mut StrategyTracker {
2246 &mut self.strategy_tracker
2247 }
2248
2249 pub fn tick_history(&self) -> &TickHistory {
2253 &self.tick_history
2254 }
2255
2256 pub fn set_tick_interval_ms(&mut self, ms: u32) {
2258 self.current_tick_interval_ms = ms;
2259 }
2260
2261 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 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 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 recommendation_confidence: step.recommendation_confidence,
2307 }
2308 }
2309
2310 fn step_recommend_tick_interval(
2315 &self,
2316 idle: usize,
2317 cpm: f64,
2318 sample_count: usize,
2319 ) -> TickRecommendation {
2320 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 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 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 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 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 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 HealthStatus::Healthy
2408 }
2409}
2410
2411#[derive(Debug, Clone, Serialize, Deserialize)]
2417pub struct ModelDiff {
2418 pub model_a: String,
2420 pub model_b: String,
2422 pub confidence_delta: f64,
2424 pub nodes_only_a: Vec<String>,
2426 pub nodes_only_b: Vec<String>,
2428 pub nodes_common: Vec<String>,
2430 pub edges_only_a: Vec<String>,
2432 pub edges_only_b: Vec<String>,
2434 pub edges_common: Vec<String>,
2436 pub causal_nodes_added: usize,
2438 pub causal_nodes_removed: usize,
2440 pub causal_edges_added: usize,
2442 pub causal_edges_removed: usize,
2444 pub summary: String,
2446}
2447
2448pub fn diff_models(a: &ExportedModel, b: &ExportedModel) -> ModelDiff {
2450 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 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
2556pub struct MergeResult {
2557 pub merged: ExportedModel,
2559 pub conflicts: Vec<MergeConflict>,
2561 pub stats: MergeStats,
2563}
2564
2565#[derive(Debug, Clone, Serialize, Deserialize)]
2567pub struct MergeConflict {
2568 pub item: String,
2570 pub value_a: String,
2572 pub value_b: String,
2574 pub resolution: ConflictResolution,
2576}
2577
2578#[non_exhaustive]
2580#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2581pub enum ConflictResolution {
2582 KeepA,
2584 KeepB,
2586 Merged,
2588 HigherConfidence,
2590}
2591
2592#[derive(Debug, Clone, Serialize, Deserialize)]
2594pub struct MergeStats {
2595 pub total_node_types: usize,
2597 pub total_edge_types: usize,
2599 pub total_causal_nodes: usize,
2601 pub total_causal_edges: usize,
2603 pub conflicts_resolved: usize,
2605 pub nodes_from_a: usize,
2607 pub nodes_from_b: usize,
2609 pub nodes_shared: usize,
2611}
2612
2613pub fn merge_models(a: &ExportedModel, b: &ExportedModel) -> MergeResult {
2620 let mut conflicts = Vec::new();
2621
2622 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 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 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 merged_causal_nodes.push((*nb).clone());
2734 }
2735 (None, None) => unreachable!(),
2736 }
2737 }
2738
2739 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
2824pub struct SerializableKB {
2825 pub version: u32,
2827 pub patterns: Vec<StrategyPattern>,
2829 pub domains_modeled: Vec<String>,
2831 pub total_sessions: u64,
2833 pub last_updated: DateTime<Utc>,
2835}
2836
2837impl WeaverKnowledgeBase {
2838 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 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 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 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 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 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 pub fn pattern_count(&self) -> usize {
2930 self.strategies.read().map(|s| s.len()).unwrap_or(0)
2931 }
2932}
2933
2934#[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 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); 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(); 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 #[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 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 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 #[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 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 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 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 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 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 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 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 #[test]
3512 fn export_model_includes_causal_data() {
3513 let engine = make_engine();
3514 engine.start_session("exp-causal", None, None).unwrap();
3515 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 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 let exported = engine
3560 .export_model_to_file("roundtrip", 0.0, &path)
3561 .unwrap();
3562 assert!(path.exists(), "export file should exist");
3563
3564 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 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 #[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 #[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 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); 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); 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 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 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 #[test]
3764 fn git_poller_poll_detects_commits_in_real_repo() {
3765 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; }
3771 let mut poller = GitPoller::new(repo_path, "HEAD".to_string());
3772 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(); let count = poller.poll(); 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 #[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 let changed = watcher.poll_changes();
3831 assert!(changed.is_empty(), "no changes on first poll");
3832
3833 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 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 #[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 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 #[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 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 let trend = history.trend(100);
4088 assert_eq!(trend.samples, 1);
4089 }
4090
4091 #[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); let h2 = tracker.begin_strategy("b", "desc b", 0.5);
4112 tracker.complete_strategy(h2, 0.9); let h3 = tracker.begin_strategy("c", "desc c", 0.9);
4114 tracker.complete_strategy(h3, 0.85); let top = tracker.most_effective(2);
4117 assert_eq!(top.len(), 2);
4118 assert_eq!(top[0].strategy, "b"); 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 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 #[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 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 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 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 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 #[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 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 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 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 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 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 #[test]
4392 fn tick_recommend_untrained_model_reproduces_step_function() {
4393 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 match case {
4403 0 => { }
4404 1 => {
4405 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 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 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 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 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 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 assert!(with_model.reason.contains("EML tick-interval model"));
4501 assert_ne!(with_model.recommended_ms, baseline.recommended_ms);
4504 assert!((100..=60_000).contains(&with_model.recommended_ms));
4506 }
4507
4508 #[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 #[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 #[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 assert_eq!(engine.strategy_tracker().len(), 1);
4554 assert_eq!(
4555 engine.strategy_tracker().outcomes()[0].strategy,
4556 "ingest:small"
4557 );
4558
4559 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 #[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 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); assert_eq!(diff.causal_nodes_removed, 1); }
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 #[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 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 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 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 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 let result = merge_models(&a, &b);
4912 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 #[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 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 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 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 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}