1use anyhow::Result;
2use chrono::NaiveDate;
3use rusqlite::{types::ToSql, Connection, OptionalExtension};
4use std::collections::HashSet;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum FactTimeMode {
8 Current,
9 AsOf(i64),
10}
11
12impl FactTimeMode {
13 pub fn from_query(query: &str) -> Self {
14 extract_as_of_epoch(query)
15 .map(Self::AsOf)
16 .unwrap_or(Self::Current)
17 }
18}
19
20pub fn search_fact_memory_ids(
21 conn: &Connection,
22 terms: &[&str],
23 project: Option<&str>,
24 memory_type: Option<&str>,
25 excluded_memory_types: &[&str],
26 owner_project: Option<&str>,
27 branch: Option<&str>,
28 limit: i64,
29 include_inactive: bool,
30 mode: FactTimeMode,
31) -> Result<Vec<i64>> {
32 let terms = normalized_fact_terms(terms);
33 if terms.is_empty() || limit <= 0 || !sqlite_table_exists(conn, "memory_facts")? {
34 return Ok(vec![]);
35 }
36 let has_invalidated_at_epoch = crate::memory::facts::invalidated_at_epoch_available(conn)?;
37 let mut conditions = vec!["f.source_memory_id IS NOT NULL".to_string()];
38 let mut params: Vec<Box<dyn ToSql>> = Vec::new();
39 let mut idx = 1;
40 match mode {
41 FactTimeMode::Current => {
42 conditions.push(crate::memory::memory_current_filter_sql(
43 "m.status",
44 "m.expires_at_epoch",
45 include_inactive,
46 ));
47 conditions.push(crate::memory::memory_state_key_current_filter_sql("m"));
48 let now = chrono::Utc::now().timestamp();
49 conditions.push(crate::memory::facts::current_fact_filter_sql(
50 "f",
51 has_invalidated_at_epoch,
52 ));
53 conditions.push(format!(
54 "(f.valid_from_epoch IS NULL OR f.valid_from_epoch <= ?{idx})"
55 ));
56 conditions.push(format!(
57 "(f.valid_to_epoch IS NULL OR f.valid_to_epoch > ?{idx})"
58 ));
59 params.push(Box::new(now));
60 idx += 1;
61 }
62 FactTimeMode::AsOf(as_of_epoch) => {
63 conditions.push(format!(
64 "COALESCE(m.valid_from_epoch, m.created_at_epoch) <= ?{idx}"
65 ));
66 conditions.push(format!(
67 "(m.valid_to_epoch IS NULL OR m.valid_to_epoch > ?{idx})"
68 ));
69 conditions.push(format!(
70 "(f.valid_from_epoch IS NULL OR f.valid_from_epoch <= ?{idx})"
71 ));
72 conditions.push(crate::memory::facts::as_of_validity_filter_sql(
73 "f",
74 idx,
75 has_invalidated_at_epoch,
76 ));
77 conditions.push(format!("f.learned_at_epoch <= ?{idx}"));
78 if has_invalidated_at_epoch {
79 conditions.push(format!(
80 "(f.invalidated_at_epoch IS NULL OR f.invalidated_at_epoch > ?{idx})"
81 ));
82 }
83 params.push(Box::new(as_of_epoch));
84 idx += 1;
85 }
86 }
87 let mut match_terms = Vec::new();
88 for term in terms.iter().take(8) {
89 match_terms.push(format!(
90 "CASE WHEN f.subject LIKE ?{idx} COLLATE NOCASE \
91 OR f.predicate LIKE ?{idx} COLLATE NOCASE \
92 OR f.object LIKE ?{idx} COLLATE NOCASE \
93 THEN 1 ELSE 0 END"
94 ));
95 params.push(Box::new(format!("%{term}%")));
96 idx += 1;
97 }
98 if match_terms.is_empty() {
99 return Ok(vec![]);
100 }
101 let required_matches = match_terms.len().min(2);
102 let match_score_sql = match_terms.join(" + ");
103 conditions.push(format!("({match_score_sql}) >= {required_matches}"));
104 if let Some(project) = project {
105 conditions.push(format!("f.project = ?{idx}"));
106 params.push(Box::new(project.to_string()));
107 idx += 1;
108 conditions.push(crate::retrieval::memory_search::project_or_global_clause(
109 "m.project",
110 idx,
111 ));
112 params.push(Box::new(project.to_string()));
113 idx += 1;
114 }
115 if let Some(memory_type) = memory_type {
116 conditions.push(format!("m.memory_type = ?{idx}"));
117 params.push(Box::new(memory_type.to_string()));
118 idx += 1;
119 }
120 push_excluded_memory_type_filter(
121 excluded_memory_types,
122 &mut idx,
123 &mut conditions,
124 &mut params,
125 );
126 if let Some(owner_project) = owner_project {
127 push_owner_included_memory_filter(owner_project, &mut idx, &mut conditions, &mut params);
128 }
129 if let Some(branch) = branch.filter(|branch| !branch.trim().is_empty()) {
130 conditions.push(format!("(m.branch = ?{idx} OR m.branch IS NULL)"));
131 params.push(Box::new(branch.to_string()));
132 idx += 1;
133 }
134 params.push(Box::new(limit));
135 let sql = format!(
136 "SELECT m.id, MAX(COALESCE(f.valid_from_epoch, f.learned_at_epoch)) AS fact_epoch,
137 MAX(f.confidence) AS confidence,
138 MAX({match_score_sql}) AS match_count
139 FROM memory_facts f
140 JOIN memories m ON m.id = f.source_memory_id
141 WHERE {}
142 GROUP BY m.id
143 ORDER BY match_count DESC, fact_epoch DESC, confidence DESC, m.updated_at_epoch DESC, m.id DESC
144 LIMIT ?{idx}",
145 conditions.join(" AND ")
146 );
147 let refs = crate::db::to_sql_refs(¶ms);
148 let mut stmt = conn.prepare(&sql)?;
149 let rows = stmt.query_map(refs.as_slice(), |row| row.get::<_, i64>(0))?;
150 crate::db::query::collect_rows(rows)
151}
152
153pub(crate) fn sqlite_table_exists(conn: &Connection, table: &str) -> Result<bool> {
154 Ok(conn
155 .query_row(
156 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1 LIMIT 1",
157 [table],
158 |_| Ok(()),
159 )
160 .optional()?
161 .is_some())
162}
163
164fn push_excluded_memory_type_filter(
165 excluded_memory_types: &[&str],
166 idx: &mut usize,
167 conditions: &mut Vec<String>,
168 params: &mut Vec<Box<dyn ToSql>>,
169) {
170 if excluded_memory_types.is_empty() {
171 return;
172 }
173 let placeholders = excluded_memory_types
174 .iter()
175 .map(|memory_type| {
176 let placeholder = format!("?{idx}");
177 params.push(Box::new((*memory_type).to_string()));
178 *idx += 1;
179 placeholder
180 })
181 .collect::<Vec<_>>();
182 conditions.push(format!(
183 "m.memory_type NOT IN ({})",
184 placeholders.join(", ")
185 ));
186}
187
188fn push_owner_included_memory_filter(
189 project: &str,
190 idx: &mut usize,
191 conditions: &mut Vec<String>,
192 params: &mut Vec<Box<dyn ToSql>>,
193) {
194 let owner_key_idx = *idx;
195 params.push(Box::new(project.to_string()));
196 *idx += 1;
197 let target_idx = *idx;
198 params.push(Box::new(project.to_string()));
199 *idx += 1;
200 let legacy_project_idx = *idx;
201 params.push(Box::new(project.to_string()));
202 *idx += 1;
203 conditions.push(format!(
204 "((m.owner_scope = 'repo' AND m.owner_key = ?{owner_key_idx}) \
205 OR (m.owner_scope = 'repo' AND m.target_project = ?{target_idx}) \
206 OR (m.owner_scope IS NULL AND m.project = ?{legacy_project_idx} \
207 AND COALESCE(m.scope, 'project') != 'global'))"
208 ));
209}
210
211pub(crate) fn normalized_fact_terms(terms: &[&str]) -> Vec<String> {
212 let mut normalized_inputs = Vec::new();
213 for raw in terms {
214 let raw = raw.trim();
215 let had_hash_marker = raw.starts_with('#');
216 let term = raw
217 .trim_matches(|c: char| !(c.is_alphanumeric() || is_cjk(c)))
218 .to_lowercase();
219 if (had_hash_marker && term.chars().all(|c| c.is_ascii_digit()))
220 || term
221 .strip_prefix("pr-")
222 .is_some_and(|ticket| ticket.chars().all(|c| c.is_ascii_digit()))
223 {
224 normalized_inputs.push("pr".to_string());
225 normalized_inputs.push(term.trim_start_matches("pr-").to_string());
226 } else {
227 normalized_inputs.push(normalize_relation_term(&term).to_string());
228 }
229 }
230 let has_ticket_marker = normalized_inputs
231 .iter()
232 .any(|term| matches!(term.as_str(), "pr" | "pull" | "issue" | "ticket"));
233 let mut normalized = Vec::new();
234 let mut seen = HashSet::new();
235 for term in normalized_inputs {
236 let numeric_ticket_id = has_ticket_marker && term.chars().all(|c| c.is_ascii_digit());
237 let short_ticket_marker = has_ticket_marker && matches!(term.as_str(), "pr" | "issue");
238 let min_len = if term.chars().any(is_cjk) { 2 } else { 3 };
239 if (!numeric_ticket_id && !short_ticket_marker && term.chars().count() < min_len)
240 || (!numeric_ticket_id && term.chars().all(|c| c.is_ascii_digit() || c == '-'))
241 || is_date_token(&term)
242 || is_fact_stop_term(&term)
243 || !seen.insert(term.clone())
244 {
245 continue;
246 }
247 normalized.push(term);
248 if normalized.len() >= 8 {
249 break;
250 }
251 }
252 normalized
253}
254
255fn is_fact_stop_term(term: &str) -> bool {
256 matches!(
257 term,
258 "after"
259 | "as-of"
260 | "asof"
261 | "before"
262 | "current"
263 | "currently"
264 | "during"
265 | "from"
266 | "latest"
267 | "recent"
268 | "recently"
269 | "that"
270 | "this"
271 | "what"
272 | "when"
273 | "where"
274 | "which"
275 | "who"
276 | "with"
277 | "当前"
278 | "目前"
279 | "最近"
280 | "截至"
281 | "截止"
282 )
283}
284
285fn normalize_relation_term(term: &str) -> &str {
286 match term {
287 "owner" | "owned" | "owns" => "own",
288 "verifies" | "verify" => "verified",
289 _ => term,
290 }
291}
292
293fn is_date_token(term: &str) -> bool {
294 let mut digit_count = 0;
295 let mut separator_count = 0;
296 for c in term.chars() {
297 if c.is_ascii_digit() {
298 digit_count += 1;
299 } else if matches!(c, '-' | '/' | '.') {
300 separator_count += 1;
301 } else {
302 return false;
303 }
304 }
305 digit_count >= 6 && separator_count > 0
306}
307
308fn is_cjk(c: char) -> bool {
309 matches!(
310 c,
311 '\u{4E00}'..='\u{9FFF}' | '\u{3400}'..='\u{4DBF}' | '\u{F900}'..='\u{FAFF}'
312 )
313}
314
315fn extract_as_of_epoch(query: &str) -> Option<i64> {
316 let lower = query.to_lowercase();
317 let markers = ["as of", "as-of", "截至", "截止"];
318 for marker in markers {
319 let Some(start) = lower.find(marker) else {
320 continue;
321 };
322 let suffix = &lower[start + marker.len()..];
323 if let Some(epoch) = first_date_epoch(suffix) {
324 return Some(epoch);
325 }
326 }
327 None
328}
329
330fn first_date_epoch(text: &str) -> Option<i64> {
331 for raw in text.split_whitespace() {
332 let token =
333 raw.trim_matches(|c: char| !(c.is_ascii_digit() || c == '-' || c == '/' || c == '.'));
334 for fmt in ["%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d"] {
335 if let Ok(date) = NaiveDate::parse_from_str(token, fmt) {
336 return date.and_hms_opt(0, 0, 0).map(|dt| dt.and_utc().timestamp());
337 }
338 }
339 }
340 None
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346 use anyhow::Context;
347 use rusqlite::{params, Connection};
348
349 fn migrated_conn() -> Result<Connection> {
350 let conn = Connection::open_in_memory()?;
351 crate::migrate::run_migrations(&conn)?;
352 Ok(conn)
353 }
354
355 fn insert_memory(conn: &Connection, id: i64, project: &str, now: i64) -> Result<()> {
356 conn.execute(
357 "INSERT INTO memories
358 (id, session_id, project, topic_key, title, content, memory_type, files,
359 created_at_epoch, updated_at_epoch, status, branch, scope)
360 VALUES (?1, NULL, ?2, NULL, ?3, ?4, 'decision', NULL, ?5, ?5,
361 'active', NULL, 'project')",
362 params![
363 id,
364 project,
365 format!("Memory {id}"),
366 "Source memory text has no signer token.",
367 now
368 ],
369 )?;
370 Ok(())
371 }
372
373 #[allow(clippy::too_many_arguments)]
374 fn insert_fact(
375 conn: &Connection,
376 memory_id: i64,
377 subject: &str,
378 object: &str,
379 status: &str,
380 valid_from_epoch: Option<i64>,
381 valid_to_epoch: Option<i64>,
382 learned_at_epoch: i64,
383 invalidated_at_epoch: Option<i64>,
384 ) -> Result<()> {
385 conn.execute(
386 "INSERT INTO memory_facts
387 (project, subject, predicate, object, valid_from_epoch, valid_to_epoch,
388 learned_at_epoch, source_memory_id, source_observation_id, source_event_ids,
389 confidence, supersedes_fact_id, status, invalidated_at_epoch,
390 created_at_epoch, updated_at_epoch)
391 VALUES ('/repo', ?1, 'verified_by', ?2, ?3, ?4, ?5, ?6, NULL, '[]',
392 0.95, NULL, ?7, ?8, ?5, ?5)",
393 params![
394 subject,
395 object,
396 valid_from_epoch,
397 valid_to_epoch,
398 learned_at_epoch,
399 memory_id,
400 status,
401 invalidated_at_epoch
402 ],
403 )?;
404 Ok(())
405 }
406
407 #[test]
408 fn parses_as_of_date_markers() {
409 let expected = NaiveDate::from_ymd_opt(2026, 5, 4)
410 .unwrap()
411 .and_hms_opt(0, 0, 0)
412 .unwrap()
413 .and_utc()
414 .timestamp();
415 assert_eq!(
416 FactTimeMode::from_query("owner as of 2026-05-04"),
417 FactTimeMode::AsOf(expected)
418 );
419 assert_eq!(
420 FactTimeMode::from_query("owner as-of 2026/05/04"),
421 FactTimeMode::AsOf(expected)
422 );
423 assert_eq!(
424 FactTimeMode::from_query("截至 2026.05.04 的 owner"),
425 FactTimeMode::AsOf(expected)
426 );
427 }
428
429 #[test]
430 fn current_search_excludes_stale_expired_and_invalidated_facts() -> Result<()> {
431 let conn = migrated_conn()?;
432 let now = chrono::Utc::now().timestamp();
433 for id in 1..=4 {
434 insert_memory(&conn, id, "/repo", now - id)?;
435 }
436 insert_fact(
437 &conn,
438 1,
439 "HarborMint",
440 "Toma Reed",
441 "active",
442 Some(now - 1_000),
443 Some(now + 1_000),
444 now - 900,
445 None,
446 )?;
447 insert_fact(
448 &conn,
449 2,
450 "HarborMint",
451 "Toma Reed",
452 "stale",
453 Some(now - 1_000),
454 Some(now + 1_000),
455 now - 800,
456 Some(now - 10),
457 )?;
458 insert_fact(
459 &conn,
460 3,
461 "HarborMint",
462 "Toma Reed",
463 "active",
464 Some(now - 1_000),
465 Some(now - 10),
466 now - 700,
467 None,
468 )?;
469 insert_fact(
470 &conn,
471 4,
472 "HarborMint",
473 "Toma Reed",
474 "active",
475 Some(now + 10),
476 None,
477 now - 600,
478 None,
479 )?;
480
481 let ids = search_fact_memory_ids(
482 &conn,
483 &["HarborMint", "Toma"],
484 Some("/repo"),
485 None,
486 &[],
487 None,
488 None,
489 10,
490 false,
491 FactTimeMode::Current,
492 )?;
493
494 assert_eq!(ids, vec![1]);
495 Ok(())
496 }
497
498 #[test]
499 fn as_of_search_uses_transaction_time_validity() -> Result<()> {
500 let conn = migrated_conn()?;
501 let as_of = NaiveDate::from_ymd_opt(2026, 1, 15)
502 .and_then(|date| date.and_hms_opt(12, 0, 0))
503 .context("valid as-of test date")?
504 .and_utc()
505 .timestamp();
506 for id in 1..=2 {
507 insert_memory(&conn, id, "/repo", as_of - id)?;
508 }
509 insert_fact(
510 &conn,
511 1,
512 "HarborMint",
513 "Toma Reed",
514 "stale",
515 Some(as_of - 10_000),
516 Some(as_of + 1_000),
517 as_of - 900,
518 Some(as_of + 500),
519 )?;
520 insert_fact(
521 &conn,
522 2,
523 "HarborMint",
524 "Toma Reed",
525 "active",
526 Some(as_of - 10_000),
527 None,
528 as_of + 100,
529 None,
530 )?;
531
532 let ids = search_fact_memory_ids(
533 &conn,
534 &["HarborMint", "Toma"],
535 Some("/repo"),
536 None,
537 &[],
538 None,
539 None,
540 10,
541 false,
542 FactTimeMode::AsOf(as_of),
543 )?;
544
545 assert_eq!(ids, vec![1]);
546 Ok(())
547 }
548
549 #[test]
550 fn as_of_search_includes_source_memory_that_is_stale_today() -> Result<()> {
551 let conn = migrated_conn()?;
552 let as_of = 1_800_000_000;
553 insert_memory(&conn, 1, "/repo", as_of - 100)?;
554 conn.execute(
555 "UPDATE memories
556 SET status = 'stale', valid_from_epoch = ?1, valid_to_epoch = ?2
557 WHERE id = 1",
558 params![as_of - 1_000, as_of + 1_000],
559 )?;
560 insert_fact(
561 &conn,
562 1,
563 "HarborMint",
564 "Toma Reed",
565 "stale",
566 Some(as_of - 1_000),
567 Some(as_of + 1_000),
568 as_of - 900,
569 Some(as_of + 500),
570 )?;
571
572 let ids = search_fact_memory_ids(
573 &conn,
574 &["HarborMint", "Toma"],
575 Some("/repo"),
576 None,
577 &[],
578 None,
579 None,
580 10,
581 false,
582 FactTimeMode::AsOf(as_of),
583 )?;
584
585 assert_eq!(ids, vec![1]);
586 Ok(())
587 }
588
589 #[test]
590 fn as_of_search_uses_memory_validity_not_current_status() -> Result<()> {
591 let conn = migrated_conn()?;
592 let as_of = 1_800_000_000;
593 insert_memory(&conn, 1, "/repo", as_of - 100)?;
594 conn.execute(
595 "UPDATE memories
596 SET status = 'archived', valid_from_epoch = ?1, valid_to_epoch = NULL
597 WHERE id = 1",
598 params![as_of - 1_000],
599 )?;
600 insert_fact(
601 &conn,
602 1,
603 "HarborMint",
604 "Toma Reed",
605 "stale",
606 Some(as_of - 1_000),
607 Some(as_of + 1_000),
608 as_of - 900,
609 Some(as_of + 500),
610 )?;
611
612 let ids = search_fact_memory_ids(
613 &conn,
614 &["HarborMint", "Toma"],
615 Some("/repo"),
616 None,
617 &[],
618 None,
619 None,
620 10,
621 false,
622 FactTimeMode::AsOf(as_of),
623 )?;
624
625 assert_eq!(ids, vec![1]);
626 Ok(())
627 }
628
629 #[test]
630 fn search_filters_by_fact_project_not_only_source_memory_project() -> Result<()> {
631 let conn = migrated_conn()?;
632 let now = chrono::Utc::now().timestamp();
633 for id in 1..=2 {
634 insert_memory(&conn, id, "/repo", now - id)?;
635 }
636 conn.execute(
637 "INSERT INTO memory_facts
638 (project, subject, predicate, object, valid_from_epoch, valid_to_epoch,
639 learned_at_epoch, source_memory_id, source_observation_id, source_event_ids,
640 confidence, supersedes_fact_id, status, invalidated_at_epoch,
641 created_at_epoch, updated_at_epoch)
642 VALUES
643 ('/other', 'HarborMint', 'verified_by', 'Toma Reed', ?1, ?2, ?3, 1,
644 NULL, '[]', 0.95, NULL, 'active', NULL, ?3, ?3),
645 ('/repo', 'HarborMint', 'verified_by', 'Toma Reed', ?1, ?2, ?3, 2,
646 NULL, '[]', 0.95, NULL, 'active', NULL, ?3, ?3)",
647 params![now - 1_000, now + 1_000, now - 900],
648 )?;
649
650 let ids = search_fact_memory_ids(
651 &conn,
652 &["HarborMint", "Toma"],
653 Some("/repo"),
654 None,
655 &[],
656 None,
657 None,
658 10,
659 false,
660 FactTimeMode::Current,
661 )?;
662
663 assert_eq!(ids, vec![2]);
664 Ok(())
665 }
666
667 #[test]
668 fn relation_terms_participate_in_fact_matching() -> Result<()> {
669 let conn = migrated_conn()?;
670 let now = chrono::Utc::now().timestamp();
671 for id in 1..=2 {
672 insert_memory(&conn, id, "/repo", now - id)?;
673 }
674 conn.execute(
675 "INSERT INTO memory_facts
676 (project, subject, predicate, object, valid_from_epoch, valid_to_epoch,
677 learned_at_epoch, source_memory_id, source_observation_id, source_event_ids,
678 confidence, supersedes_fact_id, status, invalidated_at_epoch,
679 created_at_epoch, updated_at_epoch)
680 VALUES
681 ('/repo', 'HarborMint owner', 'verified_by', 'Ada Lovelace', ?1, NULL, ?2, 1,
682 NULL, '[]', 0.95, NULL, 'active', NULL, ?2, ?2),
683 ('/repo', 'HarborMint', 'blocked_by', 'North Region', ?1, NULL, ?3, 2,
684 NULL, '[]', 0.95, NULL, 'active', NULL, ?3, ?3)",
685 params![now - 1_000, now - 900, now - 100],
686 )?;
687
688 let ids = search_fact_memory_ids(
689 &conn,
690 &["who", "owns", "HarborMint"],
691 Some("/repo"),
692 None,
693 &[],
694 None,
695 None,
696 10,
697 false,
698 FactTimeMode::Current,
699 )?;
700
701 assert_eq!(ids, vec![1]);
702 Ok(())
703 }
704
705 #[test]
706 fn normalized_terms_preserve_pr_number_pairs_without_numeric_only_queries() {
707 assert_eq!(normalized_fact_terms(&["PR", "190"]), vec!["pr", "190"]);
708 assert_eq!(normalized_fact_terms(&["PR-190"]), vec!["pr", "190"]);
709 assert_eq!(normalized_fact_terms(&["#190"]), vec!["pr", "190"]);
710 assert!(normalized_fact_terms(&["190"]).is_empty());
711 }
712
713 #[test]
714 fn normalized_terms_drop_as_of_markers_and_dates() {
715 assert_eq!(
716 normalized_fact_terms(&["HarborMint", "as-of", "2026/01/15"]),
717 vec!["harbormint"]
718 );
719 assert_eq!(
720 normalized_fact_terms(&["截至", "2026.01.15", "HarborMint"]),
721 vec!["harbormint"]
722 );
723 }
724
725 #[test]
726 fn normalized_terms_drop_nonsemantic_current_and_recent_modifiers() {
727 assert_eq!(
728 normalized_fact_terms(&[
729 "current",
730 "currently",
731 "recent",
732 "recently",
733 "当前",
734 "目前",
735 "最近",
736 "HarborMint",
737 ]),
738 vec!["harbormint"]
739 );
740 }
741}