1use chrono::{DateTime, Utc};
4use rusqlite::{Connection, params};
5
6use crate::EvolutionResult as Result;
7use crate::{Observation, Pattern, SignalType};
8
9pub struct KnowledgeStore {
11 conn: Connection,
12}
13
14impl KnowledgeStore {
15 pub fn open(path: &str) -> Result<Self> {
17 let conn = Connection::open(path)?;
18 let store = Self { conn };
19 store.migrate()?;
20 Ok(store)
21 }
22
23 pub fn open_memory() -> Result<Self> {
25 let conn = Connection::open_in_memory()?;
26 let store = Self { conn };
27 store.migrate()?;
28 Ok(store)
29 }
30
31 fn migrate(&self) -> Result<()> {
32 self.conn.execute_batch(
33 "CREATE TABLE IF NOT EXISTS observations (
34 id TEXT PRIMARY KEY,
35 signal_type TEXT NOT NULL,
36 intensity REAL NOT NULL,
37 context TEXT NOT NULL,
38 timestamp TEXT NOT NULL,
39 session_id TEXT,
40 turn_number INTEGER
41 );
42
43 CREATE TABLE IF NOT EXISTS patterns (
44 id TEXT PRIMARY KEY,
45 description TEXT NOT NULL,
46 instruction TEXT NOT NULL,
47 confidence REAL NOT NULL,
48 evidence_count INTEGER NOT NULL,
49 first_observed TEXT NOT NULL,
50 last_updated TEXT NOT NULL,
51 category TEXT NOT NULL,
52 active INTEGER NOT NULL DEFAULT 1,
53 content_hash TEXT NOT NULL DEFAULT ''
54 );
55
56 CREATE TABLE IF NOT EXISTS conflicts (
57 id TEXT PRIMARY KEY,
58 pattern_a_id TEXT NOT NULL,
59 pattern_b_id TEXT NOT NULL,
60 description TEXT NOT NULL,
61 detected_at TEXT NOT NULL,
62 resolved INTEGER NOT NULL DEFAULT 0,
63 winner_id TEXT
64 );
65
66 -- I021-S1: MenteDB-aligned tables
67 CREATE TABLE IF NOT EXISTS signals (
68 id TEXT PRIMARY KEY,
69 kind TEXT NOT NULL,
70 intensity REAL NOT NULL,
71 context TEXT NOT NULL,
72 tool_name TEXT,
73 turn_observation_id TEXT REFERENCES turn_observations(id),
74 timestamp TEXT NOT NULL
75 );
76
77 CREATE TABLE IF NOT EXISTS turn_observations (
78 id TEXT PRIMARY KEY,
79 session_id TEXT NOT NULL,
80 turn_number INTEGER NOT NULL,
81 outcome TEXT NOT NULL,
82 duration_ms INTEGER NOT NULL,
83 timestamp TEXT NOT NULL
84 );
85
86 CREATE TABLE IF NOT EXISTS schema_version (
87 version INTEGER NOT NULL
88 );",
89 )?;
90
91 let has_key_column: i64 = self
93 .conn
94 .prepare("SELECT COUNT(*) FROM pragma_table_info('patterns') WHERE name = 'key'")?
95 .query_row([], |row| row.get(0))?;
96
97 if has_key_column == 0 {
98 let obs_count: i64 = self
99 .conn
100 .query_row("SELECT COUNT(*) FROM observations", [], |row| row.get(0))
101 .unwrap_or(0);
102 let pat_count: i64 = self
103 .conn
104 .query_row("SELECT COUNT(*) FROM patterns", [], |row| row.get(0))
105 .unwrap_or(0);
106
107 if obs_count > 0 || pat_count > 0 {
108 let _ = self.conn.execute("DELETE FROM observations", []);
109 let _ = self.conn.execute("DELETE FROM patterns", []);
110 let _ = self.conn.execute("DELETE FROM conflicts", []);
111 let _ = self.conn.execute("DELETE FROM signals", []);
112 let _ = self.conn.execute("DELETE FROM turn_observations", []);
113 tracing::warn!(
114 observations = obs_count,
115 patterns = pat_count,
116 "knowledge.db schema migration: hard-reset, removed observations and patterns"
117 );
118 }
119 }
120
121 let has_column = self
124 .conn
125 .prepare(
126 "SELECT COUNT(*) FROM pragma_table_info('patterns') WHERE name = 'content_hash'",
127 )?
128 .query_row([], |row| row.get::<_, i64>(0))?;
129 if has_column == 0 {
130 let _ = self.conn.execute(
131 "ALTER TABLE patterns ADD COLUMN content_hash TEXT NOT NULL DEFAULT ''",
132 [],
133 );
134 }
135
136 for (col, default) in [
138 ("key", "''"),
139 ("value", "'null'"),
140 ("contradicting_count", "0"),
141 ("last_reinforced", "''"),
142 ("source_sessions", "'[]'"),
143 ] {
144 let has_col: i64 = self
145 .conn
146 .prepare(&format!(
147 "SELECT COUNT(*) FROM pragma_table_info('patterns') WHERE name = '{col}'"
148 ))?
149 .query_row([], |row| row.get(0))?;
150 if has_col == 0 {
151 let _ = self.conn.execute(
152 &format!(
153 "ALTER TABLE patterns ADD COLUMN {col} TEXT NOT NULL DEFAULT {default}"
154 ),
155 [],
156 );
157 }
158 }
159
160 let version_count: i64 =
162 self.conn
163 .query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0))?;
164 if version_count == 0 {
165 let _ = self
166 .conn
167 .execute("INSERT INTO schema_version (version) VALUES (2)", []);
168 }
169
170 Ok(())
171 }
172
173 pub fn insert_observation(&self, obs: &Observation) -> Result<()> {
175 self.conn.execute(
176 "INSERT INTO observations (id, signal_type, intensity, context, timestamp, session_id, turn_number)
177 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
178 params![
179 obs.id,
180 format!("{:?}", obs.signal_type),
181 obs.intensity,
182 obs.context,
183 obs.timestamp.to_rfc3339(),
184 obs.session_id,
185 obs.turn_number,
186 ],
187 )?;
188 Ok(())
189 }
190
191 pub fn get_observations(&self) -> Result<Vec<Observation>> {
193 let mut stmt = self.conn.prepare(
194 "SELECT id, signal_type, intensity, context, timestamp, session_id, turn_number
195 FROM observations ORDER BY timestamp DESC",
196 )?;
197
198 let observations = stmt
199 .query_map([], |row| {
200 let signal_type_str: String = row.get(1)?;
201 let signal_type = match signal_type_str.as_str() {
202 "Correction" => SignalType::Correction,
203 "Error" => SignalType::Error,
204 "Satisfaction" => SignalType::Satisfaction,
205 "Inefficiency" => SignalType::Inefficiency,
206 _ => SignalType::Correction,
207 };
208
209 let timestamp_str: String = row.get(4)?;
210 let timestamp = DateTime::parse_from_rfc3339(×tamp_str)
211 .map(|dt| dt.with_timezone(&Utc))
212 .unwrap_or_else(|_| Utc::now());
213
214 Ok(Observation {
215 id: row.get(0)?,
216 signal_type,
217 intensity: row.get(2)?,
218 context: row.get(3)?,
219 timestamp,
220 session_id: row.get(5)?,
221 turn_number: row.get(6)?,
222 })
223 })?
224 .collect::<std::result::Result<Vec<_>, _>>()?;
225
226 Ok(observations)
227 }
228
229 pub fn insert_pattern(&self, pattern: &Pattern) -> Result<()> {
231 let value_json = serde_json::to_string(&pattern.value).unwrap_or_else(|_| "null".into());
232 let sessions_json =
233 serde_json::to_string(&pattern.source_sessions).unwrap_or_else(|_| "[]".into());
234 self.conn.execute(
235 "INSERT INTO patterns (id, description, instruction, confidence, evidence_count, first_observed, last_updated, category, active, content_hash, key, value, contradicting_count, last_reinforced, source_sessions)
236 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
237 params![
238 pattern.id,
239 pattern.description,
240 pattern.instruction,
241 pattern.confidence,
242 pattern.evidence_count,
243 pattern.first_observed.to_rfc3339(),
244 pattern.last_updated.to_rfc3339(),
245 pattern.category,
246 pattern.active as i32,
247 pattern.content_hash,
248 pattern.key,
249 value_json,
250 pattern.contradicting_count as i32,
251 pattern.last_reinforced.to_rfc3339(),
252 sessions_json,
253 ],
254 )?;
255 Ok(())
256 }
257
258 pub fn get_active_patterns(&self, min_confidence: f64) -> Result<Vec<Pattern>> {
260 let mut stmt = self.conn.prepare(
261 "SELECT id, description, instruction, confidence, evidence_count, first_observed, last_updated, category, active, content_hash, key, value, contradicting_count, last_reinforced, source_sessions
262 FROM patterns WHERE active = 1 AND confidence >= ?1 ORDER BY confidence DESC",
263 )?;
264
265 let patterns = stmt
266 .query_map(params![min_confidence], |row| {
267 let first_observed_str: String = row.get(5)?;
268 let first_observed = DateTime::parse_from_rfc3339(&first_observed_str)
269 .map(|dt| dt.with_timezone(&Utc))
270 .unwrap_or_else(|_| Utc::now());
271
272 let last_updated_str: String = row.get(6)?;
273 let last_updated = DateTime::parse_from_rfc3339(&last_updated_str)
274 .map(|dt| dt.with_timezone(&Utc))
275 .unwrap_or_else(|_| Utc::now());
276
277 let key: String = row.get(10)?;
278 let value_str: String = row.get(11)?;
279 let value: serde_json::Value =
280 serde_json::from_str(&value_str).unwrap_or(serde_json::Value::Null);
281 let contradicting_raw: String = row.get(12)?;
282 let contradicting_count: u32 = contradicting_raw.parse().unwrap_or(0);
283
284 let last_reinforced_str: String = row.get(13)?;
285 let last_reinforced = if last_reinforced_str.is_empty() {
286 first_observed
287 } else {
288 DateTime::parse_from_rfc3339(&last_reinforced_str)
289 .map(|dt| dt.with_timezone(&Utc))
290 .unwrap_or(first_observed)
291 };
292
293 let sessions_str: String = row.get(14)?;
294 let source_sessions: Vec<uuid::Uuid> =
295 serde_json::from_str(&sessions_str).unwrap_or_default();
296
297 Ok(Pattern {
298 id: row.get(0)?,
299 description: row.get(1)?,
300 instruction: row.get(2)?,
301 confidence: row.get(3)?,
302 evidence_count: row.get(4)?,
303 first_observed,
304 last_updated,
305 category: row.get(7)?,
306 active: row.get::<_, i32>(8)? == 1,
307 content_hash: row.get(9)?,
308 key,
309 value,
310 contradicting_count,
311 last_reinforced,
312 source_sessions,
313 })
314 })?
315 .collect::<std::result::Result<Vec<_>, _>>()?;
316
317 Ok(patterns)
318 }
319
320 pub fn update_pattern(&self, pattern: &Pattern) -> Result<()> {
322 self.conn.execute(
323 "UPDATE patterns SET confidence = ?1, evidence_count = ?2, last_updated = ?3
324 WHERE id = ?4",
325 params![
326 pattern.confidence,
327 pattern.evidence_count,
328 pattern.last_updated.to_rfc3339(),
329 pattern.id,
330 ],
331 )?;
332 Ok(())
333 }
334
335 pub fn deactivate_pattern(&self, pattern_id: &str) -> Result<()> {
337 self.conn.execute(
338 "UPDATE patterns SET active = 0 WHERE id = ?1",
339 params![pattern_id],
340 )?;
341 Ok(())
342 }
343
344 pub fn delete_oversized_patterns(&self, max_bytes: usize) -> Result<usize> {
346 let changes = self.conn.execute(
347 "UPDATE patterns SET active = 0 WHERE length(instruction) > ?1",
348 params![max_bytes as i64],
349 )?;
350 Ok(changes)
351 }
352
353 pub fn get_all_patterns(&self) -> Result<Vec<Pattern>> {
355 let mut stmt = self.conn.prepare(
356 "SELECT id, description, instruction, confidence, evidence_count, first_observed, last_updated, category, active, content_hash, key, value, contradicting_count, last_reinforced, source_sessions
357 FROM patterns ORDER BY confidence DESC",
358 )?;
359
360 let patterns = stmt
361 .query_map([], |row| {
362 let first_observed_str: String = row.get(5)?;
363 let first_observed = DateTime::parse_from_rfc3339(&first_observed_str)
364 .map(|dt| dt.with_timezone(&Utc))
365 .unwrap_or_else(|_| Utc::now());
366
367 let last_updated_str: String = row.get(6)?;
368 let last_updated = DateTime::parse_from_rfc3339(&last_updated_str)
369 .map(|dt| dt.with_timezone(&Utc))
370 .unwrap_or_else(|_| Utc::now());
371
372 let key: String = row.get(10)?;
373 let value_str: String = row.get(11)?;
374 let value: serde_json::Value =
375 serde_json::from_str(&value_str).unwrap_or(serde_json::Value::Null);
376 let contradicting_raw: String = row.get(12)?;
377 let contradicting_count: u32 = contradicting_raw.parse().unwrap_or(0);
378
379 let last_reinforced_str: String = row.get(13)?;
380 let last_reinforced = if last_reinforced_str.is_empty() {
381 first_observed
382 } else {
383 DateTime::parse_from_rfc3339(&last_reinforced_str)
384 .map(|dt| dt.with_timezone(&Utc))
385 .unwrap_or(first_observed)
386 };
387
388 let sessions_str: String = row.get(14)?;
389 let source_sessions: Vec<uuid::Uuid> =
390 serde_json::from_str(&sessions_str).unwrap_or_default();
391
392 Ok(Pattern {
393 id: row.get(0)?,
394 description: row.get(1)?,
395 instruction: row.get(2)?,
396 confidence: row.get(3)?,
397 evidence_count: row.get(4)?,
398 first_observed,
399 last_updated,
400 category: row.get(7)?,
401 active: row.get::<_, i32>(8)? == 1,
402 content_hash: row.get(9)?,
403 key,
404 value,
405 contradicting_count,
406 last_reinforced,
407 source_sessions,
408 })
409 })?
410 .collect::<std::result::Result<Vec<_>, _>>()?;
411
412 Ok(patterns)
413 }
414}
415
416#[cfg(test)]
417#[allow(warnings)]
418mod tests {
419 use super::*;
420
421 #[test]
422 fn test_store_operations() {
423 let store = KnowledgeStore::open_memory().expect("operation should succeed");
424
425 let obs = Observation::new(
426 SignalType::Correction,
427 0.8,
428 "Use functional style".to_string(),
429 Some("session-1".to_string()),
430 Some(5),
431 );
432 store
433 .insert_observation(&obs)
434 .expect("operation should succeed");
435
436 let observations = store.get_observations().expect("operation should succeed");
437 assert_eq!(observations.len(), 1);
438 assert_eq!(observations[0].signal_type, SignalType::Correction);
439
440 let mut pattern = Pattern::new(
441 "Prefer functional style".to_string(),
442 "Use functional programming patterns".to_string(),
443 "preference".to_string(),
444 );
445 pattern.confidence = 0.8;
446 pattern.evidence_count = 3;
447 store
448 .insert_pattern(&pattern)
449 .expect("operation should succeed");
450
451 let patterns = store
452 .get_active_patterns(0.7)
453 .expect("operation should succeed");
454 assert_eq!(patterns.len(), 1);
455 assert_eq!(patterns[0].confidence, 0.8);
456 }
457
458 #[test]
459 fn test_update_pattern() {
460 let store = KnowledgeStore::open_memory().expect("operation should succeed");
461
462 let mut pattern = Pattern::new(
463 "Test pattern".to_string(),
464 "Test instruction".to_string(),
465 "test".to_string(),
466 );
467 pattern.confidence = 0.5;
468 store
469 .insert_pattern(&pattern)
470 .expect("operation should succeed");
471
472 pattern.confidence = 0.9;
473 pattern.evidence_count = 10;
474 store
475 .update_pattern(&pattern)
476 .expect("operation should succeed");
477
478 let patterns = store.get_all_patterns().expect("operation should succeed");
479 assert_eq!(patterns[0].confidence, 0.9);
480 assert_eq!(patterns[0].evidence_count, 10);
481 }
482
483 #[test]
484 fn test_delete_oversized_patterns_deactivates_but_keeps_row() {
485 let store = KnowledgeStore::open_memory().expect("operation should succeed");
486
487 let mut pattern = Pattern::new(
488 "Big pattern".to_string(),
489 "x".repeat(10_000),
490 "test".to_string(),
491 );
492 pattern.confidence = 0.9;
493 store
494 .insert_pattern(&pattern)
495 .expect("operation should succeed");
496
497 let count = store
498 .delete_oversized_patterns(4096)
499 .expect("operation should succeed");
500 assert_eq!(count, 1);
501
502 let all = store.get_all_patterns().expect("operation should succeed");
503 assert_eq!(all.len(), 1);
504 assert!(!all[0].active, "pattern should be deactivated, not deleted");
505 }
506
507 #[test]
508 fn test_delete_oversized_patterns_returns_count() {
509 let store = KnowledgeStore::open_memory().expect("operation should succeed");
510
511 for i in 0..3 {
512 let mut pattern = Pattern::new(
513 format!("pattern {i}"),
514 "x".repeat(5000 + i * 1000),
515 "test".to_string(),
516 );
517 pattern.confidence = 0.9;
518 store
519 .insert_pattern(&pattern)
520 .expect("operation should succeed");
521 }
522
523 let count = store
524 .delete_oversized_patterns(5500)
525 .expect("operation should succeed");
526 assert_eq!(count, 2);
527 }
528
529 #[test]
530 fn test_pattern_roundtrip_preserves_content_hash() {
531 let store = KnowledgeStore::open_memory().expect("operation should succeed");
532
533 let mut pattern = Pattern::new(
534 "Test".to_string(),
535 "Test instruction content".to_string(),
536 "test".to_string(),
537 );
538 pattern.confidence = 0.8;
539 let original_hash = pattern.content_hash.clone();
540 store
541 .insert_pattern(&pattern)
542 .expect("operation should succeed");
543
544 let patterns = store
545 .get_active_patterns(0.0)
546 .expect("operation should succeed");
547 assert_eq!(patterns.len(), 1);
548 assert_eq!(patterns[0].content_hash, original_hash);
549 }
550
551 #[test]
552 fn test_hard_reset_on_v1_schema_db() {
553 let dir = tempfile::tempdir().expect("tempdir");
554 let db_path = dir.path().join("knowledge.db");
555
556 {
558 let conn = Connection::open(&db_path).expect("operation should succeed");
559 conn.execute_batch(
560 "CREATE TABLE observations (
561 id TEXT PRIMARY KEY, signal_type TEXT NOT NULL, intensity REAL NOT NULL,
562 context TEXT NOT NULL, timestamp TEXT NOT NULL, session_id TEXT, turn_number INTEGER
563 );
564 CREATE TABLE patterns (
565 id TEXT PRIMARY KEY, description TEXT NOT NULL, instruction TEXT NOT NULL,
566 confidence REAL NOT NULL, evidence_count INTEGER NOT NULL,
567 first_observed TEXT NOT NULL, last_updated TEXT NOT NULL,
568 category TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1,
569 content_hash TEXT NOT NULL DEFAULT ''
570 );
571 CREATE TABLE conflicts (
572 id TEXT PRIMARY KEY, pattern_a_id TEXT NOT NULL, pattern_b_id TEXT NOT NULL,
573 description TEXT NOT NULL, detected_at TEXT NOT NULL,
574 resolved INTEGER NOT NULL DEFAULT 0, winner_id TEXT
575 );",
576 )
577 .expect("operation should succeed");
578
579 conn.execute(
581 "INSERT INTO observations (id, signal_type, intensity, context, timestamp, session_id, turn_number)
582 VALUES ('v1-obs', 'Correction', 0.8, 'v1 context', '2026-01-01T00:00:00Z', 'sess-1', 1)",
583 [],
584 )
585 .expect("operation should succeed");
586 conn.execute(
587 "INSERT INTO patterns (id, description, instruction, confidence, evidence_count, first_observed, last_updated, category, active, content_hash)
588 VALUES ('v1-pat', 'v1 pattern', 'v1 instruction', 0.9, 5, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 'preference', 1, 'hash123')",
589 [],
590 )
591 .expect("operation should succeed");
592 }
593
594 let store = KnowledgeStore::open(db_path.to_str().expect("operation should succeed"))
596 .expect("open v1 db");
597
598 let observations = store.get_observations().expect("operation should succeed");
600 assert!(
601 observations.is_empty(),
602 "v1 observations should be wiped, got {:?}",
603 observations
604 );
605 let patterns = store.get_all_patterns().expect("operation should succeed");
606 assert!(
607 patterns.is_empty(),
608 "v1 patterns should be wiped, got {:?}",
609 patterns
610 );
611
612 drop(store);
614 let store2 = KnowledgeStore::open(db_path.to_str().expect("operation should succeed"))
615 .expect("reopen v2 db");
616 let observations2 = store2.get_observations().expect("operation should succeed");
617 assert!(observations2.is_empty(), "second open should not re-reset");
618 }
619}