Skip to main content

remem/retrieval/temporal/
fact_keys.rs

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(&params);
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}