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    conn.query_row(
105        "SELECT m.id
106         FROM memory_state_keys sk
107         JOIN memories m ON m.id = sk.current_memory_id
108         WHERE sk.owner_scope = ?1
109           AND sk.owner_key = ?2
110           AND sk.memory_type = ?3
111           AND sk.state_key = ?4
112           AND sk.state_status = 'active'
113           AND m.status = 'active'
114           AND (m.expires_at_epoch IS NULL OR m.expires_at_epoch > ?5)
115         LIMIT 1",
116        params![owner_scope, owner_key, memory_type, state_key, now_epoch],
117        |row| row.get(0),
118    )
119    .optional()
120    .map_err(Into::into)
121}
122
123pub fn active_memory_ids(
124    conn: &Connection,
125    owner_scope: &str,
126    owner_key: &str,
127    memory_type: &str,
128    state_key: &str,
129    now_epoch: i64,
130    require_unexpired: bool,
131) -> Result<Vec<i64>> {
132    let mut stmt = conn.prepare(
133        "SELECT m.id
134         FROM memories m
135         JOIN memory_state_keys sk ON sk.id = m.state_key_id
136         WHERE sk.owner_scope = ?1
137           AND sk.owner_key = ?2
138           AND sk.memory_type = ?3
139           AND sk.state_key = ?4
140           AND sk.state_status = 'active'
141           AND m.status = 'active'
142           AND (
143                ?5 = 0
144                OR m.expires_at_epoch IS NULL
145                OR m.expires_at_epoch > ?6
146           )
147         ORDER BY m.updated_at_epoch DESC, m.id DESC",
148    )?;
149    let rows = stmt.query_map(
150        params![
151            owner_scope,
152            owner_key,
153            memory_type,
154            state_key,
155            if require_unexpired { 1_i64 } else { 0_i64 },
156            now_epoch
157        ],
158        |row| row.get(0),
159    )?;
160    crate::db::query::collect_rows(rows)
161}
162
163pub fn attach_current_memory(
164    conn: &Connection,
165    memory_id: i64,
166    owner_scope: &str,
167    owner_key: &str,
168    memory_type: &str,
169    decision: &StateKeyDecision,
170    now_epoch: i64,
171) -> Result<i64> {
172    let state_key_id = upsert_state_key(
173        conn,
174        owner_scope,
175        owner_key,
176        memory_type,
177        decision,
178        Some(memory_id),
179        now_epoch,
180    )?;
181    conn.execute(
182        "UPDATE memories SET state_key_id = ?1 WHERE id = ?2",
183        params![state_key_id, memory_id],
184    )?;
185    Ok(state_key_id)
186}
187
188pub fn ensure_state_key(
189    conn: &Connection,
190    owner_scope: &str,
191    owner_key: &str,
192    memory_type: &str,
193    decision: &StateKeyDecision,
194    created_at_epoch: i64,
195) -> Result<i64> {
196    upsert_state_key(
197        conn,
198        owner_scope,
199        owner_key,
200        memory_type,
201        decision,
202        None,
203        created_at_epoch,
204    )
205}
206
207fn upsert_state_key(
208    conn: &Connection,
209    owner_scope: &str,
210    owner_key: &str,
211    memory_type: &str,
212    decision: &StateKeyDecision,
213    current_memory_id: Option<i64>,
214    now_epoch: i64,
215) -> Result<i64> {
216    conn.execute(
217        "INSERT INTO memory_state_keys
218         (owner_scope, owner_key, memory_type, state_key, state_label, state_status,
219          current_memory_id, created_at_epoch, updated_at_epoch)
220         VALUES (?1, ?2, ?3, ?4, ?5, 'active', ?6, ?7, ?7)
221         ON CONFLICT(owner_scope, owner_key, memory_type, state_key)
222         DO UPDATE SET
223             state_label = COALESCE(excluded.state_label, memory_state_keys.state_label),
224             state_status = 'active',
225             current_memory_id = CASE
226                 WHEN excluded.current_memory_id IS NULL THEN memory_state_keys.current_memory_id
227                 WHEN memory_state_keys.current_memory_id IS NULL THEN excluded.current_memory_id
228                 WHEN excluded.updated_at_epoch >= memory_state_keys.updated_at_epoch THEN excluded.current_memory_id
229                 ELSE memory_state_keys.current_memory_id
230             END,
231             created_at_epoch = MIN(memory_state_keys.created_at_epoch, excluded.created_at_epoch),
232             updated_at_epoch = MAX(memory_state_keys.updated_at_epoch, excluded.updated_at_epoch)",
233        params![
234            owner_scope,
235            owner_key,
236            memory_type,
237            decision.state_key,
238            decision.state_key.replace('-', " "),
239            current_memory_id,
240            now_epoch
241        ],
242    )?;
243    conn.query_row(
244        "SELECT id FROM memory_state_keys
245         WHERE owner_scope = ?1
246           AND owner_key = ?2
247           AND memory_type = ?3
248           AND state_key = ?4",
249        params![owner_scope, owner_key, memory_type, decision.state_key],
250        |row| row.get(0),
251    )
252    .map_err(Into::into)
253}
254
255fn stable_state_topic_key(topic_key: Option<&str>) -> Option<String> {
256    let topic_key = topic_key?.trim();
257    if topic_key.is_empty() || is_hash_like_topic_key(topic_key) {
258        return None;
259    }
260    let slug = crate::memory::promote::slugify_for_topic(topic_key, 120);
261    if slug.is_empty() {
262        None
263    } else {
264        Some(slug)
265    }
266}
267
268fn derive_compat_preference_state_key(
269    memory_type: &str,
270    title: &str,
271    content: &str,
272) -> Option<StateKeyDecision> {
273    if memory_type != "preference" {
274        return None;
275    }
276    let combined = format!("{title}\n{content}");
277    if mentions_small_reversible_changes(&combined)
278        && mentions_concrete_verification(&combined)
279        && !mentions_cumulative_workflow_subrule(&combined)
280    {
281        return Some(StateKeyDecision {
282            state_key: "small-reversible-verified-changes".to_string(),
283            confidence: 0.95,
284            reason: "preference_domain_small_reversible_verified_changes".to_string(),
285        });
286    }
287    if mentions_verification_status(&combined) && mentions_data_code_separation(&combined) {
288        return Some(StateKeyDecision {
289            state_key: "verification-status-separation".to_string(),
290            confidence: 0.95,
291            reason: "preference_domain_verification_status_separation".to_string(),
292        });
293    }
294    if mentions_data_code_separation(&combined) {
295        return Some(StateKeyDecision {
296            state_key: "data-code-change-separation".to_string(),
297            confidence: 0.90,
298            reason: "preference_domain_data_code_separation".to_string(),
299        });
300    }
301    if mentions_codesign_binary(&combined) {
302        return Some(StateKeyDecision {
303            state_key: "local-rust-binary-codesign-after-cp".to_string(),
304            confidence: 0.90,
305            reason: "preference_domain_codesign_binary".to_string(),
306        });
307    }
308
309    None
310}
311
312fn derive_semantic_state_key(
313    memory_type: &str,
314    _title: &str,
315    content: &str,
316) -> Option<StateKeyDecision> {
317    let prefix = semantic_slot_prefix(memory_type)?;
318    let terms = semantic_slot_terms(content);
319    if terms.len() < MIN_SEMANTIC_SLOT_TERMS {
320        return None;
321    }
322    let mut key_terms = terms
323        .iter()
324        .take(MAX_SEMANTIC_SLOT_TERMS)
325        .cloned()
326        .collect::<Vec<_>>();
327    if terms.len() > MAX_SEMANTIC_SLOT_TERMS {
328        key_terms.push(semantic_terms_signature(&terms));
329    }
330    let raw_key = format!("{prefix}-{}", key_terms.join("-"));
331    let state_key = crate::memory::promote::slugify_for_topic(&raw_key, 120);
332    if state_key.is_empty() {
333        return None;
334    }
335    Some(StateKeyDecision {
336        state_key,
337        confidence: 0.82,
338        reason: "semantic_slot_terms".to_string(),
339    })
340}
341
342fn semantic_slot_prefix(memory_type: &str) -> Option<&'static str> {
343    match memory_type {
344        "architecture" => Some("architecture"),
345        "bugfix" => Some("bugfix"),
346        "decision" => Some("decision"),
347        "discovery" => Some("discovery"),
348        "lesson" => Some("lesson"),
349        "preference" => Some("preference"),
350        "procedure" => Some("procedure"),
351        _ => None,
352    }
353}
354
355fn semantic_slot_terms(text: &str) -> Vec<String> {
356    let mut terms = BTreeSet::new();
357    for raw in text.split(|ch: char| !ch.is_ascii_alphanumeric()) {
358        let Some(term) = normalize_semantic_slot_term(raw) else {
359            continue;
360        };
361        if !is_semantic_slot_stopword(&term) {
362            terms.insert(term);
363        }
364    }
365    add_cjk_semantic_slot_terms(text, &mut terms);
366    terms.into_iter().collect()
367}
368
369fn add_cjk_semantic_slot_terms(text: &str, terms: &mut BTreeSet<String>) {
370    if !text.chars().any(is_cjk) {
371        return;
372    }
373
374    let mut matches = Vec::new();
375    for (cjk, canonical) in CJK_SEMANTIC_SLOT_TERMS {
376        for (start, _) in text.match_indices(cjk) {
377            matches.push((start, start + cjk.len(), cjk.len(), *canonical));
378        }
379    }
380    matches.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
381
382    let mut claimed = Vec::new();
383    for (start, end, _, canonical) in matches {
384        if claimed
385            .iter()
386            .any(|(claimed_start, claimed_end)| start < *claimed_end && end > *claimed_start)
387        {
388            continue;
389        }
390        claimed.push((start, end));
391        let Some(term) = normalize_semantic_slot_term(canonical) else {
392            continue;
393        };
394        if !is_semantic_slot_stopword(&term) {
395            terms.insert(term);
396        }
397    }
398}
399
400fn semantic_terms_signature(terms: &[String]) -> String {
401    let joined = terms.join("\0");
402    format!(
403        "sig{:08x}",
404        crate::db::deterministic_hash(joined.as_bytes()) as u32
405    )
406}
407
408fn normalize_semantic_slot_term(raw: &str) -> Option<String> {
409    let mut term = raw.trim().to_ascii_lowercase();
410    if term.is_empty() {
411        return None;
412    }
413    term = match term.as_str() {
414        "tokenization" | "tokenized" | "tokenize" | "tokenizing" => "tokenizer".to_string(),
415        "summaries" => "summary".to_string(),
416        "memories" => "memory".to_string(),
417        "claims" => "claim".to_string(),
418        "candidates" => "candidate".to_string(),
419        "decisions" => "decision".to_string(),
420        "observations" => "observation".to_string(),
421        "indexes" | "indexed" | "indexing" => "index".to_string(),
422        "tests" | "tested" | "testing" => "test".to_string(),
423        "changes" | "changed" | "changing" => "change".to_string(),
424        "updates" | "updated" | "updating" => "update".to_string(),
425        "embeddings" => "embedding".to_string(),
426        "vectors" => "vector".to_string(),
427        "separately" | "separation" | "separate" | "separating" => "separation".to_string(),
428        "verification" | "verified" | "verifies" | "verify" => "verification".to_string(),
429        "statuses" => "status".to_string(),
430        _ => term,
431    };
432    if term.len() > 4 && term.ends_with('s') && !term.ends_with("ss") {
433        term.pop();
434    }
435    let has_digit = term.chars().any(|ch| ch.is_ascii_digit());
436    if term.len() < 3 && !has_digit {
437        return None;
438    }
439    Some(term)
440}
441
442fn is_semantic_slot_stopword(term: &str) -> bool {
443    matches!(
444        term,
445        "about"
446            | "active"
447            | "add"
448            | "after"
449            | "again"
450            | "against"
451            | "always"
452            | "and"
453            | "are"
454            | "as"
455            | "because"
456            | "before"
457            | "choose"
458            | "current"
459            | "default"
460            | "disable"
461            | "disabled"
462            | "does"
463            | "enable"
464            | "enabled"
465            | "for"
466            | "from"
467            | "has"
468            | "have"
469            | "into"
470            | "keep"
471            | "later"
472            | "must"
473            | "now"
474            | "of"
475            | "only"
476            | "or"
477            | "prefer"
478            | "record"
479            | "remove"
480            | "removed"
481            | "run"
482            | "should"
483            | "stop"
484            | "support"
485            | "supports"
486            | "switch"
487            | "text"
488            | "the"
489            | "this"
490            | "through"
491            | "to"
492            | "use"
493            | "using"
494            | "with"
495            | "without"
496    )
497}
498
499fn is_hash_like_topic_key(topic_key: &str) -> bool {
500    let lower = topic_key.to_ascii_lowercase();
501    let mut parts = lower.rsplitn(2, ['-', '_']);
502    let tail = parts.next().unwrap_or_default();
503    let prefix = parts.next().unwrap_or_default();
504    tail.len() >= 8
505        && tail.chars().all(|ch| ch.is_ascii_hexdigit())
506        && matches!(
507            prefix,
508            "decision"
509                | "discovery"
510                | "preference"
511                | "bugfix"
512                | "lesson"
513                | "procedure"
514                | "architecture"
515        )
516}
517
518fn mentions_verification_status(text: &str) -> bool {
519    let lower = text.to_ascii_lowercase();
520    lower.contains("verification status")
521        || lower.contains("verify status")
522        || (text.contains("验证") && text.contains("状态"))
523}
524
525fn mentions_data_code_separation(text: &str) -> bool {
526    let lower = text.to_ascii_lowercase();
527    let has_data_code = (lower.contains("data") && lower.contains("code"))
528        || (text.contains("数据") && text.contains("代码"));
529    let has_separation = lower.contains("separat")
530        || lower.contains("distinct")
531        || text.contains("分开")
532        || text.contains("分离")
533        || text.contains("隔离");
534    has_data_code && has_separation
535}
536
537fn mentions_codesign_binary(text: &str) -> bool {
538    let lower = text.to_ascii_lowercase();
539    lower.contains("codesign")
540        && (lower.contains("binary")
541            || lower.contains("bin/")
542            || lower.contains("target/release")
543            || lower.contains("cp "))
544}
545
546fn mentions_small_reversible_changes(text: &str) -> bool {
547    let compact_cjk = text
548        .chars()
549        .filter(|ch| !ch.is_whitespace() && !matches!(ch, ',' | ',' | '、' | ';' | ';'))
550        .collect::<String>();
551    if compact_cjk.contains("一处改动一个提交") {
552        return true;
553    }
554
555    let words = normalized_ascii_words(text);
556    words.contains(" one change per commit ") || words.contains(" one change one commit ")
557}
558
559fn mentions_concrete_verification(text: &str) -> bool {
560    let terms = ascii_term_set(text);
561    let words = normalized_ascii_words(text);
562    [
563        "artifact",
564        "build",
565        "checklist",
566        "command",
567        "evidence",
568        "lint",
569        "output",
570        "proof",
571        "test",
572        "typecheck",
573    ]
574    .iter()
575    .any(|term| terms.contains(*term))
576        || words.contains(" job id ")
577        || words.contains(" job ids ")
578        || words.contains(" build artifact ")
579        || words.contains(" build artifacts ")
580        || words.contains(" checklist proof ")
581        || words.contains(" command output ")
582        || words.contains(" test output ")
583        || text.contains("证据")
584        || text.contains("输出")
585        || text.contains("测试")
586}
587
588fn mentions_cumulative_workflow_subrule(text: &str) -> bool {
589    text.split([';', ';']).skip(1).any(|tail| {
590        let terms = ascii_term_set(tail);
591        terms.contains("avoid")
592            || terms.contains("unsafe")
593            || terms.contains("fallback")
594            || terms.contains("checklist")
595            || terms.contains("done")
596            || tail.contains("必须")
597            || tail.contains("只")
598    })
599}
600
601fn ascii_term_set(text: &str) -> BTreeSet<String> {
602    text.split(|ch: char| !ch.is_ascii_alphanumeric())
603        .filter_map(normalize_semantic_slot_term)
604        .collect()
605}
606
607fn normalized_ascii_words(text: &str) -> String {
608    let mut words = String::from(" ");
609    for raw in text.split(|ch: char| !ch.is_ascii_alphanumeric()) {
610        if raw.is_empty() {
611            continue;
612        }
613        words.push_str(&raw.to_ascii_lowercase());
614        words.push(' ');
615    }
616    words
617}
618
619fn is_cjk(ch: char) -> bool {
620    matches!(
621        ch,
622        '\u{4E00}'..='\u{9FFF}' | '\u{3400}'..='\u{4DBF}' | '\u{F900}'..='\u{FAFF}'
623    )
624}
625
626#[cfg(test)]
627mod tests;