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;