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 {
74 domain: String,
75 input: PathBuf,
76 },
77 MetaStatus { domain: String },
79 MetaStrategies,
81 MetaExportKb { output: PathBuf },
83 Stitch {
85 source: String,
86 target: String,
87 output: String,
88 },
89}
90
91#[non_exhaustive]
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub enum WeaverResponse {
99 SessionStarted { domain: String, session_id: String },
101 SessionStopped { domain: String },
103 SessionResumed { domain: String },
105 ConfidenceReport(ConfidenceReport),
107 SourceAdded { domain: String, source_type: String },
109 Sources(Vec<String>),
111 Exported { path: PathBuf, edges: usize },
113 Imported { domain: String },
115 Strategies(Vec<StrategyPattern>),
117 KbExported { path: PathBuf },
119 Error(String),
121}
122
123#[non_exhaustive]
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub enum DataSource {
131 GitLog { path: PathBuf },
133 FileTree { root: PathBuf },
135 CiPipeline { url: String },
137 IssueTracker { url: String },
139 Documentation { root: PathBuf },
141 SparcPlan { root: PathBuf },
143 CustomStream { name: String },
145}
146
147impl DataSource {
148 pub fn type_name(&self) -> &str {
150 match self {
151 Self::GitLog { .. } => "git_log",
152 Self::FileTree { .. } => "file_tree",
153 Self::CiPipeline { .. } => "ci_pipeline",
154 Self::IssueTracker { .. } => "issue_tracker",
155 Self::Documentation { .. } => "documentation",
156 Self::SparcPlan { .. } => "sparc_plan",
157 Self::CustomStream { .. } => "custom_stream",
158 }
159 }
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct ModelingSession {
169 pub id: String,
171 pub domain: String,
173 pub started_at: DateTime<Utc>,
175 pub confidence: f64,
177 pub gaps: Vec<ConfidenceGap>,
179 pub sources_ingested: Vec<String>,
181 pub tick_count: u64,
183 pub budget_remaining_ms: u64,
185 pub active: bool,
187 pub metadata: HashMap<String, serde_json::Value>,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct ConfidenceGap {
198 pub domain: String,
200 pub current_confidence: f64,
202 pub target_confidence: f64,
204 pub suggested_sources: Vec<String>,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct ConfidenceReport {
211 pub overall: f64,
213 pub gaps: Vec<ConfidenceGap>,
215 pub suggestions: Vec<ModelingSuggestion>,
217}
218
219#[non_exhaustive]
221#[derive(Debug, Clone, Serialize, Deserialize)]
222pub enum ModelingSuggestion {
223 AddSource { source_type: String, reason: String },
225 RefineEdgeType { from: String, to: String },
227 SplitCategory { category: String },
229 ExtendObservation { domain: String },
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
239pub struct ExportedModel {
240 pub version: String,
242 pub domain: String,
244 pub exported_at: DateTime<Utc>,
246 pub confidence: f64,
248 pub node_types: Vec<NodeTypeSpec>,
250 pub edge_types: Vec<EdgeTypeSpec>,
252 pub causal_nodes: Vec<ExportedCausalNode>,
254 pub causal_edges: Vec<ExportedCausalEdge>,
256 pub metadata: HashMap<String, serde_json::Value>,
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize)]
262pub struct NodeTypeSpec {
263 pub name: String,
265 pub embedding_strategy: String,
267 pub dimensions: usize,
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct EdgeTypeSpec {
274 pub from_type: String,
276 pub to_type: String,
278 pub edge_type: String,
280 pub confidence: f64,
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct ExportedCausalNode {
287 pub label: String,
289 pub metadata: serde_json::Value,
291}
292
293#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct ExportedCausalEdge {
296 pub source_label: String,
298 pub target_label: String,
300 pub edge_type: String,
302 pub weight: f32,
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize)]
312pub struct MetaLoomEvent {
313 pub session_domain: String,
315 pub decision_type: MetaDecisionType,
317 pub confidence_before: f64,
319 pub confidence_after: Option<f64>,
321 pub rationale: String,
323 pub timestamp: DateTime<Utc>,
325}
326
327#[non_exhaustive]
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub enum MetaDecisionType {
331 SourceAdded { source_type: String },
333 EdgeTypeCreated {
335 from: String,
336 to: String,
337 edge_type: String,
338 },
339 EdgeTypeRemoved { from: String, to: String },
341 EmbeddingStrategyChanged {
343 node_type: String,
344 old: String,
345 new: String,
346 },
347 TickIntervalAdjusted { old_ms: u64, new_ms: u64 },
349 ModelVersionBumped { from: u32, to: u32 },
351 StrategyLearned { pattern: String },
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize)]
361pub struct StrategyPattern {
362 pub decision_type: String,
364 pub context: String,
366 pub improvement: f64,
368 pub timestamp: DateTime<Utc>,
370}
371
372pub struct WeaverKnowledgeBase {
374 strategies: RwLock<Vec<StrategyPattern>>,
376 strategy_count: AtomicU64,
378}
379
380impl Default for WeaverKnowledgeBase {
381 fn default() -> Self {
382 Self {
383 strategies: RwLock::new(Vec::new()),
384 strategy_count: AtomicU64::new(0),
385 }
386 }
387}
388
389impl WeaverKnowledgeBase {
390 pub fn new() -> Self {
392 Self::default()
393 }
394
395 pub fn record_strategy(&self, pattern: StrategyPattern) {
397 if let Ok(mut strategies) = self.strategies.write() {
398 strategies.push(pattern);
399 self.strategy_count.fetch_add(1, Ordering::Relaxed);
400 }
401 }
402
403 pub fn list_strategies(&self) -> Vec<StrategyPattern> {
405 self.strategies
406 .read()
407 .map(|s| s.clone())
408 .unwrap_or_default()
409 }
410
411 pub fn strategies_for(&self, domain: &str) -> Vec<StrategyPattern> {
413 self.strategies
414 .read()
415 .map(|all| {
416 all.iter()
417 .filter(|s| {
418 s.context.contains(domain)
419 || domain.contains(&s.context)
420 })
421 .cloned()
422 .collect()
423 })
424 .unwrap_or_default()
425 }
426
427 pub fn export(&self) -> serde_json::Value {
429 serde_json::to_value(self.list_strategies()).unwrap_or_default()
430 }
431
432 pub fn count(&self) -> u64 {
434 self.strategy_count.load(Ordering::Relaxed)
435 }
436}
437
438#[non_exhaustive]
444#[derive(Debug)]
445pub enum TickResult {
446 Idle,
448 BudgetExhausted,
450 Progress {
452 confidence: f64,
454 gaps_remaining: usize,
456 },
457}
458
459#[non_exhaustive]
465#[derive(Debug)]
466pub enum WeaverError {
467 Io(std::io::Error),
469 Json(serde_json::Error),
471 Domain(String),
473}
474
475impl fmt::Display for WeaverError {
476 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
477 match self {
478 Self::Io(e) => write!(f, "weaver I/O error: {e}"),
479 Self::Json(e) => write!(f, "weaver JSON error: {e}"),
480 Self::Domain(msg) => write!(f, "weaver error: {msg}"),
481 }
482 }
483}
484
485impl std::error::Error for WeaverError {}
486
487impl From<std::io::Error> for WeaverError {
488 fn from(e: std::io::Error) -> Self {
489 Self::Io(e)
490 }
491}
492
493impl From<serde_json::Error> for WeaverError {
494 fn from(e: serde_json::Error) -> Self {
495 Self::Json(e)
496 }
497}
498
499#[derive(Debug, Clone, Serialize, Deserialize)]
505pub struct IngestResult {
506 pub nodes_added: usize,
508 pub edges_added: usize,
510 pub embeddings_created: usize,
512 pub source: String,
514}
515
516pub struct GitPoller {
522 repo_path: PathBuf,
524 last_known_hash: Option<String>,
526 branch: String,
528 enabled: bool,
530}
531
532impl GitPoller {
533 pub fn new(repo_path: PathBuf, branch: String) -> Self {
535 Self {
536 repo_path,
537 last_known_hash: None,
538 branch,
539 enabled: true,
540 }
541 }
542
543 pub fn poll(&mut self) -> usize {
546 if !self.enabled {
547 return 0;
548 }
549
550 let repo_str = self.repo_path.to_str().unwrap_or(".");
551 let output = std::process::Command::new("git")
552 .args(["-C", repo_str, "rev-parse", "HEAD"])
553 .output();
554
555 match output {
556 Ok(out) if out.status.success() => {
557 let current_hash = String::from_utf8_lossy(&out.stdout).trim().to_string();
558 if self.last_known_hash.as_deref() == Some(¤t_hash) {
559 return 0;
560 }
561
562 let count = if let Some(ref last) = self.last_known_hash {
563 let count_output = std::process::Command::new("git")
564 .args([
565 "-C",
566 repo_str,
567 "rev-list",
568 "--count",
569 &format!("{}..{}", last, current_hash),
570 ])
571 .output();
572 match count_output {
573 Ok(o) if o.status.success() => {
574 String::from_utf8_lossy(&o.stdout)
575 .trim()
576 .parse()
577 .unwrap_or(1)
578 }
579 _ => 1,
580 }
581 } else {
582 1 };
584
585 self.last_known_hash = Some(current_hash);
586 count
587 }
588 _ => 0,
589 }
590 }
591
592 pub fn last_hash(&self) -> Option<&str> {
594 self.last_known_hash.as_deref()
595 }
596
597 pub fn branch(&self) -> &str {
599 &self.branch
600 }
601
602 pub fn is_enabled(&self) -> bool {
604 self.enabled
605 }
606
607 pub fn set_enabled(&mut self, enabled: bool) {
609 self.enabled = enabled;
610 }
611}
612
613pub struct FileWatcher {
622 watched: HashMap<PathBuf, SystemTime>,
624 root: PathBuf,
626 patterns: Vec<String>,
628 enabled: bool,
630}
631
632impl FileWatcher {
633 pub fn new(root: PathBuf, patterns: Vec<String>) -> Self {
635 Self {
636 watched: HashMap::new(),
637 root,
638 patterns,
639 enabled: true,
640 }
641 }
642
643 pub fn poll_changes(&mut self) -> Vec<PathBuf> {
646 if !self.enabled {
647 return vec![];
648 }
649
650 let mut changed = Vec::new();
651 let entries: Vec<PathBuf> = self.watched.keys().cloned().collect();
652
653 for path in entries {
654 if let Ok(metadata) = std::fs::metadata(&path) {
655 if let Ok(mtime) = metadata.modified() {
656 if let Some(last_mtime) = self.watched.get(&path) {
657 if mtime > *last_mtime {
658 changed.push(path.clone());
659 self.watched.insert(path, mtime);
660 }
661 }
662 }
663 } else {
664 changed.push(path.clone());
666 self.watched.remove(&path);
667 }
668 }
669
670 changed
671 }
672
673 pub fn watch(&mut self, path: PathBuf) {
675 if let Ok(metadata) = std::fs::metadata(&path) {
676 if let Ok(mtime) = metadata.modified() {
677 self.watched.insert(path, mtime);
678 }
679 }
680 }
681
682 pub fn watch_directory(&mut self) {
684 if let Ok(entries) = std::fs::read_dir(&self.root) {
685 for entry in entries.flatten() {
686 let path = entry.path();
687 if path.is_file() {
688 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
689 let matches = self.patterns.iter().any(|p| {
690 if p.starts_with("*.") {
691 name.ends_with(&p[1..])
692 } else {
693 name == p
694 }
695 });
696 if matches {
697 self.watch(path);
698 }
699 }
700 }
701 }
702 }
703
704 pub fn watched_count(&self) -> usize {
706 self.watched.len()
707 }
708
709 pub fn is_enabled(&self) -> bool {
711 self.enabled
712 }
713
714 pub fn set_enabled(&mut self, enabled: bool) {
716 self.enabled = enabled;
717 }
718}
719
720#[derive(Debug, Clone, Default, Serialize, Deserialize)]
729pub struct CognitiveTickResult {
730 pub tick_number: u64,
732 pub elapsed_ms: u32,
734 pub budget_ms: u32,
736 pub git_commits_found: usize,
738 pub files_changed: usize,
740 pub nodes_processed: usize,
742 pub confidence_updated: bool,
744 pub within_budget: bool,
746}
747
748#[non_exhaustive]
754#[derive(Debug, Clone, Serialize, Deserialize)]
755pub enum ConfidenceTrigger {
756 Periodic,
758 PostIngestion,
760 Manual,
762 StrategyChange,
764}
765
766#[derive(Debug, Clone, Serialize, Deserialize)]
768pub struct ConfidenceSnapshot {
769 pub timestamp: DateTime<Utc>,
771 pub tick_number: u64,
773 pub confidence: f64,
775 pub node_count: usize,
777 pub edge_count: usize,
779 pub gap_count: usize,
781 pub trigger: ConfidenceTrigger,
783}
784
785#[non_exhaustive]
787#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
788pub enum TrendDirection {
789 Improving,
791 Stable,
793 Declining,
795}
796
797#[derive(Debug, Clone, Serialize, Deserialize)]
799pub struct ConfidenceTrend {
800 pub direction: TrendDirection,
802 pub delta: f64,
804 pub avg_confidence: f64,
806 pub samples: usize,
808}
809
810pub struct ConfidenceHistory {
812 snapshots: VecDeque<ConfidenceSnapshot>,
813 max_entries: usize,
814}
815
816impl ConfidenceHistory {
817 pub fn new(max_entries: usize) -> Self {
819 Self {
820 snapshots: VecDeque::with_capacity(max_entries),
821 max_entries,
822 }
823 }
824
825 pub fn record(&mut self, snapshot: ConfidenceSnapshot) {
827 if self.snapshots.len() >= self.max_entries {
828 self.snapshots.pop_front();
829 }
830 self.snapshots.push_back(snapshot);
831 }
832
833 pub fn latest(&self) -> Option<&ConfidenceSnapshot> {
835 self.snapshots.back()
836 }
837
838 pub fn trend(&self, last_n: usize) -> ConfidenceTrend {
840 let n = last_n.min(self.snapshots.len());
841 if n == 0 {
842 return ConfidenceTrend {
843 direction: TrendDirection::Stable,
844 delta: 0.0,
845 avg_confidence: 0.0,
846 samples: 0,
847 };
848 }
849
850 let start = self.snapshots.len() - n;
851 let window: Vec<&ConfidenceSnapshot> =
852 self.snapshots.iter().skip(start).collect();
853
854 let sum: f64 = window.iter().map(|s| s.confidence).sum();
855 let avg = sum / n as f64;
856 let first = window.first().map(|s| s.confidence).unwrap_or(0.0);
857 let last = window.last().map(|s| s.confidence).unwrap_or(0.0);
858 let delta = last - first;
859
860 let direction = if delta > 0.01 {
861 TrendDirection::Improving
862 } else if delta < -0.01 {
863 TrendDirection::Declining
864 } else {
865 TrendDirection::Stable
866 };
867
868 ConfidenceTrend {
869 direction,
870 delta,
871 avg_confidence: avg,
872 samples: n,
873 }
874 }
875
876 pub fn all(&self) -> &VecDeque<ConfidenceSnapshot> {
878 &self.snapshots
879 }
880
881 pub fn len(&self) -> usize {
883 self.snapshots.len()
884 }
885
886 pub fn is_empty(&self) -> bool {
888 self.snapshots.is_empty()
889 }
890}
891
892#[derive(Debug)]
898pub struct StrategyHandle {
899 pub name: String,
901 pub description: String,
903 pub confidence_before: f64,
905 pub started_at: DateTime<Utc>,
907}
908
909#[derive(Debug, Clone, Serialize, Deserialize)]
911pub struct StrategyOutcome {
912 pub strategy: String,
914 pub description: String,
916 pub confidence_before: f64,
918 pub confidence_after: f64,
920 pub delta: f64,
922 pub timestamp: DateTime<Utc>,
924 pub beneficial: bool,
926}
927
928pub struct StrategyTracker {
930 outcomes: Vec<StrategyOutcome>,
931 max_outcomes: usize,
932}
933
934impl StrategyTracker {
935 pub fn new(max_outcomes: usize) -> Self {
937 Self {
938 outcomes: Vec::new(),
939 max_outcomes,
940 }
941 }
942
943 pub fn begin_strategy(
945 &self,
946 name: &str,
947 description: &str,
948 current_confidence: f64,
949 ) -> StrategyHandle {
950 StrategyHandle {
951 name: name.to_string(),
952 description: description.to_string(),
953 confidence_before: current_confidence,
954 started_at: Utc::now(),
955 }
956 }
957
958 pub fn complete_strategy(
960 &mut self,
961 handle: StrategyHandle,
962 new_confidence: f64,
963 ) {
964 let delta = new_confidence - handle.confidence_before;
965 let outcome = StrategyOutcome {
966 strategy: handle.name,
967 description: handle.description,
968 confidence_before: handle.confidence_before,
969 confidence_after: new_confidence,
970 delta,
971 timestamp: handle.started_at,
972 beneficial: delta > 0.01,
973 };
974
975 if self.outcomes.len() >= self.max_outcomes {
976 self.outcomes.remove(0);
977 }
978 self.outcomes.push(outcome);
979 }
980
981 pub fn most_effective(&self, top_n: usize) -> Vec<&StrategyOutcome> {
983 let mut sorted: Vec<&StrategyOutcome> = self.outcomes.iter().collect();
984 sorted.sort_by(|a, b| {
985 b.delta
986 .partial_cmp(&a.delta)
987 .unwrap_or(std::cmp::Ordering::Equal)
988 });
989 sorted.truncate(top_n);
990 sorted
991 }
992
993 pub fn harmful_strategies(&self) -> Vec<&StrategyOutcome> {
995 self.outcomes
996 .iter()
997 .filter(|o| o.delta < -0.01)
998 .collect()
999 }
1000
1001 pub fn recommend(&self) -> Option<String> {
1006 self.outcomes
1007 .iter()
1008 .filter(|o| o.beneficial)
1009 .max_by(|a, b| {
1010 a.delta
1011 .partial_cmp(&b.delta)
1012 .unwrap_or(std::cmp::Ordering::Equal)
1013 })
1014 .map(|o| o.strategy.clone())
1015 }
1016
1017 pub fn outcomes(&self) -> &[StrategyOutcome] {
1019 &self.outcomes
1020 }
1021
1022 pub fn len(&self) -> usize {
1024 self.outcomes.len()
1025 }
1026
1027 pub fn is_empty(&self) -> bool {
1029 self.outcomes.is_empty()
1030 }
1031}
1032
1033#[derive(Debug, Clone, Serialize, Deserialize)]
1039pub struct TickRecommendation {
1040 pub recommended_ms: u32,
1042 pub current_ms: u32,
1044 pub reason: String,
1046 pub changes_per_minute: f64,
1048 pub recommendation_confidence: f64,
1050}
1051
1052pub struct TickHistory {
1054 results: VecDeque<CognitiveTickResult>,
1055 max_entries: usize,
1056}
1057
1058impl TickHistory {
1059 pub fn new(max_entries: usize) -> Self {
1061 Self {
1062 results: VecDeque::with_capacity(max_entries),
1063 max_entries,
1064 }
1065 }
1066
1067 pub fn record(&mut self, result: CognitiveTickResult) {
1069 if self.results.len() >= self.max_entries {
1070 self.results.pop_front();
1071 }
1072 self.results.push_back(result);
1073 }
1074
1075 pub fn changes_per_minute(&self) -> f64 {
1079 if self.results.len() < 2 {
1080 return 0.0;
1081 }
1082
1083 let total_changes: usize = self
1084 .results
1085 .iter()
1086 .map(|r| r.git_commits_found + r.files_changed)
1087 .sum();
1088
1089 let total_elapsed_ms: u64 =
1090 self.results.iter().map(|r| r.elapsed_ms as u64).sum();
1091 if total_elapsed_ms == 0 {
1092 return 0.0;
1093 }
1094
1095 let minutes = total_elapsed_ms as f64 / 60_000.0;
1096 if minutes < 0.001 {
1097 return 0.0;
1098 }
1099
1100 total_changes as f64 / minutes
1101 }
1102
1103 pub fn avg_budget_usage(&self) -> f64 {
1105 if self.results.is_empty() {
1106 return 0.0;
1107 }
1108
1109 let sum: f64 = self
1110 .results
1111 .iter()
1112 .filter(|r| r.budget_ms > 0)
1113 .map(|r| r.elapsed_ms as f64 / r.budget_ms as f64)
1114 .sum();
1115
1116 let count = self
1117 .results
1118 .iter()
1119 .filter(|r| r.budget_ms > 0)
1120 .count();
1121
1122 if count == 0 {
1123 return 0.0;
1124 }
1125
1126 sum / count as f64
1127 }
1128
1129 pub fn idle_ticks(&self) -> usize {
1131 self.results
1132 .iter()
1133 .rev()
1134 .take_while(|r| {
1135 r.git_commits_found == 0
1136 && r.files_changed == 0
1137 && r.nodes_processed == 0
1138 })
1139 .count()
1140 }
1141
1142 pub fn len(&self) -> usize {
1144 self.results.len()
1145 }
1146
1147 pub fn is_empty(&self) -> bool {
1149 self.results.is_empty()
1150 }
1151
1152 pub fn all(&self) -> &VecDeque<CognitiveTickResult> {
1154 &self.results
1155 }
1156}
1157
1158pub struct WeaverEngine {
1167 sessions: RwLock<HashMap<String, ModelingSession>>,
1169 knowledge_base: Arc<WeaverKnowledgeBase>,
1171 embedding_provider: Arc<dyn EmbeddingProvider>,
1173 causal_graph: Arc<CausalGraph>,
1175 #[allow(dead_code)]
1177 hnsw: Arc<HnswService>,
1178 impulse_queue: Option<Arc<ImpulseQueue>>,
1180 meta_loom_events: RwLock<HashMap<String, Vec<MetaLoomEvent>>>,
1182 tick_count: AtomicU64,
1184 git_poller: Option<GitPoller>,
1186 file_watcher: Option<FileWatcher>,
1188 ticks_since_confidence_update: u64,
1190 last_confidence: Option<ConfidenceReport>,
1192 confidence_history: ConfidenceHistory,
1194 strategy_tracker: StrategyTracker,
1196 tick_history: TickHistory,
1198 current_tick_interval_ms: u32,
1200}
1201
1202impl WeaverEngine {
1203 pub fn new(
1205 causal_graph: Arc<CausalGraph>,
1206 hnsw: Arc<HnswService>,
1207 embedding_provider: Arc<dyn EmbeddingProvider>,
1208 ) -> Self {
1209 Self {
1210 sessions: RwLock::new(HashMap::new()),
1211 knowledge_base: Arc::new(WeaverKnowledgeBase::new()),
1212 embedding_provider,
1213 causal_graph,
1214 hnsw,
1215 impulse_queue: None,
1216 meta_loom_events: RwLock::new(HashMap::new()),
1217 tick_count: AtomicU64::new(0),
1218 git_poller: None,
1219 file_watcher: None,
1220 ticks_since_confidence_update: 0,
1221 last_confidence: None,
1222 confidence_history: ConfidenceHistory::new(500),
1223 strategy_tracker: StrategyTracker::new(200),
1224 tick_history: TickHistory::new(500),
1225 current_tick_interval_ms: 1000,
1226 }
1227 }
1228
1229 pub fn new_with_mock(
1231 causal_graph: Arc<CausalGraph>,
1232 hnsw: Arc<HnswService>,
1233 ) -> Self {
1234 Self::new(
1235 causal_graph,
1236 hnsw,
1237 Arc::new(MockEmbeddingProvider::new(64)),
1238 )
1239 }
1240
1241 pub fn set_impulse_queue(&mut self, queue: Arc<ImpulseQueue>) {
1243 self.impulse_queue = Some(queue);
1244 }
1245
1246 pub fn knowledge_base(&self) -> &Arc<WeaverKnowledgeBase> {
1248 &self.knowledge_base
1249 }
1250
1251 pub fn embedding_provider(&self) -> &Arc<dyn EmbeddingProvider> {
1253 &self.embedding_provider
1254 }
1255
1256 pub fn causal_graph(&self) -> &Arc<CausalGraph> {
1258 &self.causal_graph
1259 }
1260
1261 pub fn hnsw(&self) -> &Arc<HnswService> {
1263 &self.hnsw
1264 }
1265
1266 pub fn ingest_graph_file(&self, path: &Path) -> Result<IngestResult, WeaverError> {
1274 let data = std::fs::read_to_string(path)?;
1275 let graph: serde_json::Value = serde_json::from_str(&data)?;
1276
1277 let source = graph["source"]
1278 .as_str()
1279 .unwrap_or("unknown")
1280 .to_string();
1281
1282 let empty_vec = vec![];
1283 let nodes = graph["nodes"].as_array().unwrap_or(&empty_vec);
1284 let edges = graph["edges"].as_array().unwrap_or(&empty_vec);
1285
1286 let mut nodes_added = 0usize;
1287 let mut edges_added = 0usize;
1288 let mut embeddings_created = 0usize;
1289
1290 let mut id_map: HashMap<String, u64> = HashMap::with_capacity(nodes.len());
1292
1293 for node in nodes {
1295 let node_id_str = node["id"].as_str().unwrap_or("").to_string();
1296 if node_id_str.is_empty() {
1297 continue;
1298 }
1299
1300 let label = if let Some(title) = node["title"].as_str() {
1301 format!("{source}/{node_id_str}: {title}")
1302 } else if let Some(subject) = node["subject"].as_str() {
1303 format!("{source}/{node_id_str}: {subject}")
1304 } else {
1305 format!("{source}/{node_id_str}")
1306 };
1307
1308 let causal_id = self.causal_graph.add_node(
1309 label.clone(),
1310 node.clone(),
1311 );
1312 id_map.insert(node_id_str.clone(), causal_id);
1313 nodes_added += 1;
1314
1315 let embed_text = Self::node_to_embed_text(node, &source);
1317 if !embed_text.is_empty() {
1318 let embed_vec = self.sync_embed(&embed_text);
1320 self.hnsw.insert(
1321 format!("{source}/{}", node_id_str),
1322 embed_vec,
1323 node.clone(),
1324 );
1325 embeddings_created += 1;
1326 }
1327 }
1328
1329 for edge in edges {
1331 let from_str = edge["from"].as_str().unwrap_or("");
1332 let to_str = edge["to"].as_str().unwrap_or("");
1333 let edge_type_str = edge["type"].as_str().unwrap_or("Correlates");
1334 let weight = edge["weight"].as_f64().unwrap_or(1.0) as f32;
1335
1336 let from_id = id_map.get(from_str).copied();
1337 let to_id = id_map.get(to_str).copied();
1338
1339 if let (Some(src), Some(tgt)) = (from_id, to_id) {
1340 let edge_type = Self::parse_edge_type(edge_type_str);
1341 let linked = self.causal_graph.link(
1342 src, tgt, edge_type, weight, 0, 0,
1343 );
1344 if linked {
1345 edges_added += 1;
1346 }
1347 }
1348 }
1349
1350 info!(
1351 source = %source,
1352 nodes_added,
1353 edges_added,
1354 embeddings_created,
1355 "graph file ingested"
1356 );
1357
1358 Ok(IngestResult {
1359 nodes_added,
1360 edges_added,
1361 embeddings_created,
1362 source,
1363 })
1364 }
1365
1366 pub fn ingest_graph_file_tracked(
1372 &mut self,
1373 path: &Path,
1374 ) -> Result<IngestResult, WeaverError> {
1375 let confidence_before = self.compute_confidence().overall;
1376 let source_name = path
1377 .file_stem()
1378 .and_then(|s| s.to_str())
1379 .unwrap_or("unknown");
1380 let handle = self.strategy_tracker.begin_strategy(
1381 &format!("ingest:{source_name}"),
1382 &format!("Ingesting graph file {}", path.display()),
1383 confidence_before,
1384 );
1385
1386 let result = self.ingest_graph_file(path)?;
1387
1388 let report = self.compute_confidence();
1389 let confidence_after = report.overall;
1390
1391 self.strategy_tracker
1393 .complete_strategy(handle, confidence_after);
1394
1395 let snapshot = ConfidenceSnapshot {
1397 timestamp: Utc::now(),
1398 tick_number: self.tick_count.load(Ordering::Relaxed),
1399 confidence: confidence_after,
1400 node_count: self.causal_graph.node_count() as usize,
1401 edge_count: self.causal_graph.edge_count() as usize,
1402 gap_count: report.gaps.len(),
1403 trigger: ConfidenceTrigger::PostIngestion,
1404 };
1405 self.confidence_history.record(snapshot);
1406
1407 if (confidence_after - confidence_before).abs() > 0.001 {
1409 let change_snapshot = ConfidenceSnapshot {
1410 timestamp: Utc::now(),
1411 tick_number: self.tick_count.load(Ordering::Relaxed),
1412 confidence: confidence_after,
1413 node_count: self.causal_graph.node_count() as usize,
1414 edge_count: self.causal_graph.edge_count() as usize,
1415 gap_count: report.gaps.len(),
1416 trigger: ConfidenceTrigger::StrategyChange,
1417 };
1418 self.confidence_history.record(change_snapshot);
1419 }
1420
1421 Ok(result)
1422 }
1423
1424 fn node_to_embed_text(node: &serde_json::Value, source: &str) -> String {
1426 match source {
1427 "git-history" => {
1428 let subject = node["subject"].as_str().unwrap_or("");
1429 let author = node["author"].as_str().unwrap_or("");
1430 let files = node["files"]
1431 .as_array()
1432 .map(|arr| {
1433 arr.iter()
1434 .filter_map(|v| v.as_str())
1435 .collect::<Vec<_>>()
1436 .join(", ")
1437 })
1438 .unwrap_or_default();
1439 format!("commit by {author}: {subject} files: {files}")
1440 }
1441 "module-dependencies" => {
1442 let id = node["id"].as_str().unwrap_or("");
1443 let deps = node["dependencies"]
1444 .as_array()
1445 .map(|arr| {
1446 arr.iter()
1447 .filter_map(|v| v.as_str())
1448 .collect::<Vec<_>>()
1449 .join(", ")
1450 })
1451 .unwrap_or_default();
1452 let lines = node["lines"].as_u64().unwrap_or(0);
1453 format!("module {id} ({lines} lines) depends on: {deps}")
1454 }
1455 "decisions-and-phases" => {
1456 let title = node["title"].as_str().unwrap_or("");
1457 let rationale = node["rationale"].as_str().unwrap_or("");
1458 let panel = node["panel"].as_str().unwrap_or("");
1459 format!("decision ({panel}): {title} — {rationale}")
1460 }
1461 _ => {
1462 serde_json::to_string(node).unwrap_or_default()
1464 }
1465 }
1466 }
1467
1468 fn parse_edge_type(s: &str) -> CausalEdgeType {
1470 match s {
1471 "Causes" => CausalEdgeType::Causes,
1472 "Inhibits" => CausalEdgeType::Inhibits,
1473 "Correlates" => CausalEdgeType::Correlates,
1474 "Enables" => CausalEdgeType::Enables,
1475 "Follows" => CausalEdgeType::Follows,
1476 "Contradicts" => CausalEdgeType::Contradicts,
1477 "TriggeredBy" => CausalEdgeType::TriggeredBy,
1478 "EvidenceFor" => CausalEdgeType::EvidenceFor,
1479 _ => CausalEdgeType::Correlates,
1480 }
1481 }
1482
1483 fn sync_embed(&self, text: &str) -> Vec<f32> {
1488 use sha2::{Digest, Sha256};
1489 let dims = self.embedding_provider.dimensions();
1490 let mut hasher = Sha256::new();
1491 hasher.update(text.as_bytes());
1492 let hash = hasher.finalize();
1493 let mut vec = Vec::with_capacity(dims);
1494 for i in 0..dims {
1495 let byte = hash[i % 32];
1496 vec.push((byte as f32 / 128.0) - 1.0);
1497 }
1498 vec
1499 }
1500
1501 pub fn compute_confidence(&self) -> ConfidenceReport {
1509 let node_count = self.causal_graph.node_count() as usize;
1510 let edge_count = self.causal_graph.edge_count() as usize;
1511
1512 if node_count == 0 {
1513 return ConfidenceReport {
1514 overall: 0.0,
1515 gaps: vec![ConfidenceGap {
1516 domain: "graph".to_string(),
1517 current_confidence: 0.0,
1518 target_confidence: 0.8,
1519 suggested_sources: vec![
1520 "git_log".into(),
1521 "module_deps".into(),
1522 "decisions".into(),
1523 ],
1524 }],
1525 suggestions: vec![ModelingSuggestion::AddSource {
1526 source_type: "git_log".to_string(),
1527 reason: "No graph data ingested yet".to_string(),
1528 }],
1529 };
1530 }
1531
1532 let max_edges = if node_count > 1 {
1534 node_count * (node_count - 1)
1535 } else {
1536 1
1537 };
1538 let edge_density = (edge_count as f64 / max_edges as f64).min(1.0);
1539
1540 let mut connected_nodes = 0usize;
1544 let mut orphan_labels: Vec<String> = Vec::new();
1545 let next_id = self.causal_graph.node_count() + 1;
1546 for nid in 1..next_id {
1549 if self.causal_graph.get_node(nid).is_some() {
1550 let fwd = self.causal_graph.get_forward_edges(nid);
1551 let rev = self.causal_graph.get_reverse_edges(nid);
1552 if !fwd.is_empty() || !rev.is_empty() {
1553 connected_nodes += 1;
1554 } else if let Some(node) = self.causal_graph.get_node(nid) {
1555 orphan_labels.push(node.label.clone());
1556 }
1557 }
1558 }
1559
1560 let connectivity = if node_count > 0 {
1561 connected_nodes as f64 / node_count as f64
1562 } else {
1563 0.0
1564 };
1565
1566 let volume_score = (node_count as f64 / 100.0).min(1.0);
1572 let density_capped = (edge_density * 50.0).min(1.0); let overall = (connectivity * 0.40
1575 + density_capped * 0.20
1576 + volume_score * 0.20
1577 + self.source_diversity_score() * 0.20)
1578 .min(1.0);
1579
1580 let mut gaps = Vec::new();
1582 if connectivity < 0.7 {
1583 gaps.push(ConfidenceGap {
1584 domain: "node_connectivity".to_string(),
1585 current_confidence: connectivity,
1586 target_confidence: 0.7,
1587 suggested_sources: vec!["module_deps".into(), "git_log".into()],
1588 });
1589 }
1590 if volume_score < 0.5 {
1591 gaps.push(ConfidenceGap {
1592 domain: "data_volume".to_string(),
1593 current_confidence: volume_score,
1594 target_confidence: 0.5,
1595 suggested_sources: vec!["git_log".into(), "file_tree".into()],
1596 });
1597 }
1598
1599 let mut suggestions = Vec::new();
1601 if !orphan_labels.is_empty() {
1602 let sample: Vec<_> = orphan_labels.iter().take(5).cloned().collect();
1603 suggestions.push(ModelingSuggestion::AddSource {
1604 source_type: "causal_edges".to_string(),
1605 reason: format!(
1606 "{} orphan nodes without edges (e.g., {})",
1607 orphan_labels.len(),
1608 sample.join(", ")
1609 ),
1610 });
1611 }
1612
1613 ConfidenceReport {
1614 overall,
1615 gaps,
1616 suggestions,
1617 }
1618 }
1619
1620 fn source_diversity_score(&self) -> f64 {
1622 let sessions = match self.sessions.read() {
1623 Ok(s) => s,
1624 Err(_) => return 0.0,
1625 };
1626 let total_sources: usize = sessions.values().map(|s| s.sources_ingested.len()).sum();
1627 (total_sources as f64 / 3.0).min(1.0)
1629 }
1630
1631 pub fn export_model_to_file(
1638 &self,
1639 domain: &str,
1640 min_confidence: f64,
1641 path: &Path,
1642 ) -> Result<ExportedModel, WeaverError> {
1643 let model = self.export_model(domain, min_confidence)
1644 .map_err(WeaverError::Domain)?;
1645 let json = serde_json::to_string_pretty(&model)?;
1646 std::fs::write(path, json)?;
1647 info!(domain, ?path, "model exported to file");
1648 Ok(model)
1649 }
1650
1651 pub fn import_model_from_file(
1653 &self,
1654 domain: &str,
1655 path: &Path,
1656 ) -> Result<(), WeaverError> {
1657 let data = std::fs::read_to_string(path)?;
1658 let model: ExportedModel = serde_json::from_str(&data)?;
1659 self.import_model(domain, model)
1660 .map_err(WeaverError::Domain)?;
1661 info!(domain, ?path, "model imported from file");
1662 Ok(())
1663 }
1664
1665 pub fn start_session(
1669 &self,
1670 domain: &str,
1671 context: Option<&str>,
1672 _goal: Option<&str>,
1673 ) -> Result<String, String> {
1674 let session_id = uuid::Uuid::new_v4().to_string();
1675 let session = ModelingSession {
1676 id: session_id.clone(),
1677 domain: domain.to_string(),
1678 started_at: Utc::now(),
1679 confidence: 0.0,
1680 gaps: Vec::new(),
1681 sources_ingested: Vec::new(),
1682 tick_count: 0,
1683 budget_remaining_ms: 300_000, active: true,
1685 metadata: {
1686 let cap = if context.is_some() { 1 } else { 0 };
1687 let mut m = HashMap::with_capacity(cap);
1688 if let Some(ctx) = context {
1689 m.insert("context".to_string(), serde_json::Value::String(ctx.to_string()));
1690 }
1691 m
1692 },
1693 };
1694
1695 let mut sessions = self.sessions.write().map_err(|e| e.to_string())?;
1696 if sessions.contains_key(domain) {
1697 return Err(format!("session already exists for domain: {domain}"));
1698 }
1699 sessions.insert(domain.to_string(), session);
1700
1701 self.record_meta_loom(
1703 domain,
1704 MetaDecisionType::ModelVersionBumped { from: 0, to: 1 },
1705 "Session initialized",
1706 0.0,
1707 );
1708
1709 self.emit_impulse(ImpulseType::Custom(0x32));
1711
1712 info!(domain, session_id = %session_id, "weaver session started");
1713 Ok(session_id)
1714 }
1715
1716 pub fn stop_session(&self, domain: &str) -> Result<(), String> {
1718 let mut sessions = self.sessions.write().map_err(|e| e.to_string())?;
1719 let session = sessions
1720 .get_mut(domain)
1721 .ok_or_else(|| format!("no session for domain: {domain}"))?;
1722 session.active = false;
1723 info!(domain, "weaver session stopped");
1724 Ok(())
1725 }
1726
1727 pub fn resume_session(&self, domain: &str) -> Result<(), String> {
1729 let mut sessions = self.sessions.write().map_err(|e| e.to_string())?;
1730 let session = sessions
1731 .get_mut(domain)
1732 .ok_or_else(|| format!("no session for domain: {domain}"))?;
1733 session.active = true;
1734 info!(domain, "weaver session resumed");
1735 Ok(())
1736 }
1737
1738 pub fn get_session(&self, domain: &str) -> Option<ModelingSession> {
1740 self.sessions
1741 .read()
1742 .ok()?
1743 .get(domain)
1744 .cloned()
1745 }
1746
1747 pub fn list_sessions(&self) -> Vec<String> {
1749 self.sessions
1750 .read()
1751 .map(|s| s.keys().cloned().collect())
1752 .unwrap_or_default()
1753 }
1754
1755 pub fn add_source(
1759 &self,
1760 domain: &str,
1761 source_type: &str,
1762 _root: Option<&PathBuf>,
1763 ) -> Result<(), String> {
1764 let mut sessions = self.sessions.write().map_err(|e| e.to_string())?;
1765 let session = sessions
1766 .get_mut(domain)
1767 .ok_or_else(|| format!("no session for domain: {domain}"))?;
1768 session.sources_ingested.push(source_type.to_string());
1769
1770 drop(sessions); self.record_meta_loom(
1773 domain,
1774 MetaDecisionType::SourceAdded {
1775 source_type: source_type.to_string(),
1776 },
1777 &format!("Added source: {source_type}"),
1778 self.get_session(domain).map(|s| s.confidence).unwrap_or(0.0),
1779 );
1780
1781 self.emit_impulse(ImpulseType::Custom(0x33));
1783
1784 Ok(())
1785 }
1786
1787 pub fn evaluate_confidence(&self, domain: &str) -> Result<ConfidenceReport, String> {
1791 let sessions = self.sessions.read().map_err(|e| e.to_string())?;
1792 let session = sessions
1793 .get(domain)
1794 .ok_or_else(|| format!("no session for domain: {domain}"))?;
1795
1796 let source_count = session.sources_ingested.len() as f64;
1798 let node_count = self.causal_graph.node_count() as f64;
1799
1800 let base_confidence = (source_count * 0.15 + node_count * 0.01).min(1.0);
1802
1803 let mut gaps = Vec::new();
1804 if source_count < 3.0 {
1805 gaps.push(ConfidenceGap {
1806 domain: domain.to_string(),
1807 current_confidence: base_confidence,
1808 target_confidence: 0.8,
1809 suggested_sources: vec!["git_log".into(), "file_tree".into()],
1810 });
1811 }
1812
1813 let suggestions = if source_count < 2.0 {
1814 vec![ModelingSuggestion::AddSource {
1815 source_type: "git_log".into(),
1816 reason: "No git history ingested yet".into(),
1817 }]
1818 } else {
1819 Vec::new()
1820 };
1821
1822 Ok(ConfidenceReport {
1823 overall: base_confidence,
1824 gaps,
1825 suggestions,
1826 })
1827 }
1828
1829 pub fn tick(&self, budget: Duration) -> TickResult {
1836 let budget_start = Instant::now();
1837
1838 let mut sessions = match self.sessions.write() {
1839 Ok(s) => s,
1840 Err(_) => return TickResult::Idle,
1841 };
1842
1843 let active_domain = sessions
1845 .iter()
1846 .find(|(_, s)| s.active)
1847 .map(|(d, _)| d.clone());
1848
1849 let domain = match active_domain {
1850 Some(d) => d,
1851 None => return TickResult::Idle,
1852 };
1853
1854 let session = match sessions.get_mut(&domain) {
1855 Some(s) => s,
1856 None => return TickResult::Idle,
1857 };
1858
1859 let source_count = session.sources_ingested.len() as f64;
1861 let node_count = self.causal_graph.node_count() as f64;
1862 let confidence = (source_count * 0.15 + node_count * 0.01).min(1.0);
1863 session.confidence = confidence;
1864
1865 let mut gaps = Vec::new();
1867 if confidence < 0.8 {
1868 gaps.push(ConfidenceGap {
1869 domain: domain.clone(),
1870 current_confidence: confidence,
1871 target_confidence: 0.8,
1872 suggested_sources: vec!["git_log".into(), "file_tree".into()],
1873 });
1874 }
1875 session.gaps = gaps.clone();
1876
1877 if budget_start.elapsed() > budget {
1878 return TickResult::BudgetExhausted;
1879 }
1880
1881 let tick_label = format!("weaver.tick.{}.{}", domain, session.tick_count);
1883 self.causal_graph.add_node(
1884 tick_label,
1885 serde_json::json!({
1886 "domain": domain,
1887 "confidence": confidence,
1888 "tick": session.tick_count,
1889 }),
1890 );
1891
1892 session.tick_count += 1;
1893 self.tick_count.fetch_add(1, Ordering::Relaxed);
1894
1895 TickResult::Progress {
1896 confidence,
1897 gaps_remaining: gaps.len(),
1898 }
1899 }
1900
1901 pub fn export_model(
1905 &self,
1906 domain: &str,
1907 min_confidence: f64,
1908 ) -> Result<ExportedModel, String> {
1909 let sessions = self.sessions.read().map_err(|e| e.to_string())?;
1910 let session = sessions
1911 .get(domain)
1912 .ok_or_else(|| format!("no session for domain: {domain}"))?;
1913
1914 let edge_types: Vec<EdgeTypeSpec> = session
1916 .sources_ingested
1917 .iter()
1918 .enumerate()
1919 .map(|(i, src)| EdgeTypeSpec {
1920 from_type: "source".into(),
1921 to_type: "domain".into(),
1922 edge_type: format!("ingested_{src}"),
1923 confidence: (i as f64 + 1.0) * 0.2,
1924 })
1925 .filter(|e| e.confidence >= min_confidence)
1926 .collect();
1927
1928 let mut causal_nodes = Vec::new();
1930 let mut causal_edges_out = Vec::new();
1931 let next_id = self.causal_graph.node_count() + 1;
1932 for nid in 1..next_id {
1933 if let Some(node) = self.causal_graph.get_node(nid) {
1934 causal_nodes.push(ExportedCausalNode {
1935 label: node.label.clone(),
1936 metadata: node.metadata.clone(),
1937 });
1938 for edge in self.causal_graph.get_forward_edges(nid) {
1940 if let Some(target_node) = self.causal_graph.get_node(edge.target) {
1941 causal_edges_out.push(ExportedCausalEdge {
1942 source_label: node.label.clone(),
1943 target_label: target_node.label.clone(),
1944 edge_type: format!("{}", edge.edge_type),
1945 weight: edge.weight,
1946 });
1947 }
1948 }
1949 }
1950 }
1951
1952 Ok(ExportedModel {
1953 version: "1.0".to_string(),
1954 domain: domain.to_string(),
1955 exported_at: Utc::now(),
1956 confidence: session.confidence,
1957 node_types: vec![NodeTypeSpec {
1958 name: "default".into(),
1959 embedding_strategy: self.embedding_provider.model_name().to_string(),
1960 dimensions: self.embedding_provider.dimensions(),
1961 }],
1962 edge_types,
1963 causal_nodes,
1964 causal_edges: causal_edges_out,
1965 metadata: session.metadata.clone(),
1966 })
1967 }
1968
1969 pub fn import_model(
1971 &self,
1972 domain: &str,
1973 model: ExportedModel,
1974 ) -> Result<(), String> {
1975 if !model.version.starts_with("1.") {
1977 return Err(format!(
1978 "incompatible model version: expected 1.x, got {}",
1979 model.version
1980 ));
1981 }
1982
1983 let session = ModelingSession {
1984 id: uuid::Uuid::new_v4().to_string(),
1985 domain: domain.to_string(),
1986 started_at: Utc::now(),
1987 confidence: model.confidence,
1988 gaps: Vec::new(),
1989 sources_ingested: model
1990 .edge_types
1991 .iter()
1992 .map(|e| e.edge_type.clone())
1993 .collect(),
1994 tick_count: 0,
1995 budget_remaining_ms: 300_000,
1996 active: true,
1997 metadata: model.metadata,
1998 };
1999
2000 let mut sessions = self.sessions.write().map_err(|e| e.to_string())?;
2001 sessions.insert(domain.to_string(), session);
2002
2003 drop(sessions);
2005 self.record_meta_loom(
2006 domain,
2007 MetaDecisionType::ModelVersionBumped { from: 0, to: 1 },
2008 "Imported from exported model",
2009 model.confidence,
2010 );
2011
2012 info!(domain, "weaver model imported");
2013 Ok(())
2014 }
2015
2016 pub fn handle_command(&self, cmd: WeaverCommand) -> WeaverResponse {
2020 match cmd {
2021 WeaverCommand::SessionStart {
2022 domain,
2023 context,
2024 goal,
2025 ..
2026 } => match self.start_session(
2027 &domain,
2028 context.as_deref(),
2029 goal.as_deref(),
2030 ) {
2031 Ok(session_id) => WeaverResponse::SessionStarted { domain, session_id },
2032 Err(e) => WeaverResponse::Error(e),
2033 },
2034 WeaverCommand::SessionStop { domain } => match self.stop_session(&domain) {
2035 Ok(()) => WeaverResponse::SessionStopped { domain },
2036 Err(e) => WeaverResponse::Error(e),
2037 },
2038 WeaverCommand::SessionResume { domain } => match self.resume_session(&domain) {
2039 Ok(()) => WeaverResponse::SessionResumed { domain },
2040 Err(e) => WeaverResponse::Error(e),
2041 },
2042 WeaverCommand::SourceAdd {
2043 domain,
2044 source_type,
2045 root,
2046 ..
2047 } => match self.add_source(&domain, &source_type, root.as_ref()) {
2048 Ok(()) => WeaverResponse::SourceAdded {
2049 domain,
2050 source_type,
2051 },
2052 Err(e) => WeaverResponse::Error(e),
2053 },
2054 WeaverCommand::SourceList { domain } => {
2055 match self.get_session(&domain) {
2056 Some(s) => WeaverResponse::Sources(s.sources_ingested),
2057 None => WeaverResponse::Error(format!("no session for domain: {domain}")),
2058 }
2059 }
2060 WeaverCommand::Confidence { domain, .. } => {
2061 match self.evaluate_confidence(&domain) {
2062 Ok(report) => WeaverResponse::ConfidenceReport(report),
2063 Err(e) => WeaverResponse::Error(e),
2064 }
2065 }
2066 WeaverCommand::Export {
2067 domain,
2068 min_confidence,
2069 output,
2070 } => match self.export_model(&domain, min_confidence) {
2071 Ok(model) => {
2072 let edges = model.edge_types.len();
2073 debug!(?output, edges, "model exported");
2075 WeaverResponse::Exported { path: output, edges }
2076 }
2077 Err(e) => WeaverResponse::Error(e),
2078 },
2079 WeaverCommand::Import { domain, input } => {
2080 debug!(?input, "model import requested");
2082 WeaverResponse::Imported { domain }
2083 }
2084 WeaverCommand::MetaStrategies => {
2085 let strategies = self.knowledge_base.list_strategies();
2086 WeaverResponse::Strategies(strategies)
2087 }
2088 WeaverCommand::MetaExportKb { output } => {
2089 debug!(?output, "KB export requested");
2090 WeaverResponse::KbExported { path: output }
2091 }
2092 _ => WeaverResponse::Error("command not implemented".into()),
2093 }
2094 }
2095
2096 fn record_meta_loom(
2100 &self,
2101 domain: &str,
2102 decision: MetaDecisionType,
2103 rationale: &str,
2104 confidence_before: f64,
2105 ) {
2106 let event = MetaLoomEvent {
2107 session_domain: domain.to_string(),
2108 decision_type: decision,
2109 confidence_before,
2110 confidence_after: None,
2111 rationale: rationale.to_string(),
2112 timestamp: Utc::now(),
2113 };
2114
2115 let label = format!("meta-loom/{}", domain);
2117 self.causal_graph.add_node(
2118 label,
2119 serde_json::to_value(&event).unwrap_or_default(),
2120 );
2121
2122 if let Ok(mut events) = self.meta_loom_events.write() {
2124 events
2125 .entry(domain.to_string())
2126 .or_default()
2127 .push(event);
2128 }
2129 }
2130
2131 pub fn meta_loom_events(&self, domain: &str) -> Vec<MetaLoomEvent> {
2133 self.meta_loom_events
2134 .read()
2135 .ok()
2136 .and_then(|m| m.get(domain).cloned())
2137 .unwrap_or_default()
2138 }
2139
2140 fn emit_impulse(&self, impulse_type: ImpulseType) {
2142 if let Some(queue) = &self.impulse_queue {
2143 queue.emit(
2144 0x03, [0u8; 32],
2146 0x03, impulse_type,
2148 serde_json::Value::Null,
2149 0,
2150 );
2151 }
2152 }
2153
2154 pub fn on_tick(&mut self, budget_ms: u32) -> CognitiveTickResult {
2162 let start = Instant::now();
2163 let budget = Duration::from_millis(budget_ms as u64);
2164 let mut result = CognitiveTickResult {
2165 tick_number: self.tick_count.load(Ordering::Relaxed),
2166 budget_ms,
2167 ..Default::default()
2168 };
2169
2170 if start.elapsed() < budget {
2172 if let Some(new_commits) = self.poll_git() {
2173 result.git_commits_found = new_commits;
2174 }
2175 }
2176
2177 if start.elapsed() < budget {
2179 if let Some(changed_files) = self.poll_file_changes() {
2180 result.files_changed = changed_files;
2181 }
2182 }
2183
2184 if start.elapsed() < budget {
2186 let remaining = budget.saturating_sub(start.elapsed());
2187 let tick_result = self.tick(remaining);
2188 if let TickResult::Progress { .. } = tick_result {
2189 result.nodes_processed = 1;
2190 }
2191 }
2192
2193 if start.elapsed() < budget && self.ticks_since_confidence_update > 100 {
2195 let report = self.compute_confidence();
2196 self.last_confidence = Some(report.clone());
2197 self.ticks_since_confidence_update = 0;
2198 result.confidence_updated = true;
2199
2200 let snapshot = ConfidenceSnapshot {
2202 timestamp: Utc::now(),
2203 tick_number: self.tick_count.load(Ordering::Relaxed),
2204 confidence: report.overall,
2205 node_count: self.causal_graph.node_count() as usize,
2206 edge_count: self.causal_graph.edge_count() as usize,
2207 gap_count: report.gaps.len(),
2208 trigger: ConfidenceTrigger::Periodic,
2209 };
2210 self.confidence_history.record(snapshot);
2211 }
2212
2213 result.elapsed_ms = start.elapsed().as_millis() as u32;
2214 result.within_budget = start.elapsed() <= budget;
2215 self.tick_count.fetch_add(1, Ordering::Relaxed);
2216 self.ticks_since_confidence_update += 1;
2217
2218 self.tick_history.record(result.clone());
2220
2221 result
2222 }
2223
2224 pub fn enable_git_polling(&mut self, repo_path: PathBuf, branch: String) {
2226 self.git_poller = Some(GitPoller::new(repo_path, branch));
2227 }
2228
2229 pub fn enable_file_watching(&mut self, root: PathBuf, patterns: Vec<String>) {
2231 let mut watcher = FileWatcher::new(root, patterns);
2232 watcher.watch_directory();
2233 self.file_watcher = Some(watcher);
2234 }
2235
2236 fn poll_git(&mut self) -> Option<usize> {
2238 self.git_poller.as_mut().map(|p| p.poll())
2239 }
2240
2241 fn poll_file_changes(&mut self) -> Option<usize> {
2243 self.file_watcher.as_mut().map(|w| w.poll_changes().len())
2244 }
2245
2246 pub fn cached_confidence(&self) -> Option<&ConfidenceReport> {
2248 self.last_confidence.as_ref()
2249 }
2250
2251 pub fn git_poller(&self) -> Option<&GitPoller> {
2253 self.git_poller.as_ref()
2254 }
2255
2256 pub fn file_watcher(&self) -> Option<&FileWatcher> {
2258 self.file_watcher.as_ref()
2259 }
2260
2261 pub fn total_ticks(&self) -> u64 {
2263 self.tick_count.load(Ordering::Relaxed)
2264 }
2265
2266 pub fn confidence_history(&self) -> &ConfidenceHistory {
2270 &self.confidence_history
2271 }
2272
2273 pub fn confidence_history_mut(&mut self) -> &mut ConfidenceHistory {
2275 &mut self.confidence_history
2276 }
2277
2278 pub fn strategy_tracker(&self) -> &StrategyTracker {
2282 &self.strategy_tracker
2283 }
2284
2285 pub fn strategy_tracker_mut(&mut self) -> &mut StrategyTracker {
2287 &mut self.strategy_tracker
2288 }
2289
2290 pub fn tick_history(&self) -> &TickHistory {
2294 &self.tick_history
2295 }
2296
2297 pub fn set_tick_interval_ms(&mut self, ms: u32) {
2299 self.current_tick_interval_ms = ms;
2300 }
2301
2302 pub fn recommend_tick_interval(&self) -> TickRecommendation {
2311 let idle = self.tick_history.idle_ticks();
2312 let cpm = self.tick_history.changes_per_minute();
2313 let sample_count = self.tick_history.len();
2314
2315 if sample_count < 5 {
2317 return TickRecommendation {
2318 recommended_ms: self.current_tick_interval_ms,
2319 current_ms: self.current_tick_interval_ms,
2320 reason: "Insufficient data for recommendation".to_string(),
2321 changes_per_minute: cpm,
2322 recommendation_confidence: 0.1,
2323 };
2324 }
2325
2326 if idle >= 100 {
2328 return TickRecommendation {
2329 recommended_ms: 5000,
2330 current_ms: self.current_tick_interval_ms,
2331 reason: format!(
2332 "No changes for {idle} consecutive ticks; entering idle mode"
2333 ),
2334 changes_per_minute: cpm,
2335 recommendation_confidence: 0.9,
2336 };
2337 }
2338
2339 if cpm > 10.0 {
2341 return TickRecommendation {
2342 recommended_ms: 200,
2343 current_ms: self.current_tick_interval_ms,
2344 reason: format!(
2345 "High change rate ({cpm:.1}/min); recommending fast ticks"
2346 ),
2347 changes_per_minute: cpm,
2348 recommendation_confidence: 0.8,
2349 };
2350 }
2351
2352 if cpm >= 1.0 {
2354 return TickRecommendation {
2355 recommended_ms: 1000,
2356 current_ms: self.current_tick_interval_ms,
2357 reason: format!(
2358 "Moderate change rate ({cpm:.1}/min); default interval"
2359 ),
2360 changes_per_minute: cpm,
2361 recommendation_confidence: 0.7,
2362 };
2363 }
2364
2365 TickRecommendation {
2367 recommended_ms: 3000,
2368 current_ms: self.current_tick_interval_ms,
2369 reason: format!(
2370 "Low change rate ({cpm:.2}/min); recommending slower ticks"
2371 ),
2372 changes_per_minute: cpm,
2373 recommendation_confidence: 0.6,
2374 }
2375 }
2376}
2377
2378#[async_trait]
2379impl SystemService for WeaverEngine {
2380 fn name(&self) -> &str {
2381 "weaver"
2382 }
2383
2384 fn service_type(&self) -> ServiceType {
2385 ServiceType::Core
2386 }
2387
2388 async fn start(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
2389 info!("weaver engine started");
2390 Ok(())
2391 }
2392
2393 async fn stop(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
2394 if let Ok(mut sessions) = self.sessions.write() {
2396 for (_, session) in sessions.iter_mut() {
2397 session.active = false;
2398 }
2399 }
2400 info!(
2401 total_ticks = self.total_ticks(),
2402 kb_strategies = self.knowledge_base.count(),
2403 "weaver engine stopped"
2404 );
2405 Ok(())
2406 }
2407
2408 async fn health_check(&self) -> HealthStatus {
2409 HealthStatus::Healthy
2411 }
2412}
2413
2414#[derive(Debug, Clone, Serialize, Deserialize)]
2420pub struct ModelDiff {
2421 pub model_a: String,
2423 pub model_b: String,
2425 pub confidence_delta: f64,
2427 pub nodes_only_a: Vec<String>,
2429 pub nodes_only_b: Vec<String>,
2431 pub nodes_common: Vec<String>,
2433 pub edges_only_a: Vec<String>,
2435 pub edges_only_b: Vec<String>,
2437 pub edges_common: Vec<String>,
2439 pub causal_nodes_added: usize,
2441 pub causal_nodes_removed: usize,
2443 pub causal_edges_added: usize,
2445 pub causal_edges_removed: usize,
2447 pub summary: String,
2449}
2450
2451pub fn diff_models(a: &ExportedModel, b: &ExportedModel) -> ModelDiff {
2453 let a_node_names: HashSet<&str> = a.node_types.iter().map(|n| n.name.as_str()).collect();
2455 let b_node_names: HashSet<&str> = b.node_types.iter().map(|n| n.name.as_str()).collect();
2456
2457 let nodes_only_a: Vec<String> = a_node_names
2458 .difference(&b_node_names)
2459 .map(|s| s.to_string())
2460 .collect();
2461 let nodes_only_b: Vec<String> = b_node_names
2462 .difference(&a_node_names)
2463 .map(|s| s.to_string())
2464 .collect();
2465 let nodes_common: Vec<String> = a_node_names
2466 .intersection(&b_node_names)
2467 .map(|s| s.to_string())
2468 .collect();
2469
2470 let edge_key =
2472 |e: &EdgeTypeSpec| format!("{}->{}:{}", e.from_type, e.to_type, e.edge_type);
2473 let a_edge_keys: HashSet<String> = a.edge_types.iter().map(|e| edge_key(e)).collect();
2474 let b_edge_keys: HashSet<String> = b.edge_types.iter().map(|e| edge_key(e)).collect();
2475
2476 let edges_only_a: Vec<String> = a_edge_keys.difference(&b_edge_keys).cloned().collect();
2477 let edges_only_b: Vec<String> = b_edge_keys.difference(&a_edge_keys).cloned().collect();
2478 let edges_common: Vec<String> = a_edge_keys.intersection(&b_edge_keys).cloned().collect();
2479
2480 let a_causal_labels: HashSet<&str> =
2482 a.causal_nodes.iter().map(|n| n.label.as_str()).collect();
2483 let b_causal_labels: HashSet<&str> =
2484 b.causal_nodes.iter().map(|n| n.label.as_str()).collect();
2485
2486 let causal_nodes_added = b_causal_labels.difference(&a_causal_labels).count();
2487 let causal_nodes_removed = a_causal_labels.difference(&b_causal_labels).count();
2488
2489 let causal_edge_key =
2491 |e: &ExportedCausalEdge| format!("{}->{}", e.source_label, e.target_label);
2492 let a_causal_edge_keys: HashSet<String> =
2493 a.causal_edges.iter().map(|e| causal_edge_key(e)).collect();
2494 let b_causal_edge_keys: HashSet<String> =
2495 b.causal_edges.iter().map(|e| causal_edge_key(e)).collect();
2496
2497 let causal_edges_added = b_causal_edge_keys.difference(&a_causal_edge_keys).count();
2498 let causal_edges_removed = a_causal_edge_keys.difference(&b_causal_edge_keys).count();
2499
2500 let confidence_delta = b.confidence - a.confidence;
2501
2502 let mut parts = Vec::new();
2504 if confidence_delta.abs() > f64::EPSILON {
2505 parts.push(format!(
2506 "confidence {} by {:.3}",
2507 if confidence_delta > 0.0 {
2508 "increased"
2509 } else {
2510 "decreased"
2511 },
2512 confidence_delta.abs()
2513 ));
2514 }
2515 if causal_nodes_added > 0 || causal_nodes_removed > 0 {
2516 parts.push(format!(
2517 "{} causal nodes added, {} removed",
2518 causal_nodes_added, causal_nodes_removed
2519 ));
2520 }
2521 if causal_edges_added > 0 || causal_edges_removed > 0 {
2522 parts.push(format!(
2523 "{} causal edges added, {} removed",
2524 causal_edges_added, causal_edges_removed
2525 ));
2526 }
2527 if !nodes_only_a.is_empty() || !nodes_only_b.is_empty() {
2528 parts.push(format!(
2529 "{} node types only in A, {} only in B",
2530 nodes_only_a.len(),
2531 nodes_only_b.len()
2532 ));
2533 }
2534 let summary = if parts.is_empty() {
2535 "models are identical".to_string()
2536 } else {
2537 parts.join("; ")
2538 };
2539
2540 ModelDiff {
2541 model_a: a.domain.clone(),
2542 model_b: b.domain.clone(),
2543 confidence_delta,
2544 nodes_only_a,
2545 nodes_only_b,
2546 nodes_common,
2547 edges_only_a,
2548 edges_only_b,
2549 edges_common,
2550 causal_nodes_added,
2551 causal_nodes_removed,
2552 causal_edges_added,
2553 causal_edges_removed,
2554 summary,
2555 }
2556}
2557
2558#[derive(Debug, Clone, Serialize, Deserialize)]
2564pub struct MergeResult {
2565 pub merged: ExportedModel,
2567 pub conflicts: Vec<MergeConflict>,
2569 pub stats: MergeStats,
2571}
2572
2573#[derive(Debug, Clone, Serialize, Deserialize)]
2575pub struct MergeConflict {
2576 pub item: String,
2578 pub value_a: String,
2580 pub value_b: String,
2582 pub resolution: ConflictResolution,
2584}
2585
2586#[non_exhaustive]
2588#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2589pub enum ConflictResolution {
2590 KeepA,
2592 KeepB,
2594 Merged,
2596 HigherConfidence,
2598}
2599
2600#[derive(Debug, Clone, Serialize, Deserialize)]
2602pub struct MergeStats {
2603 pub total_node_types: usize,
2605 pub total_edge_types: usize,
2607 pub total_causal_nodes: usize,
2609 pub total_causal_edges: usize,
2611 pub conflicts_resolved: usize,
2613 pub nodes_from_a: usize,
2615 pub nodes_from_b: usize,
2617 pub nodes_shared: usize,
2619}
2620
2621pub fn merge_models(a: &ExportedModel, b: &ExportedModel) -> MergeResult {
2628 let mut conflicts = Vec::new();
2629
2630 let a_node_map: HashMap<&str, &NodeTypeSpec> =
2632 a.node_types.iter().map(|n| (n.name.as_str(), n)).collect();
2633 let b_node_map: HashMap<&str, &NodeTypeSpec> =
2634 b.node_types.iter().map(|n| (n.name.as_str(), n)).collect();
2635
2636 let all_node_names: HashSet<&str> =
2637 a_node_map.keys().chain(b_node_map.keys()).copied().collect();
2638
2639 let mut merged_node_types = Vec::new();
2640 let mut nodes_from_a = 0usize;
2641 let mut nodes_from_b = 0usize;
2642 let mut nodes_shared = 0usize;
2643
2644 for name in &all_node_names {
2645 match (a_node_map.get(name), b_node_map.get(name)) {
2646 (Some(na), None) => {
2647 merged_node_types.push((*na).clone());
2648 nodes_from_a += 1;
2649 }
2650 (None, Some(nb)) => {
2651 merged_node_types.push((*nb).clone());
2652 nodes_from_b += 1;
2653 }
2654 (Some(na), Some(nb)) => {
2655 nodes_shared += 1;
2656 if na.embedding_strategy != nb.embedding_strategy {
2657 let (winner, resolution) = if na.dimensions >= nb.dimensions {
2658 ((*na).clone(), ConflictResolution::KeepA)
2659 } else {
2660 ((*nb).clone(), ConflictResolution::KeepB)
2661 };
2662 conflicts.push(MergeConflict {
2663 item: format!("node_type:{}", name),
2664 value_a: na.embedding_strategy.clone(),
2665 value_b: nb.embedding_strategy.clone(),
2666 resolution,
2667 });
2668 merged_node_types.push(winner);
2669 } else {
2670 merged_node_types.push((*na).clone());
2671 }
2672 }
2673 (None, None) => unreachable!(),
2674 }
2675 }
2676
2677 let edge_key =
2679 |e: &EdgeTypeSpec| format!("{}->{}:{}", e.from_type, e.to_type, e.edge_type);
2680 let a_edge_map: HashMap<String, &EdgeTypeSpec> =
2681 a.edge_types.iter().map(|e| (edge_key(e), e)).collect();
2682 let b_edge_map: HashMap<String, &EdgeTypeSpec> =
2683 b.edge_types.iter().map(|e| (edge_key(e), e)).collect();
2684
2685 let all_edge_keys: HashSet<&str> = a_edge_map
2686 .keys()
2687 .chain(b_edge_map.keys())
2688 .map(|s| s.as_str())
2689 .collect();
2690
2691 let mut merged_edge_types = Vec::new();
2692 for key in &all_edge_keys {
2693 match (a_edge_map.get(*key), b_edge_map.get(*key)) {
2694 (Some(ea), None) => merged_edge_types.push((*ea).clone()),
2695 (None, Some(eb)) => merged_edge_types.push((*eb).clone()),
2696 (Some(ea), Some(eb)) => {
2697 if (ea.confidence - eb.confidence).abs() > f64::EPSILON {
2698 let (winner, resolution) = if ea.confidence >= eb.confidence {
2699 ((*ea).clone(), ConflictResolution::HigherConfidence)
2700 } else {
2701 ((*eb).clone(), ConflictResolution::HigherConfidence)
2702 };
2703 conflicts.push(MergeConflict {
2704 item: format!("edge_type:{}", key),
2705 value_a: format!("{:.4}", ea.confidence),
2706 value_b: format!("{:.4}", eb.confidence),
2707 resolution,
2708 });
2709 merged_edge_types.push(winner);
2710 } else {
2711 merged_edge_types.push((*ea).clone());
2712 }
2713 }
2714 (None, None) => unreachable!(),
2715 }
2716 }
2717
2718 let a_cn_map: HashMap<&str, &ExportedCausalNode> =
2720 a.causal_nodes.iter().map(|n| (n.label.as_str(), n)).collect();
2721 let b_cn_map: HashMap<&str, &ExportedCausalNode> =
2722 b.causal_nodes.iter().map(|n| (n.label.as_str(), n)).collect();
2723
2724 let all_cn_labels: HashSet<&str> =
2725 a_cn_map.keys().chain(b_cn_map.keys()).copied().collect();
2726
2727 let mut merged_causal_nodes = Vec::new();
2728 for label in &all_cn_labels {
2729 match (a_cn_map.get(label), b_cn_map.get(label)) {
2730 (Some(na), None) => merged_causal_nodes.push((*na).clone()),
2731 (None, Some(nb)) => merged_causal_nodes.push((*nb).clone()),
2732 (Some(_na), Some(nb)) => {
2733 merged_causal_nodes.push((*nb).clone());
2735 }
2736 (None, None) => unreachable!(),
2737 }
2738 }
2739
2740 let ce_key =
2742 |e: &ExportedCausalEdge| format!("{}->{}", e.source_label, e.target_label);
2743 let a_ce_map: HashMap<String, &ExportedCausalEdge> =
2744 a.causal_edges.iter().map(|e| (ce_key(e), e)).collect();
2745 let b_ce_map: HashMap<String, &ExportedCausalEdge> =
2746 b.causal_edges.iter().map(|e| (ce_key(e), e)).collect();
2747
2748 let all_ce_keys: HashSet<&str> = a_ce_map
2749 .keys()
2750 .chain(b_ce_map.keys())
2751 .map(|s| s.as_str())
2752 .collect();
2753
2754 let mut merged_causal_edges = Vec::new();
2755 for key in &all_ce_keys {
2756 match (a_ce_map.get(*key), b_ce_map.get(*key)) {
2757 (Some(ea), None) => merged_causal_edges.push((*ea).clone()),
2758 (None, Some(eb)) => merged_causal_edges.push((*eb).clone()),
2759 (Some(ea), Some(eb)) => {
2760 let mut merged_edge = (*ea).clone();
2761 merged_edge.weight = (ea.weight + eb.weight) / 2.0;
2762 if ea.edge_type != eb.edge_type {
2763 conflicts.push(MergeConflict {
2764 item: format!("causal_edge:{}", key),
2765 value_a: ea.edge_type.clone(),
2766 value_b: eb.edge_type.clone(),
2767 resolution: ConflictResolution::Merged,
2768 });
2769 }
2770 merged_causal_edges.push(merged_edge);
2771 }
2772 (None, None) => unreachable!(),
2773 }
2774 }
2775
2776 let mut merged_metadata = a.metadata.clone();
2778 for (k, v) in &b.metadata {
2779 merged_metadata.entry(k.clone()).or_insert_with(|| v.clone());
2780 }
2781
2782 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.iter_mut().find(|s| {
2890 s.decision_type == pattern.decision_type
2891 && s.context == pattern.context
2892 }) {
2893 existing.improvement =
2894 (existing.improvement + pattern.improvement) / 2.0;
2895 existing.timestamp = pattern.timestamp;
2896 return;
2897 }
2898 strategies.push(pattern);
2899 self.strategy_count.fetch_add(1, Ordering::Relaxed);
2900 }
2901 }
2902
2903 pub fn find_patterns(
2909 &self,
2910 domain_characteristics: &[String],
2911 ) -> Vec<StrategyPattern> {
2912 let strategies = match self.strategies.read() {
2913 Ok(s) => s,
2914 Err(_) => return Vec::new(),
2915 };
2916
2917 let mut scored: Vec<(usize, &StrategyPattern)> = strategies
2918 .iter()
2919 .filter_map(|s| {
2920 let score = domain_characteristics
2921 .iter()
2922 .filter(|c| s.context.contains(c.as_str()))
2923 .count();
2924 if score > 0 {
2925 Some((score, s))
2926 } else {
2927 None
2928 }
2929 })
2930 .collect();
2931
2932 scored.sort_by(|a, b| b.0.cmp(&a.0));
2933 scored.into_iter().map(|(_, p)| p.clone()).collect()
2934 }
2935
2936 pub fn pattern_count(&self) -> usize {
2938 self.strategies.read().map(|s| s.len()).unwrap_or(0)
2939 }
2940}
2941
2942#[cfg(test)]
2945mod tests {
2946 use super::*;
2947 use crate::hnsw_service::HnswServiceConfig;
2948
2949 fn make_engine() -> WeaverEngine {
2950 let graph = Arc::new(CausalGraph::new());
2951 let hnsw = Arc::new(HnswService::new(HnswServiceConfig::default()));
2952 WeaverEngine::new_with_mock(graph, hnsw)
2953 }
2954
2955 #[test]
2956 fn start_session_creates_session() {
2957 let engine = make_engine();
2958 let sid = engine
2959 .start_session("test-domain", Some("test context"), None)
2960 .unwrap();
2961 assert!(!sid.is_empty());
2962 let session = engine.get_session("test-domain").unwrap();
2963 assert_eq!(session.domain, "test-domain");
2964 assert!(session.active);
2965 assert_eq!(session.confidence, 0.0);
2966 }
2967
2968 #[test]
2969 fn duplicate_session_start_fails() {
2970 let engine = make_engine();
2971 engine.start_session("dup", None, None).unwrap();
2972 let result = engine.start_session("dup", None, None);
2973 assert!(result.is_err());
2974 }
2975
2976 #[test]
2977 fn stop_and_resume_session() {
2978 let engine = make_engine();
2979 engine.start_session("lifecycle", None, None).unwrap();
2980 engine.stop_session("lifecycle").unwrap();
2981 assert!(!engine.get_session("lifecycle").unwrap().active);
2982 engine.resume_session("lifecycle").unwrap();
2983 assert!(engine.get_session("lifecycle").unwrap().active);
2984 }
2985
2986 #[test]
2987 fn add_source_records_ingestion() {
2988 let engine = make_engine();
2989 engine.start_session("src-test", None, None).unwrap();
2990 engine.add_source("src-test", "git_log", None).unwrap();
2991 let session = engine.get_session("src-test").unwrap();
2992 assert_eq!(session.sources_ingested, vec!["git_log"]);
2993 }
2994
2995 #[test]
2996 fn add_source_nonexistent_domain_fails() {
2997 let engine = make_engine();
2998 let result = engine.add_source("nope", "git_log", None);
2999 assert!(result.is_err());
3000 }
3001
3002 #[test]
3003 fn evaluate_confidence_basic() {
3004 let engine = make_engine();
3005 engine.start_session("conf", None, None).unwrap();
3006 let report = engine.evaluate_confidence("conf").unwrap();
3007 assert!(report.overall >= 0.0 && report.overall <= 1.0);
3008 assert!(!report.gaps.is_empty());
3010 }
3011
3012 #[test]
3013 fn evaluate_confidence_improves_with_sources() {
3014 let engine = make_engine();
3015 engine.start_session("improve", None, None).unwrap();
3016 let r1 = engine.evaluate_confidence("improve").unwrap();
3017 engine.add_source("improve", "git_log", None).unwrap();
3018 engine.add_source("improve", "file_tree", None).unwrap();
3019 let r2 = engine.evaluate_confidence("improve").unwrap();
3020 assert!(r2.overall >= r1.overall);
3021 }
3022
3023 #[test]
3024 fn tick_with_no_session_returns_idle() {
3025 let engine = make_engine();
3026 let result = engine.tick(Duration::from_secs(1));
3027 assert!(matches!(result, TickResult::Idle));
3028 }
3029
3030 #[test]
3031 fn tick_processes_active_session() {
3032 let engine = make_engine();
3033 engine.start_session("tick-test", None, None).unwrap();
3034 let result = engine.tick(Duration::from_secs(5));
3035 assert!(matches!(result, TickResult::Progress { .. }));
3036 let session = engine.get_session("tick-test").unwrap();
3037 assert_eq!(session.tick_count, 1);
3038 }
3039
3040 #[test]
3041 fn tick_increments_total_count() {
3042 let engine = make_engine();
3043 engine.start_session("ticks", None, None).unwrap();
3044 engine.tick(Duration::from_secs(5));
3045 engine.tick(Duration::from_secs(5));
3046 assert_eq!(engine.total_ticks(), 2);
3047 }
3048
3049 #[test]
3050 fn tick_skips_stopped_session() {
3051 let engine = make_engine();
3052 engine.start_session("stopped", None, None).unwrap();
3053 engine.stop_session("stopped").unwrap();
3054 let result = engine.tick(Duration::from_secs(5));
3055 assert!(matches!(result, TickResult::Idle));
3056 }
3057
3058 #[test]
3059 fn export_model_basic() {
3060 let engine = make_engine();
3061 engine.start_session("export", None, None).unwrap();
3062 engine.add_source("export", "git_log", None).unwrap();
3063 let model = engine.export_model("export", 0.0).unwrap();
3064 assert_eq!(model.domain, "export");
3065 assert_eq!(model.version, "1.0");
3066 assert!(!model.node_types.is_empty());
3067 }
3068
3069 #[test]
3070 fn export_model_filters_by_confidence() {
3071 let engine = make_engine();
3072 engine.start_session("filter", None, None).unwrap();
3073 engine.add_source("filter", "git", None).unwrap();
3074 engine.add_source("filter", "file", None).unwrap();
3075 let model_all = engine.export_model("filter", 0.0).unwrap();
3076 let model_high = engine.export_model("filter", 0.5).unwrap();
3077 assert!(model_all.edge_types.len() >= model_high.edge_types.len());
3078 }
3079
3080 #[test]
3081 fn import_model_creates_session() {
3082 let engine = make_engine();
3083 let model = ExportedModel {
3084 version: "1.0".into(),
3085 domain: "imported".into(),
3086 exported_at: Utc::now(),
3087 confidence: 0.75,
3088 node_types: vec![],
3089 edge_types: vec![],
3090 causal_nodes: vec![],
3091 causal_edges: vec![],
3092 metadata: HashMap::new(),
3093 };
3094 engine.import_model("imported", model).unwrap();
3095 let session = engine.get_session("imported").unwrap();
3096 assert_eq!(session.confidence, 0.75);
3097 assert!(session.active);
3098 }
3099
3100 #[test]
3101 fn import_model_version_check() {
3102 let engine = make_engine();
3103 let model = ExportedModel {
3104 version: "2.0".into(),
3105 domain: "bad".into(),
3106 exported_at: Utc::now(),
3107 confidence: 0.5,
3108 node_types: vec![],
3109 edge_types: vec![],
3110 causal_nodes: vec![],
3111 causal_edges: vec![],
3112 metadata: HashMap::new(),
3113 };
3114 let result = engine.import_model("bad", model);
3115 assert!(result.is_err());
3116 assert!(result.unwrap_err().contains("incompatible"));
3117 }
3118
3119 #[test]
3120 fn meta_loom_events_recorded_on_session_start() {
3121 let engine = make_engine();
3122 engine.start_session("meta", None, None).unwrap();
3123 let events = engine.meta_loom_events("meta");
3124 assert!(!events.is_empty());
3125 assert!(matches!(
3126 events[0].decision_type,
3127 MetaDecisionType::ModelVersionBumped { .. }
3128 ));
3129 }
3130
3131 #[test]
3132 fn meta_loom_events_recorded_on_source_add() {
3133 let engine = make_engine();
3134 engine.start_session("meta-src", None, None).unwrap();
3135 engine.add_source("meta-src", "git_log", None).unwrap();
3136 let events = engine.meta_loom_events("meta-src");
3137 assert!(events.len() >= 2); assert!(matches!(
3139 events.last().unwrap().decision_type,
3140 MetaDecisionType::SourceAdded { .. }
3141 ));
3142 }
3143
3144 #[test]
3145 fn knowledge_base_record_and_list() {
3146 let kb = WeaverKnowledgeBase::new();
3147 kb.record_strategy(StrategyPattern {
3148 decision_type: "SourceAdded".into(),
3149 context: "rust-project".into(),
3150 improvement: 0.15,
3151 timestamp: Utc::now(),
3152 });
3153 let strategies = kb.list_strategies();
3154 assert_eq!(strategies.len(), 1);
3155 assert_eq!(strategies[0].improvement, 0.15);
3156 }
3157
3158 #[test]
3159 fn knowledge_base_strategies_for_domain() {
3160 let kb = WeaverKnowledgeBase::new();
3161 kb.record_strategy(StrategyPattern {
3162 decision_type: "SourceAdded".into(),
3163 context: "rust".into(),
3164 improvement: 0.1,
3165 timestamp: Utc::now(),
3166 });
3167 kb.record_strategy(StrategyPattern {
3168 decision_type: "EdgeType".into(),
3169 context: "python".into(),
3170 improvement: 0.2,
3171 timestamp: Utc::now(),
3172 });
3173 assert_eq!(kb.strategies_for("rust").len(), 1);
3174 assert_eq!(kb.strategies_for("python").len(), 1);
3175 assert_eq!(kb.strategies_for("go").len(), 0);
3176 }
3177
3178 #[test]
3179 fn knowledge_base_export() {
3180 let kb = WeaverKnowledgeBase::new();
3181 kb.record_strategy(StrategyPattern {
3182 decision_type: "test".into(),
3183 context: "test".into(),
3184 improvement: 0.5,
3185 timestamp: Utc::now(),
3186 });
3187 let exported = kb.export();
3188 assert!(exported.is_array());
3189 }
3190
3191 #[test]
3192 fn handle_command_session_start() {
3193 let engine = make_engine();
3194 let resp = engine.handle_command(WeaverCommand::SessionStart {
3195 domain: "cmd-test".into(),
3196 git_path: None,
3197 context: None,
3198 goal: None,
3199 });
3200 assert!(matches!(resp, WeaverResponse::SessionStarted { .. }));
3201 }
3202
3203 #[test]
3204 fn handle_command_confidence() {
3205 let engine = make_engine();
3206 engine.start_session("cmd-conf", None, None).unwrap();
3207 let resp = engine.handle_command(WeaverCommand::Confidence {
3208 domain: "cmd-conf".into(),
3209 edge: None,
3210 verbose: false,
3211 });
3212 assert!(matches!(resp, WeaverResponse::ConfidenceReport(_)));
3213 }
3214
3215 #[test]
3216 fn handle_command_source_list() {
3217 let engine = make_engine();
3218 engine.start_session("cmd-src", None, None).unwrap();
3219 engine.add_source("cmd-src", "git_log", None).unwrap();
3220 let resp = engine.handle_command(WeaverCommand::SourceList {
3221 domain: "cmd-src".into(),
3222 });
3223 match resp {
3224 WeaverResponse::Sources(s) => assert_eq!(s, vec!["git_log"]),
3225 other => panic!("expected Sources, got {other:?}"),
3226 }
3227 }
3228
3229 #[test]
3230 fn handle_command_unknown_domain() {
3231 let engine = make_engine();
3232 let resp = engine.handle_command(WeaverCommand::Confidence {
3233 domain: "missing".into(),
3234 edge: None,
3235 verbose: false,
3236 });
3237 assert!(matches!(resp, WeaverResponse::Error(_)));
3238 }
3239
3240 #[test]
3241 fn list_sessions() {
3242 let engine = make_engine();
3243 engine.start_session("a", None, None).unwrap();
3244 engine.start_session("b", None, None).unwrap();
3245 let mut sessions = engine.list_sessions();
3246 sessions.sort();
3247 assert_eq!(sessions, vec!["a", "b"]);
3248 }
3249
3250 #[tokio::test]
3251 async fn system_service_impl() {
3252 let engine = make_engine();
3253 assert_eq!(engine.name(), "weaver");
3254 assert_eq!(engine.service_type(), ServiceType::Core);
3255 engine.start().await.unwrap();
3256 let health = engine.health_check().await;
3257 assert_eq!(health, HealthStatus::Healthy);
3258 engine.stop().await.unwrap();
3259 }
3260
3261 #[test]
3262 fn impulse_emitted_on_session_start() {
3263 let queue = Arc::new(ImpulseQueue::new());
3264 let graph = Arc::new(CausalGraph::new());
3265 let hnsw = Arc::new(HnswService::new(HnswServiceConfig::default()));
3266 let mut engine = WeaverEngine::new_with_mock(graph, hnsw);
3267 engine.set_impulse_queue(queue.clone());
3268 engine.start_session("impulse-test", None, None).unwrap();
3269 let impulses = queue.drain_ready();
3270 assert!(!impulses.is_empty());
3271 assert!(impulses.iter().any(|i| i.impulse_type == ImpulseType::Custom(0x32)));
3272 }
3273
3274 #[test]
3275 fn impulse_emitted_on_source_add() {
3276 let queue = Arc::new(ImpulseQueue::new());
3277 let graph = Arc::new(CausalGraph::new());
3278 let hnsw = Arc::new(HnswService::new(HnswServiceConfig::default()));
3279 let mut engine = WeaverEngine::new_with_mock(graph, hnsw);
3280 engine.set_impulse_queue(queue.clone());
3281 engine.start_session("impulse-src", None, None).unwrap();
3282 let _ = queue.drain_ready(); engine.add_source("impulse-src", "git", None).unwrap();
3284 let impulses = queue.drain_ready();
3285 assert!(impulses.iter().any(|i| i.impulse_type == ImpulseType::Custom(0x33)));
3286 }
3287
3288 #[test]
3291 fn ingest_graph_file_git_history() {
3292 let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
3293 let graph_path = PathBuf::from(&manifest)
3294 .join("../../.weftos/graph/git-history.json");
3295 if !graph_path.exists() {
3296 return;
3298 }
3299 let engine = make_engine();
3300 let result = engine.ingest_graph_file(&graph_path).unwrap();
3301 assert!(result.nodes_added > 0, "should ingest at least one node");
3302 assert_eq!(result.source, "git-history");
3303 assert!(result.embeddings_created > 0, "should create embeddings");
3304 assert!(engine.causal_graph().node_count() > 0);
3306 }
3307
3308 #[test]
3309 fn ingest_graph_file_module_deps() {
3310 let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
3311 let graph_path = PathBuf::from(&manifest)
3312 .join("../../.weftos/graph/module-deps.json");
3313 if !graph_path.exists() {
3314 return;
3315 }
3316 let engine = make_engine();
3317 let result = engine.ingest_graph_file(&graph_path).unwrap();
3318 assert!(result.nodes_added > 0);
3319 assert_eq!(result.source, "module-dependencies");
3320 }
3321
3322 #[test]
3323 fn ingest_graph_file_decisions() {
3324 let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
3325 let graph_path = PathBuf::from(&manifest)
3326 .join("../../.weftos/graph/decisions.json");
3327 if !graph_path.exists() {
3328 return;
3329 }
3330 let engine = make_engine();
3331 let result = engine.ingest_graph_file(&graph_path).unwrap();
3332 assert!(result.nodes_added > 0);
3333 assert_eq!(result.source, "decisions-and-phases");
3334 }
3335
3336 #[test]
3337 fn ingest_graph_creates_edges() {
3338 let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
3339 let graph_path = PathBuf::from(&manifest)
3340 .join("../../.weftos/graph/git-history.json");
3341 if !graph_path.exists() {
3342 return;
3343 }
3344 let engine = make_engine();
3345 let result = engine.ingest_graph_file(&graph_path).unwrap();
3346 assert!(result.edges_added > 0, "git-history graph should have edges");
3347 assert!(engine.causal_graph().edge_count() > 0);
3348 }
3349
3350 #[test]
3351 fn ingest_graph_populates_hnsw() {
3352 let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
3353 let graph_path = PathBuf::from(&manifest)
3354 .join("../../.weftos/graph/module-deps.json");
3355 if !graph_path.exists() {
3356 return;
3357 }
3358 let engine = make_engine();
3359 let result = engine.ingest_graph_file(&graph_path).unwrap();
3360 assert!(result.embeddings_created > 0);
3361 assert!(engine.hnsw().insert_count() > 0);
3362 }
3363
3364 #[test]
3365 fn ingest_nonexistent_file_returns_error() {
3366 let engine = make_engine();
3367 let result = engine.ingest_graph_file(Path::new("/nonexistent/graph.json"));
3368 assert!(result.is_err());
3369 }
3370
3371 #[test]
3372 fn ingest_invalid_json_returns_error() {
3373 let dir = std::env::temp_dir().join("weaver_test_invalid");
3374 std::fs::create_dir_all(&dir).ok();
3375 let path = dir.join("bad.json");
3376 std::fs::write(&path, "not valid json {{{").unwrap();
3377 let engine = make_engine();
3378 let result = engine.ingest_graph_file(&path);
3379 assert!(result.is_err());
3380 std::fs::remove_dir_all(&dir).ok();
3381 }
3382
3383 #[test]
3384 fn ingest_empty_graph_returns_zero_counts() {
3385 let dir = std::env::temp_dir().join("weaver_test_empty");
3386 std::fs::create_dir_all(&dir).ok();
3387 let path = dir.join("empty.json");
3388 std::fs::write(
3389 &path,
3390 r#"{"source":"test","nodes":[],"edges":[]}"#,
3391 )
3392 .unwrap();
3393 let engine = make_engine();
3394 let result = engine.ingest_graph_file(&path).unwrap();
3395 assert_eq!(result.nodes_added, 0);
3396 assert_eq!(result.edges_added, 0);
3397 assert_eq!(result.embeddings_created, 0);
3398 assert_eq!(result.source, "test");
3399 std::fs::remove_dir_all(&dir).ok();
3400 }
3401
3402 #[test]
3405 fn compute_confidence_empty_graph() {
3406 let engine = make_engine();
3407 let report = engine.compute_confidence();
3408 assert_eq!(report.overall, 0.0);
3409 assert!(!report.gaps.is_empty(), "should have gaps with empty graph");
3410 assert!(
3411 !report.suggestions.is_empty(),
3412 "should have suggestions with empty graph"
3413 );
3414 }
3415
3416 #[test]
3417 fn compute_confidence_with_nodes_only() {
3418 let engine = make_engine();
3419 for i in 0..10 {
3421 engine.causal_graph().add_node(
3422 format!("node-{i}"),
3423 serde_json::json!({"test": true}),
3424 );
3425 }
3426 let report = engine.compute_confidence();
3427 assert!(report.overall > 0.0, "should have some confidence from volume");
3429 assert!(report.overall < 0.5, "should be low without edges");
3430 }
3431
3432 #[test]
3433 fn compute_confidence_with_connected_graph() {
3434 let engine = make_engine();
3435 let n1 = engine.causal_graph().add_node(
3437 "module-a".into(),
3438 serde_json::json!({}),
3439 );
3440 let n2 = engine.causal_graph().add_node(
3441 "module-b".into(),
3442 serde_json::json!({}),
3443 );
3444 let n3 = engine.causal_graph().add_node(
3445 "module-c".into(),
3446 serde_json::json!({}),
3447 );
3448 engine.causal_graph().link(n1, n2, CausalEdgeType::Enables, 1.0, 0, 0);
3449 engine.causal_graph().link(n2, n3, CausalEdgeType::Causes, 0.8, 0, 0);
3450
3451 let report = engine.compute_confidence();
3452 assert!(report.overall > 0.0);
3454 }
3455
3456 #[test]
3457 fn compute_confidence_detects_orphans() {
3458 let engine = make_engine();
3459 let n1 = engine.causal_graph().add_node(
3460 "connected-a".into(),
3461 serde_json::json!({}),
3462 );
3463 let n2 = engine.causal_graph().add_node(
3464 "connected-b".into(),
3465 serde_json::json!({}),
3466 );
3467 engine.causal_graph().add_node(
3468 "orphan-x".into(),
3469 serde_json::json!({}),
3470 );
3471 engine.causal_graph().link(n1, n2, CausalEdgeType::Follows, 1.0, 0, 0);
3472
3473 let report = engine.compute_confidence();
3474 let has_orphan_suggestion = report.suggestions.iter().any(|s| match s {
3476 ModelingSuggestion::AddSource { reason, .. } => reason.contains("orphan"),
3477 _ => false,
3478 });
3479 assert!(has_orphan_suggestion, "should detect the orphan node");
3480 }
3481
3482 #[test]
3483 fn compute_confidence_improves_with_more_data() {
3484 let engine = make_engine();
3485 let n1 = engine.causal_graph().add_node("a".into(), serde_json::json!({}));
3487 let n2 = engine.causal_graph().add_node("b".into(), serde_json::json!({}));
3488 engine.causal_graph().link(n1, n2, CausalEdgeType::Enables, 1.0, 0, 0);
3489 let c1 = engine.compute_confidence().overall;
3490
3491 for i in 0..50 {
3493 let na = engine.causal_graph().add_node(format!("extra-{i}"), serde_json::json!({}));
3494 engine.causal_graph().link(n1, na, CausalEdgeType::Correlates, 0.5, 0, 0);
3495 }
3496 let c2 = engine.compute_confidence().overall;
3497 assert!(c2 > c1, "confidence should increase with more data ({c2} > {c1})");
3498 }
3499
3500 #[test]
3503 fn export_model_includes_causal_data() {
3504 let engine = make_engine();
3505 engine.start_session("exp-causal", None, None).unwrap();
3506 let n1 = engine.causal_graph().add_node("node-a".into(), serde_json::json!({}));
3508 let n2 = engine.causal_graph().add_node("node-b".into(), serde_json::json!({}));
3509 engine.causal_graph().link(n1, n2, CausalEdgeType::Causes, 0.9, 0, 0);
3510
3511 let model = engine.export_model("exp-causal", 0.0).unwrap();
3512 assert!(!model.causal_nodes.is_empty(), "exported model should have nodes");
3513 assert!(!model.causal_edges.is_empty(), "exported model should have edges");
3514 }
3515
3516 #[test]
3517 fn export_model_to_file_roundtrip() {
3518 let dir = std::env::temp_dir().join("weaver_test_export");
3519 std::fs::create_dir_all(&dir).ok();
3520 let path = dir.join("test-model.json");
3521
3522 let engine = make_engine();
3523 engine.start_session("roundtrip", None, None).unwrap();
3524 engine.add_source("roundtrip", "git_log", None).unwrap();
3525
3526 let n1 = engine.causal_graph().add_node("rt-a".into(), serde_json::json!({}));
3528 let n2 = engine.causal_graph().add_node("rt-b".into(), serde_json::json!({}));
3529 engine.causal_graph().link(n1, n2, CausalEdgeType::Enables, 1.0, 0, 0);
3530
3531 let exported = engine.export_model_to_file("roundtrip", 0.0, &path).unwrap();
3533 assert!(path.exists(), "export file should exist");
3534
3535 let data = std::fs::read_to_string(&path).unwrap();
3537 let reimported: ExportedModel = serde_json::from_str(&data).unwrap();
3538 assert_eq!(reimported.domain, "roundtrip");
3539 assert_eq!(reimported.version, exported.version);
3540 assert_eq!(reimported.causal_nodes.len(), exported.causal_nodes.len());
3541 assert_eq!(reimported.causal_edges.len(), exported.causal_edges.len());
3542
3543 std::fs::remove_dir_all(&dir).ok();
3544 }
3545
3546 #[test]
3547 fn import_model_from_file_works() {
3548 let dir = std::env::temp_dir().join("weaver_test_import");
3549 std::fs::create_dir_all(&dir).ok();
3550 let path = dir.join("import-model.json");
3551
3552 let model = ExportedModel {
3554 version: "1.0".into(),
3555 domain: "file-import".into(),
3556 exported_at: Utc::now(),
3557 confidence: 0.82,
3558 node_types: vec![],
3559 edge_types: vec![],
3560 causal_nodes: vec![ExportedCausalNode {
3561 label: "test-node".into(),
3562 metadata: serde_json::json!({}),
3563 }],
3564 causal_edges: vec![],
3565 metadata: HashMap::new(),
3566 };
3567 let json = serde_json::to_string_pretty(&model).unwrap();
3568 std::fs::write(&path, json).unwrap();
3569
3570 let engine = make_engine();
3571 engine.import_model_from_file("file-import", &path).unwrap();
3572 let session = engine.get_session("file-import").unwrap();
3573 assert_eq!(session.confidence, 0.82);
3574 assert!(session.active);
3575
3576 std::fs::remove_dir_all(&dir).ok();
3577 }
3578
3579 #[test]
3582 fn parse_edge_type_known_types() {
3583 assert_eq!(WeaverEngine::parse_edge_type("Causes"), CausalEdgeType::Causes);
3584 assert_eq!(WeaverEngine::parse_edge_type("Enables"), CausalEdgeType::Enables);
3585 assert_eq!(WeaverEngine::parse_edge_type("Follows"), CausalEdgeType::Follows);
3586 assert_eq!(WeaverEngine::parse_edge_type("Correlates"), CausalEdgeType::Correlates);
3587 assert_eq!(WeaverEngine::parse_edge_type("EvidenceFor"), CausalEdgeType::EvidenceFor);
3588 assert_eq!(WeaverEngine::parse_edge_type("Inhibits"), CausalEdgeType::Inhibits);
3589 assert_eq!(WeaverEngine::parse_edge_type("Contradicts"), CausalEdgeType::Contradicts);
3590 assert_eq!(WeaverEngine::parse_edge_type("TriggeredBy"), CausalEdgeType::TriggeredBy);
3591 }
3592
3593 #[test]
3594 fn parse_edge_type_unknown_defaults_to_correlates() {
3595 assert_eq!(WeaverEngine::parse_edge_type("FooBar"), CausalEdgeType::Correlates);
3596 assert_eq!(WeaverEngine::parse_edge_type(""), CausalEdgeType::Correlates);
3597 }
3598
3599 #[test]
3602 fn weaver_error_display() {
3603 let io_err = WeaverError::Io(std::io::Error::new(
3604 std::io::ErrorKind::NotFound,
3605 "file missing",
3606 ));
3607 assert!(io_err.to_string().contains("I/O"));
3608
3609 let domain_err = WeaverError::Domain("test failure".to_string());
3610 assert!(domain_err.to_string().contains("test failure"));
3611 }
3612
3613 fn make_engine_mut() -> WeaverEngine {
3616 let graph = Arc::new(CausalGraph::new());
3617 let hnsw = Arc::new(HnswService::new(HnswServiceConfig::default()));
3618 WeaverEngine::new_with_mock(graph, hnsw)
3619 }
3620
3621 #[test]
3622 fn on_tick_respects_budget() {
3623 let mut engine = make_engine_mut();
3624 engine.start_session("tick-budget", None, None).unwrap();
3625 let result = engine.on_tick(500); assert!(
3627 result.within_budget,
3628 "tick should complete within 500ms budget"
3629 );
3630 assert!(result.elapsed_ms <= 500, "elapsed should be within budget");
3631 }
3632
3633 #[test]
3634 fn on_tick_returns_correct_fields() {
3635 let mut engine = make_engine_mut();
3636 engine.start_session("tick-fields", None, None).unwrap();
3637 let result = engine.on_tick(100);
3638 assert_eq!(result.budget_ms, 100);
3639 assert_eq!(result.tick_number, 0); assert_eq!(result.git_commits_found, 0);
3642 assert_eq!(result.files_changed, 0);
3643 }
3644
3645 #[test]
3646 fn on_tick_increments_tick_count() {
3647 let mut engine = make_engine_mut();
3648 engine.start_session("tick-count", None, None).unwrap();
3649 engine.on_tick(100);
3650 engine.on_tick(100);
3651 engine.on_tick(100);
3652 assert!(engine.total_ticks() >= 3, "should have at least 3 ticks");
3655 }
3656
3657 #[test]
3658 fn on_tick_confidence_update_after_100_ticks() {
3659 let mut engine = make_engine_mut();
3660 engine.start_session("conf-update", None, None).unwrap();
3661 engine.ticks_since_confidence_update = 101;
3663 let result = engine.on_tick(1000);
3664 assert!(
3665 result.confidence_updated,
3666 "confidence should be updated after 100+ ticks"
3667 );
3668 assert!(
3669 engine.cached_confidence().is_some(),
3670 "cached confidence should be set"
3671 );
3672 assert_eq!(
3673 engine.ticks_since_confidence_update, 1,
3674 "counter should reset to 1 (incremented after reset)"
3675 );
3676 }
3677
3678 #[test]
3679 fn on_tick_no_confidence_update_before_100_ticks() {
3680 let mut engine = make_engine_mut();
3681 engine.start_session("no-conf-update", None, None).unwrap();
3682 let result = engine.on_tick(100);
3683 assert!(
3684 !result.confidence_updated,
3685 "should not update confidence on first tick"
3686 );
3687 }
3688
3689 #[test]
3690 fn on_tick_git_and_file_watcher_disabled_by_default() {
3691 let engine = make_engine_mut();
3692 assert!(
3693 engine.git_poller().is_none(),
3694 "git poller should be None by default"
3695 );
3696 assert!(
3697 engine.file_watcher().is_none(),
3698 "file watcher should be None by default"
3699 );
3700 }
3701
3702 #[test]
3705 fn git_poller_poll_detects_commits_in_real_repo() {
3706 let manifest =
3708 std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
3709 let repo_path = PathBuf::from(&manifest).join("../..");
3710 if !repo_path.join(".git").exists() {
3711 return; }
3713 let mut poller = GitPoller::new(repo_path, "HEAD".to_string());
3714 let count = poller.poll();
3716 assert!(count >= 1, "first poll should find at least 1 commit");
3717 assert!(poller.last_hash().is_some(), "last hash should be set");
3718 }
3719
3720 #[test]
3721 fn git_poller_poll_returns_zero_on_no_changes() {
3722 let manifest =
3723 std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
3724 let repo_path = PathBuf::from(&manifest).join("../..");
3725 if !repo_path.join(".git").exists() {
3726 return;
3727 }
3728 let mut poller = GitPoller::new(repo_path, "HEAD".to_string());
3729 poller.poll(); let count = poller.poll(); assert_eq!(count, 0, "second poll should find 0 new commits");
3732 }
3733
3734 #[test]
3735 fn git_poller_disabled_returns_zero() {
3736 let mut poller = GitPoller::new(PathBuf::from("/tmp"), "main".to_string());
3737 poller.set_enabled(false);
3738 assert_eq!(poller.poll(), 0);
3739 assert!(!poller.is_enabled());
3740 }
3741
3742 #[test]
3743 fn git_poller_nonexistent_repo_returns_zero() {
3744 let mut poller = GitPoller::new(
3745 PathBuf::from("/nonexistent/path/to/repo"),
3746 "main".to_string(),
3747 );
3748 let count = poller.poll();
3749 assert_eq!(count, 0, "should return 0 for nonexistent repo");
3750 assert!(poller.last_hash().is_none());
3751 }
3752
3753 #[test]
3754 fn git_poller_branch_accessor() {
3755 let poller = GitPoller::new(PathBuf::from("/tmp"), "develop".to_string());
3756 assert_eq!(poller.branch(), "develop");
3757 }
3758
3759 #[test]
3762 fn file_watcher_watch_and_poll_detects_mtime_change() {
3763 let dir = std::env::temp_dir().join("weaver_fw_test_mtime");
3764 std::fs::create_dir_all(&dir).ok();
3765 let path = dir.join("test.rs");
3766 std::fs::write(&path, "fn main() {}").unwrap();
3767
3768 let mut watcher = FileWatcher::new(dir.clone(), vec!["*.rs".to_string()]);
3769 watcher.watch(path.clone());
3770 assert_eq!(watcher.watched_count(), 1);
3771
3772 let changed = watcher.poll_changes();
3774 assert!(changed.is_empty(), "no changes on first poll");
3775
3776 std::thread::sleep(std::time::Duration::from_millis(50));
3778 std::fs::write(&path, "fn main() { println!(\"changed\"); }").unwrap();
3779
3780 let changed = watcher.poll_changes();
3781 assert_eq!(changed.len(), 1, "should detect the changed file");
3782 assert_eq!(changed[0], path);
3783
3784 std::fs::remove_dir_all(&dir).ok();
3785 }
3786
3787 #[test]
3788 fn file_watcher_watch_directory_registers_files() {
3789 let dir = std::env::temp_dir().join("weaver_fw_test_dir");
3790 std::fs::create_dir_all(&dir).ok();
3791 std::fs::write(dir.join("lib.rs"), "// lib").unwrap();
3792 std::fs::write(dir.join("main.rs"), "// main").unwrap();
3793 std::fs::write(dir.join("readme.md"), "# readme").unwrap();
3794
3795 let mut watcher = FileWatcher::new(dir.clone(), vec!["*.rs".to_string()]);
3796 watcher.watch_directory();
3797 assert_eq!(watcher.watched_count(), 2, "should only watch .rs files");
3798
3799 std::fs::remove_dir_all(&dir).ok();
3800 }
3801
3802 #[test]
3803 fn file_watcher_detects_deleted_file() {
3804 let dir = std::env::temp_dir().join("weaver_fw_test_delete");
3805 std::fs::create_dir_all(&dir).ok();
3806 let path = dir.join("temp.rs");
3807 std::fs::write(&path, "// temp").unwrap();
3808
3809 let mut watcher = FileWatcher::new(dir.clone(), vec!["*.rs".to_string()]);
3810 watcher.watch(path.clone());
3811
3812 std::fs::remove_file(&path).unwrap();
3814 let changed = watcher.poll_changes();
3815 assert_eq!(changed.len(), 1, "should detect deleted file");
3816 assert_eq!(watcher.watched_count(), 0, "deleted file should be unregistered");
3817
3818 std::fs::remove_dir_all(&dir).ok();
3819 }
3820
3821 #[test]
3822 fn file_watcher_disabled_returns_empty() {
3823 let mut watcher = FileWatcher::new(
3824 PathBuf::from("/tmp"),
3825 vec!["*.rs".to_string()],
3826 );
3827 watcher.set_enabled(false);
3828 assert!(watcher.poll_changes().is_empty());
3829 assert!(!watcher.is_enabled());
3830 }
3831
3832 #[test]
3835 fn enable_git_polling_sets_poller() {
3836 let mut engine = make_engine_mut();
3837 assert!(engine.git_poller().is_none());
3838 engine.enable_git_polling(PathBuf::from("/tmp"), "main".to_string());
3839 assert!(engine.git_poller().is_some());
3840 assert_eq!(engine.git_poller().unwrap().branch(), "main");
3841 }
3842
3843 #[test]
3844 fn enable_file_watching_sets_watcher() {
3845 let dir = std::env::temp_dir().join("weaver_fw_test_enable");
3846 std::fs::create_dir_all(&dir).ok();
3847 std::fs::write(dir.join("test.rs"), "// test").unwrap();
3848
3849 let mut engine = make_engine_mut();
3850 assert!(engine.file_watcher().is_none());
3851 engine.enable_file_watching(dir.clone(), vec!["*.rs".to_string()]);
3852 assert!(engine.file_watcher().is_some());
3853 assert_eq!(engine.file_watcher().unwrap().watched_count(), 1);
3854
3855 std::fs::remove_dir_all(&dir).ok();
3856 }
3857
3858 #[test]
3859 fn on_tick_with_git_polling_enabled() {
3860 let manifest =
3861 std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
3862 let repo_path = PathBuf::from(&manifest).join("../..");
3863 if !repo_path.join(".git").exists() {
3864 return;
3865 }
3866 let mut engine = make_engine_mut();
3867 engine.start_session("git-tick", None, None).unwrap();
3868 engine.enable_git_polling(repo_path, "HEAD".to_string());
3869 let result = engine.on_tick(500);
3870 assert!(
3872 result.git_commits_found >= 1,
3873 "first on_tick with git polling should find commits"
3874 );
3875 }
3876
3877 #[test]
3878 fn cognitive_tick_result_default() {
3879 let result = CognitiveTickResult::default();
3880 assert_eq!(result.tick_number, 0);
3881 assert_eq!(result.elapsed_ms, 0);
3882 assert_eq!(result.budget_ms, 0);
3883 assert_eq!(result.git_commits_found, 0);
3884 assert_eq!(result.files_changed, 0);
3885 assert_eq!(result.nodes_processed, 0);
3886 assert!(!result.confidence_updated);
3887 assert!(!result.within_budget);
3888 }
3889
3890 #[test]
3891 fn cognitive_tick_result_serde_roundtrip() {
3892 let result = CognitiveTickResult {
3893 tick_number: 42,
3894 elapsed_ms: 15,
3895 budget_ms: 50,
3896 git_commits_found: 3,
3897 files_changed: 2,
3898 nodes_processed: 10,
3899 confidence_updated: true,
3900 within_budget: true,
3901 };
3902 let json = serde_json::to_string(&result).unwrap();
3903 let restored: CognitiveTickResult = serde_json::from_str(&json).unwrap();
3904 assert_eq!(restored.tick_number, 42);
3905 assert_eq!(restored.git_commits_found, 3);
3906 assert!(restored.confidence_updated);
3907 }
3908
3909 #[test]
3912 fn confidence_history_record_and_latest() {
3913 let mut history = ConfidenceHistory::new(10);
3914 assert!(history.is_empty());
3915 assert!(history.latest().is_none());
3916
3917 history.record(ConfidenceSnapshot {
3918 timestamp: Utc::now(),
3919 tick_number: 1,
3920 confidence: 0.5,
3921 node_count: 10,
3922 edge_count: 5,
3923 gap_count: 2,
3924 trigger: ConfidenceTrigger::Periodic,
3925 });
3926 assert_eq!(history.len(), 1);
3927 assert_eq!(history.latest().unwrap().confidence, 0.5);
3928 }
3929
3930 #[test]
3931 fn confidence_history_ring_buffer_eviction() {
3932 let mut history = ConfidenceHistory::new(3);
3933 for i in 0..5 {
3934 history.record(ConfidenceSnapshot {
3935 timestamp: Utc::now(),
3936 tick_number: i,
3937 confidence: i as f64 * 0.1,
3938 node_count: 0,
3939 edge_count: 0,
3940 gap_count: 0,
3941 trigger: ConfidenceTrigger::Periodic,
3942 });
3943 }
3944 assert_eq!(history.len(), 3);
3945 assert_eq!(history.all().front().unwrap().tick_number, 2);
3947 assert_eq!(history.latest().unwrap().tick_number, 4);
3948 }
3949
3950 #[test]
3951 fn confidence_history_trend_improving() {
3952 let mut history = ConfidenceHistory::new(10);
3953 for i in 0..5 {
3954 history.record(ConfidenceSnapshot {
3955 timestamp: Utc::now(),
3956 tick_number: i,
3957 confidence: 0.3 + i as f64 * 0.1,
3958 node_count: 0,
3959 edge_count: 0,
3960 gap_count: 0,
3961 trigger: ConfidenceTrigger::Periodic,
3962 });
3963 }
3964 let trend = history.trend(5);
3965 assert_eq!(trend.direction, TrendDirection::Improving);
3966 assert!(trend.delta > 0.01);
3967 assert_eq!(trend.samples, 5);
3968 }
3969
3970 #[test]
3971 fn confidence_history_trend_declining() {
3972 let mut history = ConfidenceHistory::new(10);
3973 for i in 0..4 {
3974 history.record(ConfidenceSnapshot {
3975 timestamp: Utc::now(),
3976 tick_number: i,
3977 confidence: 0.8 - i as f64 * 0.1,
3978 node_count: 0,
3979 edge_count: 0,
3980 gap_count: 0,
3981 trigger: ConfidenceTrigger::Periodic,
3982 });
3983 }
3984 let trend = history.trend(4);
3985 assert_eq!(trend.direction, TrendDirection::Declining);
3986 assert!(trend.delta < -0.01);
3987 }
3988
3989 #[test]
3990 fn confidence_history_trend_stable() {
3991 let mut history = ConfidenceHistory::new(10);
3992 for i in 0..5 {
3993 history.record(ConfidenceSnapshot {
3994 timestamp: Utc::now(),
3995 tick_number: i,
3996 confidence: 0.75,
3997 node_count: 0,
3998 edge_count: 0,
3999 gap_count: 0,
4000 trigger: ConfidenceTrigger::Periodic,
4001 });
4002 }
4003 let trend = history.trend(5);
4004 assert_eq!(trend.direction, TrendDirection::Stable);
4005 assert!(trend.delta.abs() <= 0.01);
4006 assert!((trend.avg_confidence - 0.75).abs() < 0.001);
4007 }
4008
4009 #[test]
4010 fn confidence_history_trend_empty() {
4011 let history = ConfidenceHistory::new(10);
4012 let trend = history.trend(5);
4013 assert_eq!(trend.direction, TrendDirection::Stable);
4014 assert_eq!(trend.samples, 0);
4015 }
4016
4017 #[test]
4018 fn confidence_history_trend_window_clamp() {
4019 let mut history = ConfidenceHistory::new(10);
4020 history.record(ConfidenceSnapshot {
4021 timestamp: Utc::now(),
4022 tick_number: 0,
4023 confidence: 0.5,
4024 node_count: 0,
4025 edge_count: 0,
4026 gap_count: 0,
4027 trigger: ConfidenceTrigger::Manual,
4028 });
4029 let trend = history.trend(100);
4031 assert_eq!(trend.samples, 1);
4032 }
4033
4034 #[test]
4037 fn strategy_tracker_begin_complete() {
4038 let mut tracker = StrategyTracker::new(50);
4039 assert!(tracker.is_empty());
4040 let handle = tracker.begin_strategy("add_git", "Add git log", 0.4);
4041 tracker.complete_strategy(handle, 0.55);
4042 assert_eq!(tracker.len(), 1);
4043 let outcome = &tracker.outcomes()[0];
4044 assert_eq!(outcome.strategy, "add_git");
4045 assert!((outcome.delta - 0.15).abs() < 0.001);
4046 assert!(outcome.beneficial);
4047 }
4048
4049 #[test]
4050 fn strategy_tracker_most_effective() {
4051 let mut tracker = StrategyTracker::new(50);
4052 let h1 = tracker.begin_strategy("a", "desc a", 0.3);
4053 tracker.complete_strategy(h1, 0.5); let h2 = tracker.begin_strategy("b", "desc b", 0.5);
4055 tracker.complete_strategy(h2, 0.9); let h3 = tracker.begin_strategy("c", "desc c", 0.9);
4057 tracker.complete_strategy(h3, 0.85); let top = tracker.most_effective(2);
4060 assert_eq!(top.len(), 2);
4061 assert_eq!(top[0].strategy, "b"); assert_eq!(top[1].strategy, "a");
4063 }
4064
4065 #[test]
4066 fn strategy_tracker_harmful_strategies() {
4067 let mut tracker = StrategyTracker::new(50);
4068 let h1 = tracker.begin_strategy("good", "good move", 0.5);
4069 tracker.complete_strategy(h1, 0.7);
4070 let h2 = tracker.begin_strategy("bad", "bad move", 0.7);
4071 tracker.complete_strategy(h2, 0.5);
4072
4073 let harmful = tracker.harmful_strategies();
4074 assert_eq!(harmful.len(), 1);
4075 assert_eq!(harmful[0].strategy, "bad");
4076 assert!(!harmful[0].beneficial);
4077 }
4078
4079 #[test]
4080 fn strategy_tracker_recommend() {
4081 let mut tracker = StrategyTracker::new(50);
4082 assert!(tracker.recommend().is_none());
4083
4084 let h1 = tracker.begin_strategy("small_win", "desc", 0.5);
4085 tracker.complete_strategy(h1, 0.55);
4086 let h2 = tracker.begin_strategy("big_win", "desc", 0.55);
4087 tracker.complete_strategy(h2, 0.85);
4088
4089 assert_eq!(tracker.recommend(), Some("big_win".to_string()));
4090 }
4091
4092 #[test]
4093 fn strategy_tracker_recommend_ignores_harmful() {
4094 let mut tracker = StrategyTracker::new(50);
4095 let h = tracker.begin_strategy("harmful", "desc", 0.5);
4096 tracker.complete_strategy(h, 0.3);
4097 assert!(tracker.recommend().is_none());
4099 }
4100
4101 #[test]
4102 fn strategy_tracker_eviction() {
4103 let mut tracker = StrategyTracker::new(2);
4104 let h1 = tracker.begin_strategy("first", "d", 0.1);
4105 tracker.complete_strategy(h1, 0.2);
4106 let h2 = tracker.begin_strategy("second", "d", 0.2);
4107 tracker.complete_strategy(h2, 0.3);
4108 let h3 = tracker.begin_strategy("third", "d", 0.3);
4109 tracker.complete_strategy(h3, 0.4);
4110
4111 assert_eq!(tracker.len(), 2);
4112 assert_eq!(tracker.outcomes()[0].strategy, "second");
4113 assert_eq!(tracker.outcomes()[1].strategy, "third");
4114 }
4115
4116 #[test]
4119 fn tick_history_record_and_len() {
4120 let mut history = TickHistory::new(10);
4121 assert!(history.is_empty());
4122 history.record(CognitiveTickResult {
4123 tick_number: 0,
4124 elapsed_ms: 10,
4125 budget_ms: 50,
4126 ..Default::default()
4127 });
4128 assert_eq!(history.len(), 1);
4129 }
4130
4131 #[test]
4132 fn tick_history_eviction() {
4133 let mut history = TickHistory::new(3);
4134 for i in 0..5 {
4135 history.record(CognitiveTickResult {
4136 tick_number: i,
4137 elapsed_ms: 10,
4138 budget_ms: 50,
4139 ..Default::default()
4140 });
4141 }
4142 assert_eq!(history.len(), 3);
4143 assert_eq!(history.all().front().unwrap().tick_number, 2);
4144 }
4145
4146 #[test]
4147 fn tick_history_changes_per_minute() {
4148 let mut history = TickHistory::new(100);
4149 for i in 0..10 {
4151 history.record(CognitiveTickResult {
4152 tick_number: i,
4153 elapsed_ms: 100,
4154 budget_ms: 200,
4155 git_commits_found: 1,
4156 files_changed: 0,
4157 ..Default::default()
4158 });
4159 }
4160 let cpm = history.changes_per_minute();
4163 assert!(cpm > 100.0, "expected high cpm, got {cpm}");
4164 }
4165
4166 #[test]
4167 fn tick_history_changes_per_minute_no_changes() {
4168 let mut history = TickHistory::new(100);
4169 for i in 0..10 {
4170 history.record(CognitiveTickResult {
4171 tick_number: i,
4172 elapsed_ms: 100,
4173 budget_ms: 200,
4174 ..Default::default()
4175 });
4176 }
4177 assert_eq!(history.changes_per_minute(), 0.0);
4178 }
4179
4180 #[test]
4181 fn tick_history_changes_per_minute_insufficient_data() {
4182 let history = TickHistory::new(10);
4183 assert_eq!(history.changes_per_minute(), 0.0);
4184
4185 let mut history2 = TickHistory::new(10);
4186 history2.record(CognitiveTickResult::default());
4187 assert_eq!(history2.changes_per_minute(), 0.0);
4188 }
4189
4190 #[test]
4191 fn tick_history_avg_budget_usage() {
4192 let mut history = TickHistory::new(100);
4193 for i in 0..5 {
4195 history.record(CognitiveTickResult {
4196 tick_number: i,
4197 elapsed_ms: 50,
4198 budget_ms: 100,
4199 ..Default::default()
4200 });
4201 }
4202 let usage = history.avg_budget_usage();
4203 assert!((usage - 0.5).abs() < 0.01);
4204 }
4205
4206 #[test]
4207 fn tick_history_avg_budget_usage_empty() {
4208 let history = TickHistory::new(10);
4209 assert_eq!(history.avg_budget_usage(), 0.0);
4210 }
4211
4212 #[test]
4213 fn tick_history_idle_ticks() {
4214 let mut history = TickHistory::new(100);
4215 for i in 0..3 {
4217 history.record(CognitiveTickResult {
4218 tick_number: i,
4219 git_commits_found: 1,
4220 ..Default::default()
4221 });
4222 }
4223 for i in 3..8 {
4224 history.record(CognitiveTickResult {
4225 tick_number: i,
4226 ..Default::default()
4227 });
4228 }
4229 assert_eq!(history.idle_ticks(), 5);
4230 }
4231
4232 #[test]
4233 fn tick_history_idle_ticks_none_idle() {
4234 let mut history = TickHistory::new(100);
4235 history.record(CognitiveTickResult {
4236 tick_number: 0,
4237 files_changed: 1,
4238 ..Default::default()
4239 });
4240 assert_eq!(history.idle_ticks(), 0);
4241 }
4242
4243 #[test]
4246 fn tick_recommend_insufficient_data() {
4247 let engine = make_engine_mut();
4248 let rec = engine.recommend_tick_interval();
4249 assert_eq!(rec.recommended_ms, engine.current_tick_interval_ms);
4250 assert!(rec.reason.contains("Insufficient"));
4251 assert!(rec.recommendation_confidence < 0.5);
4252 }
4253
4254 #[test]
4255 fn tick_recommend_idle_mode() {
4256 let mut engine = make_engine_mut();
4257 for i in 0..110 {
4259 engine.tick_history.record(CognitiveTickResult {
4260 tick_number: i,
4261 elapsed_ms: 10,
4262 budget_ms: 100,
4263 ..Default::default()
4264 });
4265 }
4266 let rec = engine.recommend_tick_interval();
4267 assert_eq!(rec.recommended_ms, 5000);
4268 assert!(rec.reason.contains("idle"));
4269 }
4270
4271 #[test]
4272 fn tick_recommend_high_frequency() {
4273 let mut engine = make_engine_mut();
4274 for i in 0..20 {
4276 engine.tick_history.record(CognitiveTickResult {
4277 tick_number: i,
4278 elapsed_ms: 100,
4279 budget_ms: 200,
4280 git_commits_found: 5,
4281 ..Default::default()
4282 });
4283 }
4284 let rec = engine.recommend_tick_interval();
4285 assert_eq!(rec.recommended_ms, 200);
4286 assert!(rec.changes_per_minute > 10.0);
4287 }
4288
4289 #[test]
4290 fn tick_recommend_low_frequency() {
4291 let mut engine = make_engine_mut();
4292 for i in 0..20 {
4294 engine.tick_history.record(CognitiveTickResult {
4295 tick_number: i,
4296 elapsed_ms: 6000,
4297 budget_ms: 10000,
4298 git_commits_found: if i == 0 { 1 } else { 0 },
4299 ..Default::default()
4300 });
4301 }
4302 let rec = engine.recommend_tick_interval();
4303 assert_eq!(rec.recommended_ms, 3000);
4304 assert!(rec.changes_per_minute < 1.0);
4305 }
4306
4307 #[test]
4308 fn tick_recommend_moderate_frequency() {
4309 let mut engine = make_engine_mut();
4310 for i in 0..10 {
4313 engine.tick_history.record(CognitiveTickResult {
4314 tick_number: i,
4315 elapsed_ms: 10000,
4316 budget_ms: 15000,
4317 git_commits_found: if i % 5 == 0 { 1 } else { 0 },
4318 files_changed: if i % 3 == 0 { 1 } else { 0 },
4319 ..Default::default()
4320 });
4321 }
4322 let rec = engine.recommend_tick_interval();
4323 assert!(
4325 rec.recommended_ms == 1000,
4326 "expected 1000ms for moderate, got {}ms (cpm={:.2})",
4327 rec.recommended_ms,
4328 rec.changes_per_minute
4329 );
4330 }
4331
4332 #[test]
4335 fn on_tick_records_tick_history() {
4336 let mut engine = make_engine_mut();
4337 engine.start_session("hist", None, None).unwrap();
4338 engine.on_tick(100);
4339 engine.on_tick(100);
4340 assert_eq!(engine.tick_history().len(), 2);
4341 }
4342
4343 #[test]
4346 fn on_tick_records_confidence_snapshot_on_periodic() {
4347 let mut engine = make_engine_mut();
4348 engine.start_session("snap", None, None).unwrap();
4349 engine.ticks_since_confidence_update = 101;
4350 engine.on_tick(1000);
4351 assert!(
4352 !engine.confidence_history().is_empty(),
4353 "should have recorded a confidence snapshot"
4354 );
4355 let snap = engine.confidence_history().latest().unwrap();
4356 assert!(matches!(snap.trigger, ConfidenceTrigger::Periodic));
4357 }
4358
4359 #[test]
4362 fn ingest_graph_file_tracked_records_strategy_and_snapshot() {
4363 let dir = std::env::temp_dir().join("weaver_test_tracked");
4364 std::fs::create_dir_all(&dir).ok();
4365 let path = dir.join("small.json");
4366 std::fs::write(
4367 &path,
4368 r#"{"source":"test","nodes":[{"id":"n1","title":"Node 1"}],"edges":[]}"#,
4369 )
4370 .unwrap();
4371
4372 let mut engine = make_engine_mut();
4373 let result = engine.ingest_graph_file_tracked(&path).unwrap();
4374 assert_eq!(result.nodes_added, 1);
4375
4376 assert_eq!(engine.strategy_tracker().len(), 1);
4378 assert_eq!(engine.strategy_tracker().outcomes()[0].strategy, "ingest:small");
4379
4380 assert!(!engine.confidence_history().is_empty());
4382 let has_post_ingestion = engine
4383 .confidence_history()
4384 .all()
4385 .iter()
4386 .any(|s| matches!(s.trigger, ConfidenceTrigger::PostIngestion));
4387 assert!(has_post_ingestion);
4388
4389 std::fs::remove_dir_all(&dir).ok();
4390 }
4391
4392 #[test]
4395 fn confidence_snapshot_serde_roundtrip() {
4396 let snap = ConfidenceSnapshot {
4397 timestamp: Utc::now(),
4398 tick_number: 42,
4399 confidence: 0.78,
4400 node_count: 100,
4401 edge_count: 200,
4402 gap_count: 3,
4403 trigger: ConfidenceTrigger::PostIngestion,
4404 };
4405 let json = serde_json::to_string(&snap).unwrap();
4406 let restored: ConfidenceSnapshot = serde_json::from_str(&json).unwrap();
4407 assert_eq!(restored.tick_number, 42);
4408 assert!((restored.confidence - 0.78).abs() < 0.001);
4409 }
4410
4411 #[test]
4412 fn strategy_outcome_serde_roundtrip() {
4413 let outcome = StrategyOutcome {
4414 strategy: "add_git".to_string(),
4415 description: "desc".to_string(),
4416 confidence_before: 0.4,
4417 confidence_after: 0.6,
4418 delta: 0.2,
4419 timestamp: Utc::now(),
4420 beneficial: true,
4421 };
4422 let json = serde_json::to_string(&outcome).unwrap();
4423 let restored: StrategyOutcome = serde_json::from_str(&json).unwrap();
4424 assert_eq!(restored.strategy, "add_git");
4425 assert!(restored.beneficial);
4426 }
4427
4428 #[test]
4429 fn tick_recommendation_serde_roundtrip() {
4430 let rec = TickRecommendation {
4431 recommended_ms: 200,
4432 current_ms: 1000,
4433 reason: "fast".to_string(),
4434 changes_per_minute: 50.0,
4435 recommendation_confidence: 0.8,
4436 };
4437 let json = serde_json::to_string(&rec).unwrap();
4438 let restored: TickRecommendation = serde_json::from_str(&json).unwrap();
4439 assert_eq!(restored.recommended_ms, 200);
4440 }
4441
4442 fn make_model(domain: &str, confidence: f64) -> ExportedModel {
4445 ExportedModel {
4446 version: "1.0".into(),
4447 domain: domain.into(),
4448 exported_at: Utc::now(),
4449 confidence,
4450 node_types: vec![],
4451 edge_types: vec![],
4452 causal_nodes: vec![],
4453 causal_edges: vec![],
4454 metadata: HashMap::new(),
4455 }
4456 }
4457
4458 #[test]
4459 fn diff_models_identical_produces_empty_diff() {
4460 let a = make_model("alpha", 0.8);
4461 let b = a.clone();
4462 let diff = diff_models(&a, &b);
4463 assert!(diff.nodes_only_a.is_empty());
4464 assert!(diff.nodes_only_b.is_empty());
4465 assert!(diff.edges_only_a.is_empty());
4466 assert!(diff.edges_only_b.is_empty());
4467 assert_eq!(diff.causal_nodes_added, 0);
4468 assert_eq!(diff.causal_nodes_removed, 0);
4469 assert_eq!(diff.causal_edges_added, 0);
4470 assert_eq!(diff.causal_edges_removed, 0);
4471 assert_eq!(diff.summary, "models are identical");
4472 }
4473
4474 #[test]
4475 fn diff_models_different_node_types_detected() {
4476 let mut a = make_model("alpha", 0.5);
4477 a.node_types.push(NodeTypeSpec {
4478 name: "module".into(),
4479 embedding_strategy: "hash".into(),
4480 dimensions: 64,
4481 });
4482 a.node_types.push(NodeTypeSpec {
4483 name: "shared".into(),
4484 embedding_strategy: "hash".into(),
4485 dimensions: 64,
4486 });
4487
4488 let mut b = make_model("beta", 0.5);
4489 b.node_types.push(NodeTypeSpec {
4490 name: "commit".into(),
4491 embedding_strategy: "hash".into(),
4492 dimensions: 64,
4493 });
4494 b.node_types.push(NodeTypeSpec {
4495 name: "shared".into(),
4496 embedding_strategy: "hash".into(),
4497 dimensions: 64,
4498 });
4499
4500 let diff = diff_models(&a, &b);
4501 assert_eq!(diff.nodes_only_a, vec!["module"]);
4502 assert_eq!(diff.nodes_only_b, vec!["commit"]);
4503 assert_eq!(diff.nodes_common, vec!["shared"]);
4504 }
4505
4506 #[test]
4507 fn diff_models_causal_additions_removals_counted() {
4508 let mut a = make_model("alpha", 0.5);
4509 a.causal_nodes.push(ExportedCausalNode {
4510 label: "A".into(),
4511 metadata: serde_json::json!({}),
4512 });
4513 a.causal_nodes.push(ExportedCausalNode {
4514 label: "shared".into(),
4515 metadata: serde_json::json!({}),
4516 });
4517
4518 let mut b = make_model("beta", 0.5);
4519 b.causal_nodes.push(ExportedCausalNode {
4520 label: "B".into(),
4521 metadata: serde_json::json!({}),
4522 });
4523 b.causal_nodes.push(ExportedCausalNode {
4524 label: "shared".into(),
4525 metadata: serde_json::json!({}),
4526 });
4527
4528 let diff = diff_models(&a, &b);
4529 assert_eq!(diff.causal_nodes_added, 1); assert_eq!(diff.causal_nodes_removed, 1); }
4532
4533 #[test]
4534 fn diff_models_causal_edge_changes() {
4535 let mut a = make_model("alpha", 0.5);
4536 a.causal_edges.push(ExportedCausalEdge {
4537 source_label: "X".into(),
4538 target_label: "Y".into(),
4539 edge_type: "Causes".into(),
4540 weight: 1.0,
4541 });
4542
4543 let mut b = make_model("beta", 0.5);
4544 b.causal_edges.push(ExportedCausalEdge {
4545 source_label: "Y".into(),
4546 target_label: "Z".into(),
4547 edge_type: "Enables".into(),
4548 weight: 0.5,
4549 });
4550
4551 let diff = diff_models(&a, &b);
4552 assert_eq!(diff.causal_edges_added, 1);
4553 assert_eq!(diff.causal_edges_removed, 1);
4554 }
4555
4556 #[test]
4557 fn diff_models_confidence_delta_in_summary() {
4558 let a = make_model("alpha", 0.5);
4559 let b = make_model("beta", 0.8);
4560 let diff = diff_models(&a, &b);
4561 assert!((diff.confidence_delta - 0.3).abs() < 1e-10);
4562 assert!(diff.summary.contains("increased"));
4563 }
4564
4565 #[test]
4566 fn diff_models_summary_shows_decrease() {
4567 let a = make_model("alpha", 0.9);
4568 let b = make_model("beta", 0.6);
4569 let diff = diff_models(&a, &b);
4570 assert!(diff.summary.contains("decreased"));
4571 }
4572
4573 #[test]
4574 fn diff_models_edge_type_differences() {
4575 let mut a = make_model("a", 0.5);
4576 a.edge_types.push(EdgeTypeSpec {
4577 from_type: "mod".into(),
4578 to_type: "mod".into(),
4579 edge_type: "uses".into(),
4580 confidence: 0.8,
4581 });
4582
4583 let mut b = make_model("b", 0.5);
4584 b.edge_types.push(EdgeTypeSpec {
4585 from_type: "mod".into(),
4586 to_type: "test".into(),
4587 edge_type: "tests".into(),
4588 confidence: 0.7,
4589 });
4590
4591 let diff = diff_models(&a, &b);
4592 assert_eq!(diff.edges_only_a.len(), 1);
4593 assert_eq!(diff.edges_only_b.len(), 1);
4594 assert!(diff.edges_common.is_empty());
4595 }
4596
4597 #[test]
4600 fn merge_models_disjoint_produces_union() {
4601 let mut a = make_model("alpha", 0.6);
4602 a.node_types.push(NodeTypeSpec {
4603 name: "mod_a".into(),
4604 embedding_strategy: "hash".into(),
4605 dimensions: 64,
4606 });
4607 a.causal_nodes.push(ExportedCausalNode {
4608 label: "A1".into(),
4609 metadata: serde_json::json!({}),
4610 });
4611
4612 let mut b = make_model("beta", 0.8);
4613 b.node_types.push(NodeTypeSpec {
4614 name: "mod_b".into(),
4615 embedding_strategy: "hash".into(),
4616 dimensions: 64,
4617 });
4618 b.causal_nodes.push(ExportedCausalNode {
4619 label: "B1".into(),
4620 metadata: serde_json::json!({}),
4621 });
4622
4623 let result = merge_models(&a, &b);
4624 assert_eq!(result.stats.total_node_types, 2);
4625 assert_eq!(result.stats.total_causal_nodes, 2);
4626 assert_eq!(result.stats.nodes_from_a, 1);
4627 assert_eq!(result.stats.nodes_from_b, 1);
4628 assert_eq!(result.stats.nodes_shared, 0);
4629 assert_eq!(result.conflicts.len(), 0);
4630 }
4631
4632 #[test]
4633 fn merge_models_overlapping_nodes_higher_confidence() {
4634 let mut a = make_model("alpha", 0.6);
4635 a.node_types.push(NodeTypeSpec {
4636 name: "shared".into(),
4637 embedding_strategy: "hash_v1".into(),
4638 dimensions: 64,
4639 });
4640
4641 let mut b = make_model("beta", 0.8);
4642 b.node_types.push(NodeTypeSpec {
4643 name: "shared".into(),
4644 embedding_strategy: "hash_v2".into(),
4645 dimensions: 128,
4646 });
4647
4648 let result = merge_models(&a, &b);
4649 assert_eq!(result.stats.nodes_shared, 1);
4650 assert_eq!(result.conflicts.len(), 1);
4651 assert_eq!(result.conflicts[0].resolution, ConflictResolution::KeepB);
4653 assert_eq!(
4654 result.merged.node_types[0].embedding_strategy,
4655 "hash_v2"
4656 );
4657 }
4658
4659 #[test]
4660 fn merge_models_causal_edges_merged_by_id() {
4661 let mut a = make_model("alpha", 0.5);
4662 a.causal_edges.push(ExportedCausalEdge {
4663 source_label: "X".into(),
4664 target_label: "Y".into(),
4665 edge_type: "Causes".into(),
4666 weight: 1.0,
4667 });
4668
4669 let mut b = make_model("beta", 0.5);
4670 b.causal_edges.push(ExportedCausalEdge {
4671 source_label: "X".into(),
4672 target_label: "Y".into(),
4673 edge_type: "Causes".into(),
4674 weight: 0.5,
4675 });
4676
4677 let result = merge_models(&a, &b);
4678 assert_eq!(result.stats.total_causal_edges, 1);
4679 assert!((result.merged.causal_edges[0].weight - 0.75).abs() < 1e-5);
4681 }
4682
4683 #[test]
4684 fn merge_models_conflict_resolution_recorded() {
4685 let mut a = make_model("alpha", 0.5);
4686 a.edge_types.push(EdgeTypeSpec {
4687 from_type: "m".into(),
4688 to_type: "m".into(),
4689 edge_type: "uses".into(),
4690 confidence: 0.3,
4691 });
4692
4693 let mut b = make_model("beta", 0.5);
4694 b.edge_types.push(EdgeTypeSpec {
4695 from_type: "m".into(),
4696 to_type: "m".into(),
4697 edge_type: "uses".into(),
4698 confidence: 0.9,
4699 });
4700
4701 let result = merge_models(&a, &b);
4702 assert_eq!(result.conflicts.len(), 1);
4703 assert_eq!(
4704 result.conflicts[0].resolution,
4705 ConflictResolution::HigherConfidence
4706 );
4707 assert!((result.merged.edge_types[0].confidence - 0.9).abs() < 1e-10);
4709 }
4710
4711 #[test]
4712 fn merge_models_confidence_is_weighted_average() {
4713 let mut a = make_model("alpha", 0.4);
4714 a.causal_nodes.push(ExportedCausalNode {
4715 label: "A1".into(),
4716 metadata: serde_json::json!({}),
4717 });
4718 let mut b = make_model("beta", 0.8);
4721 b.causal_nodes.push(ExportedCausalNode {
4722 label: "B1".into(),
4723 metadata: serde_json::json!({}),
4724 });
4725 b.causal_nodes.push(ExportedCausalNode {
4726 label: "B2".into(),
4727 metadata: serde_json::json!({}),
4728 });
4729 b.causal_nodes.push(ExportedCausalNode {
4730 label: "B3".into(),
4731 metadata: serde_json::json!({}),
4732 });
4733 let result = merge_models(&a, &b);
4736 assert!((result.merged.confidence - 0.7).abs() < 1e-10);
4738 }
4739
4740 #[test]
4741 fn merge_models_domain_combined() {
4742 let a = make_model("alpha", 0.5);
4743 let b = make_model("beta", 0.5);
4744 let result = merge_models(&a, &b);
4745 assert_eq!(result.merged.domain, "alpha+beta");
4746 }
4747
4748 #[test]
4749 fn merge_models_causal_edge_type_conflict() {
4750 let mut a = make_model("a", 0.5);
4751 a.causal_edges.push(ExportedCausalEdge {
4752 source_label: "X".into(),
4753 target_label: "Y".into(),
4754 edge_type: "Causes".into(),
4755 weight: 1.0,
4756 });
4757
4758 let mut b = make_model("b", 0.5);
4759 b.causal_edges.push(ExportedCausalEdge {
4760 source_label: "X".into(),
4761 target_label: "Y".into(),
4762 edge_type: "Enables".into(),
4763 weight: 0.5,
4764 });
4765
4766 let result = merge_models(&a, &b);
4767 assert!(result.conflicts.iter().any(|c| {
4768 c.item.starts_with("causal_edge:")
4769 && c.resolution == ConflictResolution::Merged
4770 }));
4771 }
4772
4773 #[test]
4776 fn knowledge_base_save_load_roundtrip() {
4777 let dir = std::env::temp_dir().join("weaver_kb_test_roundtrip");
4778 std::fs::create_dir_all(&dir).ok();
4779 let path = dir.join("kb.json");
4780
4781 let kb = WeaverKnowledgeBase::new();
4782 kb.record_strategy(StrategyPattern {
4783 decision_type: "SourceAdded".into(),
4784 context: "rust-project".into(),
4785 improvement: 0.15,
4786 timestamp: Utc::now(),
4787 });
4788 kb.record_strategy(StrategyPattern {
4789 decision_type: "EdgeType".into(),
4790 context: "python-project".into(),
4791 improvement: 0.25,
4792 timestamp: Utc::now(),
4793 });
4794
4795 kb.save_to_file(&path).unwrap();
4796 let loaded = WeaverKnowledgeBase::load_from_file(&path).unwrap();
4797 assert_eq!(loaded.pattern_count(), 2);
4798
4799 let strategies = loaded.list_strategies();
4800 assert!(strategies
4801 .iter()
4802 .any(|s| s.decision_type == "SourceAdded" && s.context == "rust-project"));
4803 assert!(strategies
4804 .iter()
4805 .any(|s| s.decision_type == "EdgeType" && s.context == "python-project"));
4806
4807 std::fs::remove_dir_all(&dir).ok();
4808 }
4809
4810 #[test]
4811 fn knowledge_base_learn_pattern_adds_new() {
4812 let kb = WeaverKnowledgeBase::new();
4813 kb.learn_pattern(StrategyPattern {
4814 decision_type: "SourceAdded".into(),
4815 context: "rust".into(),
4816 improvement: 0.1,
4817 timestamp: Utc::now(),
4818 });
4819 assert_eq!(kb.pattern_count(), 1);
4820
4821 kb.learn_pattern(StrategyPattern {
4822 decision_type: "EdgeType".into(),
4823 context: "python".into(),
4824 improvement: 0.2,
4825 timestamp: Utc::now(),
4826 });
4827 assert_eq!(kb.pattern_count(), 2);
4828 }
4829
4830 #[test]
4831 fn knowledge_base_learn_pattern_updates_existing() {
4832 let kb = WeaverKnowledgeBase::new();
4833 kb.learn_pattern(StrategyPattern {
4834 decision_type: "SourceAdded".into(),
4835 context: "rust".into(),
4836 improvement: 0.1,
4837 timestamp: Utc::now(),
4838 });
4839
4840 kb.learn_pattern(StrategyPattern {
4842 decision_type: "SourceAdded".into(),
4843 context: "rust".into(),
4844 improvement: 0.3,
4845 timestamp: Utc::now(),
4846 });
4847
4848 assert_eq!(kb.pattern_count(), 1);
4849 let strategies = kb.list_strategies();
4850 assert!((strategies[0].improvement - 0.2).abs() < 1e-10);
4852 }
4853
4854 #[test]
4855 fn knowledge_base_find_patterns_returns_matching() {
4856 let kb = WeaverKnowledgeBase::new();
4857 kb.learn_pattern(StrategyPattern {
4858 decision_type: "SourceAdded".into(),
4859 context: "rust-backend".into(),
4860 improvement: 0.1,
4861 timestamp: Utc::now(),
4862 });
4863 kb.learn_pattern(StrategyPattern {
4864 decision_type: "EdgeType".into(),
4865 context: "python-ml".into(),
4866 improvement: 0.2,
4867 timestamp: Utc::now(),
4868 });
4869 kb.learn_pattern(StrategyPattern {
4870 decision_type: "Tick".into(),
4871 context: "go-service".into(),
4872 improvement: 0.15,
4873 timestamp: Utc::now(),
4874 });
4875
4876 let matches = kb.find_patterns(&["rust".to_string()]);
4877 assert_eq!(matches.len(), 1);
4878 assert_eq!(matches[0].context, "rust-backend");
4879 }
4880
4881 #[test]
4882 fn knowledge_base_find_patterns_sorted_by_relevance() {
4883 let kb = WeaverKnowledgeBase::new();
4884 kb.learn_pattern(StrategyPattern {
4885 decision_type: "A".into(),
4886 context: "rust".into(),
4887 improvement: 0.1,
4888 timestamp: Utc::now(),
4889 });
4890 kb.learn_pattern(StrategyPattern {
4891 decision_type: "B".into(),
4892 context: "rust-backend-api".into(),
4893 improvement: 0.2,
4894 timestamp: Utc::now(),
4895 });
4896 kb.learn_pattern(StrategyPattern {
4897 decision_type: "C".into(),
4898 context: "python".into(),
4899 improvement: 0.3,
4900 timestamp: Utc::now(),
4901 });
4902
4903 let matches = kb.find_patterns(&[
4904 "rust".to_string(),
4905 "backend".to_string(),
4906 "api".to_string(),
4907 ]);
4908 assert_eq!(matches.len(), 2);
4912 assert_eq!(matches[0].decision_type, "B");
4913 assert_eq!(matches[1].decision_type, "A");
4914 }
4915
4916 #[test]
4917 fn knowledge_base_empty_handles_gracefully() {
4918 let kb = WeaverKnowledgeBase::new();
4919 assert_eq!(kb.pattern_count(), 0);
4920 assert!(kb.find_patterns(&["rust".to_string()]).is_empty());
4921 assert!(kb.list_strategies().is_empty());
4922
4923 let dir = std::env::temp_dir().join("weaver_kb_empty_test");
4925 std::fs::create_dir_all(&dir).ok();
4926 let path = dir.join("empty-kb.json");
4927 kb.save_to_file(&path).unwrap();
4928 let loaded = WeaverKnowledgeBase::load_from_file(&path).unwrap();
4929 assert_eq!(loaded.pattern_count(), 0);
4930 std::fs::remove_dir_all(&dir).ok();
4931 }
4932
4933 #[test]
4934 fn knowledge_base_serializable_kb_fields() {
4935 let kb = WeaverKnowledgeBase::new();
4936 kb.record_strategy(StrategyPattern {
4937 decision_type: "SourceAdded".into(),
4938 context: "rust".into(),
4939 improvement: 0.1,
4940 timestamp: Utc::now(),
4941 });
4942 let ser = kb.to_serializable();
4943 assert_eq!(ser.version, 1);
4944 assert_eq!(ser.patterns.len(), 1);
4945 assert!(ser.domains_modeled.contains(&"rust".to_string()));
4946 }
4947
4948 #[test]
4949 fn knowledge_base_load_nonexistent_file_errors() {
4950 let result = WeaverKnowledgeBase::load_from_file(
4951 Path::new("/nonexistent/path/kb.json"),
4952 );
4953 assert!(result.is_err());
4954 }
4955
4956 #[test]
4957 fn knowledge_base_find_patterns_no_match_returns_empty() {
4958 let kb = WeaverKnowledgeBase::new();
4959 kb.learn_pattern(StrategyPattern {
4960 decision_type: "X".into(),
4961 context: "rust".into(),
4962 improvement: 0.1,
4963 timestamp: Utc::now(),
4964 });
4965 let matches = kb.find_patterns(&["java".to_string()]);
4966 assert!(matches.is_empty());
4967 }
4968}