Skip to main content

remem/memory/
state_key.rs

1use anyhow::Result;
2use rusqlite::{params, Connection, OptionalExtension};
3use std::collections::BTreeSet;
4
5const MIN_SEMANTIC_SLOT_TERMS: usize = 4;
6const MAX_SEMANTIC_SLOT_TERMS: usize = 6;
7const CJK_SEMANTIC_SLOT_TERMS: &[(&str, &str)] = &[
8    ("三元组", "trigram"),
9    ("中文", "cjk"),
10    ("全文搜索", "fts5"),
11    ("分词器", "tokenizer"),
12    ("分词", "tokenizer"),
13    ("搜索", "search"),
14    ("检索", "retrieval"),
15    ("查询", "query"),
16    ("数据库", "database"),
17    ("加密", "encryption"),
18    ("接口", "api"),
19    ("钩子", "hook"),
20    ("适配器", "adapter"),
21    ("评测", "eval"),
22    ("基准测试", "benchmark"),
23    ("压缩", "compression"),
24    ("超时", "timeout"),
25    ("工作线程", "worker"),
26    ("记忆", "memory"),
27    ("捕获", "capture"),
28    ("提取", "extraction"),
29    ("事实", "fact"),
30    ("知识图谱", "knowledge-graph"),
31    ("提示词", "prompt"),
32    ("发布", "publish"),
33    ("部署", "deploy"),
34    ("配置", "config"),
35    ("端口", "port"),
36    ("会话", "session"),
37    ("作用域", "scope"),
38    ("全局", "global"),
39    ("摘要", "summary"),
40    ("格式", "format"),
41    ("服务器", "server"),
42    ("服务", "service"),
43    ("性能", "performance"),
44    ("上下文", "context"),
45    ("竞品", "competitive"),
46    ("对比", "comparison"),
47    ("偏好", "preference"),
48    ("共享", "sharing"),
49    ("架构", "architecture"),
50    ("设计", "design"),
51    ("规则", "rule"),
52    ("跨项目", "cross-project"),
53    ("候选", "candidate"),
54    ("声明", "declaration"),
55    ("执行", "execution"),
56    ("验证", "verification"),
57    ("状态", "status"),
58    ("数据", "data"),
59    ("代码", "code"),
60    ("分离", "separation"),
61    ("分开", "separation"),
62    ("隔离", "separation"),
63];
64
65#[derive(Debug, Clone, PartialEq)]
66pub struct StateKeyDecision {
67    pub state_key: String,
68    pub confidence: f64,
69    pub reason: String,
70}
71
72impl StateKeyDecision {
73    pub fn allows_direct_upsert(&self) -> bool {
74        self.reason != "semantic_slot_terms"
75    }
76}
77
78pub fn derive_state_key(
79    memory_type: &str,
80    topic_key: Option<&str>,
81    title: &str,
82    content: &str,
83) -> Option<StateKeyDecision> {
84    if let Some(topic_key) = stable_state_topic_key(topic_key) {
85        return Some(StateKeyDecision {
86            state_key: topic_key,
87            confidence: 1.0,
88            reason: "stable_topic_key".to_string(),
89        });
90    }
91
92    derive_compat_preference_state_key(memory_type, title, content)
93        .or_else(|| derive_semantic_state_key(memory_type, title, content))
94}
95
96pub fn current_memory_id(
97    conn: &Connection,
98    owner_scope: &str,
99    owner_key: &str,
100    memory_type: &str,
101    state_key: &str,
102    now_epoch: i64,
103) -> Result<Option<i64>> {
104    let mut values: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(owner_scope.to_string())];
105    let (owner_clause, mut idx) = owner_key_filter(conn, owner_scope, owner_key, 2, &mut values)?;
106    values.push(Box::new(memory_type.to_string()));
107    let memory_type_idx = idx;
108    idx += 1;
109    values.push(Box::new(state_key.to_string()));
110    let state_key_idx = idx;
111    idx += 1;
112    values.push(Box::new(now_epoch));
113    let now_idx = idx;
114    let sql = format!(
115        "SELECT m.id
116         FROM memory_state_keys sk
117         JOIN memories m ON m.id = sk.current_memory_id
118         WHERE sk.owner_scope = ?1
119           AND {owner_clause}
120           AND sk.memory_type = ?{memory_type_idx}
121           AND sk.state_key = ?{state_key_idx}
122           AND sk.state_status = 'active'
123           AND m.status = 'active'
124           AND (m.expires_at_epoch IS NULL OR m.expires_at_epoch > ?{now_idx})
125         ORDER BY m.updated_at_epoch DESC, m.id DESC
126         LIMIT 1"
127    );
128    let refs = crate::db::to_sql_refs(&values);
129    conn.query_row(&sql, refs.as_slice(), |row| row.get(0))
130        .optional()
131        .map_err(Into::into)
132}
133
134pub fn active_memory_ids(
135    conn: &Connection,
136    owner_scope: &str,
137    owner_key: &str,
138    memory_type: &str,
139    state_key: &str,
140    now_epoch: i64,
141    require_unexpired: bool,
142) -> Result<Vec<i64>> {
143    let mut values: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(owner_scope.to_string())];
144    let (owner_clause, mut idx) = owner_key_filter(conn, owner_scope, owner_key, 2, &mut values)?;
145    values.push(Box::new(memory_type.to_string()));
146    let memory_type_idx = idx;
147    idx += 1;
148    values.push(Box::new(state_key.to_string()));
149    let state_key_idx = idx;
150    idx += 1;
151    values.push(Box::new(if require_unexpired { 1_i64 } else { 0_i64 }));
152    let require_idx = idx;
153    idx += 1;
154    values.push(Box::new(now_epoch));
155    let now_idx = idx;
156    let sql = format!(
157        "SELECT m.id
158         FROM memories m
159         JOIN memory_state_keys sk ON sk.id = m.state_key_id
160         WHERE sk.owner_scope = ?1
161           AND {owner_clause}
162           AND sk.memory_type = ?{memory_type_idx}
163           AND sk.state_key = ?{state_key_idx}
164           AND sk.state_status = 'active'
165           AND m.status = 'active'
166           AND (
167                ?{require_idx} = 0
168                OR m.expires_at_epoch IS NULL
169                OR m.expires_at_epoch > ?{now_idx}
170           )
171         ORDER BY m.updated_at_epoch DESC, m.id DESC"
172    );
173    let mut stmt = conn.prepare(&sql)?;
174    let refs = crate::db::to_sql_refs(&values);
175    let rows = stmt.query_map(refs.as_slice(), |row| row.get(0))?;
176    crate::db::query::collect_rows(rows)
177}
178
179pub fn attach_current_memory(
180    conn: &Connection,
181    memory_id: i64,
182    owner_scope: &str,
183    owner_key: &str,
184    memory_type: &str,
185    decision: &StateKeyDecision,
186    now_epoch: i64,
187) -> Result<i64> {
188    let state_key_id = upsert_state_key(
189        conn,
190        owner_scope,
191        owner_key,
192        memory_type,
193        decision,
194        Some(memory_id),
195        now_epoch,
196    )?;
197    conn.execute(
198        "UPDATE memories SET state_key_id = ?1 WHERE id = ?2",
199        params![state_key_id, memory_id],
200    )?;
201    Ok(state_key_id)
202}
203
204pub fn ensure_state_key(
205    conn: &Connection,
206    owner_scope: &str,
207    owner_key: &str,
208    memory_type: &str,
209    decision: &StateKeyDecision,
210    created_at_epoch: i64,
211) -> Result<i64> {
212    upsert_state_key(
213        conn,
214        owner_scope,
215        owner_key,
216        memory_type,
217        decision,
218        None,
219        created_at_epoch,
220    )
221}
222
223fn upsert_state_key(
224    conn: &Connection,
225    owner_scope: &str,
226    owner_key: &str,
227    memory_type: &str,
228    decision: &StateKeyDecision,
229    current_memory_id: Option<i64>,
230    now_epoch: i64,
231) -> Result<i64> {
232    let canonical_owner_key = if owner_scope == "repo" {
233        crate::project_alias::canonical_project_path_for_write(conn, owner_key)?
234    } else {
235        owner_key.to_string()
236    };
237    conn.execute(
238        "INSERT INTO memory_state_keys
239         (owner_scope, owner_key, memory_type, state_key, state_label, state_status,
240          current_memory_id, created_at_epoch, updated_at_epoch)
241         VALUES (?1, ?2, ?3, ?4, ?5, 'active', ?6, ?7, ?7)
242         ON CONFLICT(owner_scope, owner_key, memory_type, state_key)
243         DO UPDATE SET
244             state_label = COALESCE(excluded.state_label, memory_state_keys.state_label),
245             state_status = 'active',
246             current_memory_id = CASE
247                 WHEN excluded.current_memory_id IS NULL THEN memory_state_keys.current_memory_id
248                 WHEN memory_state_keys.current_memory_id IS NULL THEN excluded.current_memory_id
249                 WHEN excluded.updated_at_epoch >= memory_state_keys.updated_at_epoch THEN excluded.current_memory_id
250                 ELSE memory_state_keys.current_memory_id
251             END,
252             created_at_epoch = MIN(memory_state_keys.created_at_epoch, excluded.created_at_epoch),
253             updated_at_epoch = MAX(memory_state_keys.updated_at_epoch, excluded.updated_at_epoch)",
254        params![
255            owner_scope,
256            canonical_owner_key,
257            memory_type,
258            decision.state_key,
259            decision.state_key.replace('-', " "),
260            current_memory_id,
261            now_epoch
262        ],
263    )?;
264    conn.query_row(
265        "SELECT id FROM memory_state_keys
266         WHERE owner_scope = ?1
267           AND owner_key = ?2
268           AND memory_type = ?3
269           AND state_key = ?4",
270        params![
271            owner_scope,
272            canonical_owner_key,
273            memory_type,
274            decision.state_key
275        ],
276        |row| row.get(0),
277    )
278    .map_err(Into::into)
279}
280
281fn owner_key_filter(
282    conn: &Connection,
283    owner_scope: &str,
284    owner_key: &str,
285    idx: usize,
286    params: &mut Vec<Box<dyn rusqlite::types::ToSql>>,
287) -> Result<(String, usize)> {
288    if owner_scope == "repo" {
289        crate::project_alias::push_project_value_filter(
290            conn,
291            "sk.owner_key",
292            owner_key,
293            idx,
294            params,
295        )
296    } else {
297        params.push(Box::new(owner_key.to_string()));
298        Ok((format!("sk.owner_key = ?{idx}"), idx + 1))
299    }
300}
301
302fn stable_state_topic_key(topic_key: Option<&str>) -> Option<String> {
303    let topic_key = topic_key?.trim();
304    if topic_key.is_empty() || is_hash_like_topic_key(topic_key) {
305        return None;
306    }
307    let slug = crate::memory::promote::slugify_for_topic(topic_key, 120);
308    if slug.is_empty() {
309        None
310    } else {
311        Some(slug)
312    }
313}
314
315fn derive_compat_preference_state_key(
316    memory_type: &str,
317    title: &str,
318    content: &str,
319) -> Option<StateKeyDecision> {
320    if memory_type != "preference" {
321        return None;
322    }
323    let combined = format!("{title}\n{content}");
324    if mentions_small_reversible_changes(&combined)
325        && mentions_concrete_verification(&combined)
326        && !mentions_cumulative_workflow_subrule(&combined)
327    {
328        return Some(StateKeyDecision {
329            state_key: "small-reversible-verified-changes".to_string(),
330            confidence: 0.95,
331            reason: "preference_domain_small_reversible_verified_changes".to_string(),
332        });
333    }
334    if mentions_verification_status(&combined) && mentions_data_code_separation(&combined) {
335        return Some(StateKeyDecision {
336            state_key: "verification-status-separation".to_string(),
337            confidence: 0.95,
338            reason: "preference_domain_verification_status_separation".to_string(),
339        });
340    }
341    if mentions_data_code_separation(&combined) {
342        return Some(StateKeyDecision {
343            state_key: "data-code-change-separation".to_string(),
344            confidence: 0.90,
345            reason: "preference_domain_data_code_separation".to_string(),
346        });
347    }
348    if mentions_codesign_binary(&combined) {
349        return Some(StateKeyDecision {
350            state_key: "local-rust-binary-codesign-after-cp".to_string(),
351            confidence: 0.90,
352            reason: "preference_domain_codesign_binary".to_string(),
353        });
354    }
355
356    None
357}
358
359fn derive_semantic_state_key(
360    memory_type: &str,
361    _title: &str,
362    content: &str,
363) -> Option<StateKeyDecision> {
364    let prefix = semantic_slot_prefix(memory_type)?;
365    let terms = semantic_slot_terms(content);
366    if terms.len() < MIN_SEMANTIC_SLOT_TERMS {
367        return None;
368    }
369    let mut key_terms = terms
370        .iter()
371        .take(MAX_SEMANTIC_SLOT_TERMS)
372        .cloned()
373        .collect::<Vec<_>>();
374    if terms.len() > MAX_SEMANTIC_SLOT_TERMS {
375        key_terms.push(semantic_terms_signature(&terms));
376    }
377    let raw_key = format!("{prefix}-{}", key_terms.join("-"));
378    let state_key = crate::memory::promote::slugify_for_topic(&raw_key, 120);
379    if state_key.is_empty() {
380        return None;
381    }
382    Some(StateKeyDecision {
383        state_key,
384        confidence: 0.82,
385        reason: "semantic_slot_terms".to_string(),
386    })
387}
388
389fn semantic_slot_prefix(memory_type: &str) -> Option<&'static str> {
390    match memory_type {
391        "architecture" => Some("architecture"),
392        "bugfix" => Some("bugfix"),
393        "decision" => Some("decision"),
394        "discovery" => Some("discovery"),
395        "lesson" => Some("lesson"),
396        "preference" => Some("preference"),
397        "procedure" => Some("procedure"),
398        _ => None,
399    }
400}
401
402fn semantic_slot_terms(text: &str) -> Vec<String> {
403    let mut terms = BTreeSet::new();
404    for raw in text.split(|ch: char| !ch.is_ascii_alphanumeric()) {
405        let Some(term) = normalize_semantic_slot_term(raw) else {
406            continue;
407        };
408        if !is_semantic_slot_stopword(&term) {
409            terms.insert(term);
410        }
411    }
412    add_cjk_semantic_slot_terms(text, &mut terms);
413    terms.into_iter().collect()
414}
415
416fn add_cjk_semantic_slot_terms(text: &str, terms: &mut BTreeSet<String>) {
417    if !text.chars().any(is_cjk) {
418        return;
419    }
420
421    let mut matches = Vec::new();
422    for (cjk, canonical) in CJK_SEMANTIC_SLOT_TERMS {
423        for (start, _) in text.match_indices(cjk) {
424            matches.push((start, start + cjk.len(), cjk.len(), *canonical));
425        }
426    }
427    matches.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
428
429    let mut claimed = Vec::new();
430    for (start, end, _, canonical) in matches {
431        if claimed
432            .iter()
433            .any(|(claimed_start, claimed_end)| start < *claimed_end && end > *claimed_start)
434        {
435            continue;
436        }
437        claimed.push((start, end));
438        let Some(term) = normalize_semantic_slot_term(canonical) else {
439            continue;
440        };
441        if !is_semantic_slot_stopword(&term) {
442            terms.insert(term);
443        }
444    }
445}
446
447fn semantic_terms_signature(terms: &[String]) -> String {
448    let joined = terms.join("\0");
449    format!(
450        "sig{:08x}",
451        crate::db::deterministic_hash(joined.as_bytes()) as u32
452    )
453}
454
455fn normalize_semantic_slot_term(raw: &str) -> Option<String> {
456    let mut term = raw.trim().to_ascii_lowercase();
457    if term.is_empty() {
458        return None;
459    }
460    term = match term.as_str() {
461        "tokenization" | "tokenized" | "tokenize" | "tokenizing" => "tokenizer".to_string(),
462        "summaries" => "summary".to_string(),
463        "memories" => "memory".to_string(),
464        "claims" => "claim".to_string(),
465        "candidates" => "candidate".to_string(),
466        "decisions" => "decision".to_string(),
467        "observations" => "observation".to_string(),
468        "indexes" | "indexed" | "indexing" => "index".to_string(),
469        "tests" | "tested" | "testing" => "test".to_string(),
470        "changes" | "changed" | "changing" => "change".to_string(),
471        "updates" | "updated" | "updating" => "update".to_string(),
472        "embeddings" => "embedding".to_string(),
473        "vectors" => "vector".to_string(),
474        "separately" | "separation" | "separate" | "separating" => "separation".to_string(),
475        "verification" | "verified" | "verifies" | "verify" => "verification".to_string(),
476        "statuses" => "status".to_string(),
477        _ => term,
478    };
479    if term.len() > 4 && term.ends_with('s') && !term.ends_with("ss") {
480        term.pop();
481    }
482    let has_digit = term.chars().any(|ch| ch.is_ascii_digit());
483    if term.len() < 3 && !has_digit {
484        return None;
485    }
486    Some(term)
487}
488
489fn is_semantic_slot_stopword(term: &str) -> bool {
490    matches!(
491        term,
492        "about"
493            | "active"
494            | "add"
495            | "after"
496            | "again"
497            | "against"
498            | "always"
499            | "and"
500            | "are"
501            | "as"
502            | "because"
503            | "before"
504            | "choose"
505            | "current"
506            | "default"
507            | "disable"
508            | "disabled"
509            | "does"
510            | "enable"
511            | "enabled"
512            | "for"
513            | "from"
514            | "has"
515            | "have"
516            | "into"
517            | "keep"
518            | "later"
519            | "must"
520            | "now"
521            | "of"
522            | "only"
523            | "or"
524            | "prefer"
525            | "record"
526            | "remove"
527            | "removed"
528            | "run"
529            | "should"
530            | "stop"
531            | "support"
532            | "supports"
533            | "switch"
534            | "text"
535            | "the"
536            | "this"
537            | "through"
538            | "to"
539            | "use"
540            | "using"
541            | "with"
542            | "without"
543    )
544}
545
546fn is_hash_like_topic_key(topic_key: &str) -> bool {
547    let lower = topic_key.to_ascii_lowercase();
548    let mut parts = lower.rsplitn(2, ['-', '_']);
549    let tail = parts.next().unwrap_or_default();
550    let prefix = parts.next().unwrap_or_default();
551    tail.len() >= 8
552        && tail.chars().all(|ch| ch.is_ascii_hexdigit())
553        && matches!(
554            prefix,
555            "decision"
556                | "discovery"
557                | "preference"
558                | "bugfix"
559                | "lesson"
560                | "procedure"
561                | "architecture"
562        )
563}
564
565fn mentions_verification_status(text: &str) -> bool {
566    let lower = text.to_ascii_lowercase();
567    lower.contains("verification status")
568        || lower.contains("verify status")
569        || (text.contains("验证") && text.contains("状态"))
570}
571
572fn mentions_data_code_separation(text: &str) -> bool {
573    let lower = text.to_ascii_lowercase();
574    let has_data_code = (lower.contains("data") && lower.contains("code"))
575        || (text.contains("数据") && text.contains("代码"));
576    let has_separation = lower.contains("separat")
577        || lower.contains("distinct")
578        || text.contains("分开")
579        || text.contains("分离")
580        || text.contains("隔离");
581    has_data_code && has_separation
582}
583
584fn mentions_codesign_binary(text: &str) -> bool {
585    let lower = text.to_ascii_lowercase();
586    lower.contains("codesign")
587        && (lower.contains("binary")
588            || lower.contains("bin/")
589            || lower.contains("target/release")
590            || lower.contains("cp "))
591}
592
593fn mentions_small_reversible_changes(text: &str) -> bool {
594    let compact_cjk = text
595        .chars()
596        .filter(|ch| !ch.is_whitespace() && !matches!(ch, ',' | ',' | '、' | ';' | ';'))
597        .collect::<String>();
598    if compact_cjk.contains("一处改动一个提交") {
599        return true;
600    }
601
602    let words = normalized_ascii_words(text);
603    words.contains(" one change per commit ") || words.contains(" one change one commit ")
604}
605
606fn mentions_concrete_verification(text: &str) -> bool {
607    let terms = ascii_term_set(text);
608    let words = normalized_ascii_words(text);
609    [
610        "artifact",
611        "build",
612        "checklist",
613        "command",
614        "evidence",
615        "lint",
616        "output",
617        "proof",
618        "test",
619        "typecheck",
620    ]
621    .iter()
622    .any(|term| terms.contains(*term))
623        || words.contains(" job id ")
624        || words.contains(" job ids ")
625        || words.contains(" build artifact ")
626        || words.contains(" build artifacts ")
627        || words.contains(" checklist proof ")
628        || words.contains(" command output ")
629        || words.contains(" test output ")
630        || text.contains("证据")
631        || text.contains("输出")
632        || text.contains("测试")
633}
634
635fn mentions_cumulative_workflow_subrule(text: &str) -> bool {
636    text.split([';', ';']).skip(1).any(|tail| {
637        let terms = ascii_term_set(tail);
638        terms.contains("avoid")
639            || terms.contains("unsafe")
640            || terms.contains("fallback")
641            || terms.contains("checklist")
642            || terms.contains("done")
643            || tail.contains("必须")
644            || tail.contains("只")
645    })
646}
647
648fn ascii_term_set(text: &str) -> BTreeSet<String> {
649    text.split(|ch: char| !ch.is_ascii_alphanumeric())
650        .filter_map(normalize_semantic_slot_term)
651        .collect()
652}
653
654fn normalized_ascii_words(text: &str) -> String {
655    let mut words = String::from(" ");
656    for raw in text.split(|ch: char| !ch.is_ascii_alphanumeric()) {
657        if raw.is_empty() {
658            continue;
659        }
660        words.push_str(&raw.to_ascii_lowercase());
661        words.push(' ');
662    }
663    words
664}
665
666fn is_cjk(ch: char) -> bool {
667    matches!(
668        ch,
669        '\u{4E00}'..='\u{9FFF}' | '\u{3400}'..='\u{4DBF}' | '\u{F900}'..='\u{FAFF}'
670    )
671}
672
673#[cfg(test)]
674mod tests;