1use crate::config::ProjectConfig;
2use crate::domain::note::tokenize;
3use crate::domain::{
4 ConfidenceTier, LifecycleCandidate, MatchedModule, MatchedProject, MatchedScene,
5 MemoryLifecycleState, MemoryRecord, MemoryScope, MemorySourceKind, Note, RouteInput,
6 ScoreContribution, ScoreSource,
7};
8use std::collections::BTreeSet;
9
10fn extract_tags(value: &serde_json::Value) -> Vec<&str> {
11 match value {
12 serde_json::Value::String(tag) => vec![tag.as_str()],
13 serde_json::Value::Array(tags) => tags.iter().filter_map(|tag| tag.as_str()).collect(),
14 _ => Vec::new(),
15 }
16}
17
18struct Accumulator {
25 score: i32,
26 reasons: Vec<String>,
27 breakdown: Vec<ScoreContribution>,
28}
29
30impl Accumulator {
31 fn new() -> Self {
32 Self {
33 score: 0,
34 reasons: Vec::new(),
35 breakdown: Vec::new(),
36 }
37 }
38
39 fn add(&mut self, source: ScoreSource, field: &str, term: &str, weight: i32, reason: String) {
40 self.score += weight;
41 if !self.reasons.iter().any(|existing| existing == &reason) {
42 self.reasons.push(reason);
43 }
44 self.breakdown.push(ScoreContribution {
45 source,
46 field: field.to_string(),
47 term: term.to_string(),
48 weight,
49 });
50 }
51}
52
53fn apply_structured_frontmatter_score(
54 acc: &mut Accumulator,
55 note: &Note,
56 project: Option<&MatchedProject>,
57) {
58 if let Some(memory_type) = note.memory_type() {
59 let delta = match memory_type {
60 "constraint" => 14,
61 "decision" => 12,
62 "project" => 10,
63 "preference" => 9,
64 "incident" => 8,
65 "workflow" => 7,
66 "pattern" => 6,
67 "person" => 4,
68 "session" => -2,
69 _ => 0,
70 };
71 if delta != 0 {
72 acc.add(
73 ScoreSource::MemoryType,
74 "memory_type",
75 memory_type,
76 delta,
77 format!("memory_type {memory_type} adjusted score {delta:+}"),
78 );
79 }
80 }
81
82 if let Some(project_id) = note.frontmatter_str("project_id")
83 && let Some(project) = project
84 && project_id == project.id
85 {
86 acc.add(
87 ScoreSource::Frontmatter,
88 "project_id",
89 &project.id,
90 12,
91 format!("frontmatter project_id matched {}", project.id),
92 );
93 }
94
95 if note.source_of_truth() {
96 acc.add(
97 ScoreSource::Frontmatter,
98 "source_of_truth",
99 "true",
100 10,
101 "source_of_truth boosted retrieval".to_string(),
102 );
103 }
104
105 if let Some(priority) = note.frontmatter_str("retrieval_priority") {
106 let delta = match priority {
107 "high" => 10,
108 "medium" => 4,
109 "low" => -4,
110 _ => 0,
111 };
112 if delta != 0 {
113 acc.add(
114 ScoreSource::Frontmatter,
115 "retrieval_priority",
116 priority,
117 delta,
118 format!("retrieval_priority {priority} adjusted score {delta:+}"),
119 );
120 }
121 }
122
123 if let Some(sensitivity) = note.sensitivity() {
124 let delta = match sensitivity {
125 "public" => 2,
126 "internal" => 0,
127 "confidential" => -4,
128 "secret" => -12,
129 _ => 0,
130 };
131 if delta != 0 {
132 acc.add(
133 ScoreSource::Sensitivity,
134 "sensitivity",
135 sensitivity,
136 delta,
137 format!("sensitivity {sensitivity} adjusted score {delta:+}"),
138 );
139 } else {
140 if !acc
143 .reasons
144 .iter()
145 .any(|r| r == &format!("sensitivity {sensitivity} acknowledged"))
146 {
147 acc.reasons
148 .push(format!("sensitivity {sensitivity} acknowledged"));
149 }
150 }
151 }
152}
153
154fn score_named_match(acc: &mut Accumulator, note: &Note, label: &str, term: &str) {
155 if note.search_index.matches_title(term) {
156 acc.add(
157 ScoreSource::NamedMatch,
158 "title",
159 term,
160 18,
161 format!("{label} matched title {term}"),
162 );
163 }
164 if note.search_index.matches_heading(term) {
165 acc.add(
166 ScoreSource::NamedMatch,
167 "heading",
168 term,
169 14,
170 format!("{label} matched heading {term}"),
171 );
172 }
173 if note.search_index.matches_wikilink(term) {
174 acc.add(
175 ScoreSource::NamedMatch,
176 "wikilink",
177 term,
178 12,
179 format!("{label} matched wikilink {term}"),
180 );
181 }
182 if note.search_index.matches_path(term) {
183 acc.add(
184 ScoreSource::NamedMatch,
185 "path",
186 term,
187 10,
188 format!("{label} matched path {term}"),
189 );
190 }
191 if note.search_index.matches_body(term) {
192 acc.add(
193 ScoreSource::NamedMatch,
194 "body",
195 term,
196 8,
197 format!("{label} matched body {term}"),
198 );
199 }
200}
201
202fn confidence_label(tier: ConfidenceTier) -> &'static str {
203 match tier {
204 ConfidenceTier::High => "high",
205 ConfidenceTier::Medium => "medium",
206 ConfidenceTier::Low => "low",
207 }
208}
209
210fn derive_note_confidence(note: &Note) -> ConfidenceTier {
211 if note.source_of_truth() {
212 return ConfidenceTier::High;
213 }
214 let sensitivity = note.sensitivity().unwrap_or("internal");
215 if sensitivity == "secret" {
216 return ConfidenceTier::Low;
217 }
218 let priority = note
219 .frontmatter_str("retrieval_priority")
220 .unwrap_or("medium");
221 if priority == "low" {
222 return ConfidenceTier::Low;
223 }
224 let memory_type = note.memory_type().unwrap_or("");
225 if priority == "high" && matches!(memory_type, "constraint" | "decision" | "project") {
226 return ConfidenceTier::High;
227 }
228 ConfidenceTier::Medium
229}
230
231fn derive_lifecycle_confidence(
232 state: MemoryLifecycleState,
233 source_kind: MemorySourceKind,
234 sensitivity: Option<&str>,
235) -> ConfidenceTier {
236 let base = match state {
237 MemoryLifecycleState::Canonical => ConfidenceTier::High,
238 MemoryLifecycleState::Accepted => match source_kind {
239 MemorySourceKind::Manual => ConfidenceTier::High,
240 _ => ConfidenceTier::Medium,
241 },
242 MemoryLifecycleState::Candidate => ConfidenceTier::Low,
243 _ => ConfidenceTier::Medium,
244 };
245 if sensitivity == Some("secret") {
246 match base {
247 ConfidenceTier::High => return ConfidenceTier::Medium,
248 _ => return ConfidenceTier::Low,
249 }
250 }
251 base
252}
253
254fn query_terms(input: &RouteInput) -> (BTreeSet<String>, BTreeSet<String>) {
255 let task_terms = tokenize(&input.task);
256 let file_terms = input
257 .files
258 .iter()
259 .flat_map(|file| tokenize(file))
260 .filter(|segment| segment.chars().count() >= 3)
261 .collect();
262 (task_terms, file_terms)
263}
264
265pub fn score_note(
266 project_config: Option<&ProjectConfig>,
267 project: Option<&MatchedProject>,
268 modules: &[MatchedModule],
269 scenes: &[MatchedScene],
270 note: &Note,
271 input: &RouteInput,
272) -> (i32, Vec<String>, Vec<ScoreContribution>, ConfidenceTier) {
273 let mut acc = Accumulator::new();
274 let (task_terms, file_terms) = query_terms(input);
275
276 apply_structured_frontmatter_score(&mut acc, note, project);
277
278 if let Some(project) = project {
279 score_named_match(&mut acc, note, "project", &project.id);
280 score_named_match(&mut acc, note, "project", &project.name);
281 }
282
283 if let Some(project_config) = project_config {
284 for root in &project_config.note_roots {
285 if note.relative_path.starts_with(root) {
286 acc.add(
287 ScoreSource::DefaultTag,
288 "note_root",
289 root,
290 10,
291 format!("note under preferred root {root}"),
292 );
293 }
294 }
295 let note_tags = note
296 .frontmatter
297 .get("tags")
298 .into_iter()
299 .flat_map(extract_tags);
300 for tag in &project_config.default_tags {
301 if note_tags.clone().any(|note_tag| note_tag == tag.as_str()) {
302 acc.add(
303 ScoreSource::DefaultTag,
304 "tag",
305 tag,
306 6,
307 format!("matched frontmatter tag {tag}"),
308 );
309 }
310 }
311 }
312
313 for module in modules {
314 score_named_match(&mut acc, note, "module", &module.id);
315 }
316
317 for scene in scenes {
318 score_named_match(&mut acc, note, "scene", &scene.id);
319 if scene
320 .preferred_notes
321 .iter()
322 .any(|preferred| preferred == ¬e.relative_path)
323 {
324 acc.add(
325 ScoreSource::ScenePreferred,
326 "preferred_note",
327 &scene.id,
328 25,
329 format!("preferred by scene {}", scene.id),
330 );
331 }
332 }
333
334 for term in file_terms {
335 if note.search_index.matches_title(&term) {
336 acc.add(
337 ScoreSource::TaskToken,
338 "title",
339 &term,
340 7,
341 format!("matched file segment {term} in title"),
342 );
343 }
344 if note.search_index.matches_heading(&term) {
345 acc.add(
346 ScoreSource::TaskToken,
347 "heading",
348 &term,
349 5,
350 format!("matched file segment {term} in heading"),
351 );
352 }
353 if note.search_index.matches_wikilink(&term) {
354 acc.add(
355 ScoreSource::TaskToken,
356 "wikilink",
357 &term,
358 5,
359 format!("matched file segment {term} in wikilink"),
360 );
361 }
362 if note.search_index.matches_body(&term) || note.search_index.matches_path(&term) {
363 acc.add(
364 ScoreSource::TaskToken,
365 "body_or_path",
366 &term,
367 3,
368 format!("matched file segment {term}"),
369 );
370 }
371 }
372
373 for token in task_terms {
374 if note.search_index.matches_title(&token) {
375 acc.add(
376 ScoreSource::TaskToken,
377 "title",
378 &token,
379 7,
380 format!("matched task token {token} in title"),
381 );
382 }
383 if note.search_index.matches_heading(&token) {
384 acc.add(
385 ScoreSource::TaskToken,
386 "heading",
387 &token,
388 5,
389 format!("matched task token {token} in heading"),
390 );
391 }
392 if note.search_index.matches_wikilink(&token) {
393 acc.add(
394 ScoreSource::TaskToken,
395 "wikilink",
396 &token,
397 5,
398 format!("matched task token {token} in wikilink"),
399 );
400 }
401 if note.search_index.matches_body(&token) || note.search_index.matches_path(&token) {
402 acc.add(
403 ScoreSource::TaskToken,
404 "body_or_path",
405 &token,
406 3,
407 format!("matched task token {token}"),
408 );
409 }
410 }
411
412 let confidence = derive_note_confidence(note);
413 let confidence_weight = match confidence {
414 ConfidenceTier::High => 6,
415 ConfidenceTier::Medium => 0,
416 ConfidenceTier::Low => -4,
417 };
418 if confidence_weight != 0 {
419 acc.add(
420 ScoreSource::Confidence,
421 "confidence",
422 confidence_label(confidence),
423 confidence_weight,
424 format!(
425 "confidence={} adjusted score {:+}",
426 confidence_label(confidence),
427 confidence_weight
428 ),
429 );
430 }
431
432 (acc.score, acc.reasons, acc.breakdown, confidence)
433}
434
435fn lifecycle_memory_type_weight(memory_type: &str) -> i32 {
436 match memory_type {
437 "knowledge" => 16,
438 "constraint" => 14,
439 "decision" => 12,
440 "project" => 10,
441 "preference" => 9,
442 "incident" => 8,
443 "workflow" => 7,
444 "pattern" => 6,
445 "person" => 4,
446 "session" => -2,
447 _ => 0,
448 }
449}
450
451pub fn score_lifecycle_candidate(
460 project: Option<&MatchedProject>,
461 record_id: &str,
462 record: &MemoryRecord,
463 input: &RouteInput,
464 reference_map: Option<&crate::reference_tracker::ReferenceMap>,
465 existing_records: Option<&[(String, MemoryRecord)]>,
466) -> Option<LifecycleCandidate> {
467 if matches!(
469 record.state,
470 MemoryLifecycleState::Archived | MemoryLifecycleState::Draft
471 ) {
472 return None;
473 }
474
475 let mut score = 0;
476 let mut reasons: Vec<String> = Vec::new();
477
478 match record.scope {
479 MemoryScope::Project => {
480 let project_match = record
481 .project_id
482 .as_deref()
483 .zip(project.map(|matched| matched.id.as_str()))
484 .map(|(record_pid, matched_pid)| record_pid == matched_pid)
485 .unwrap_or(false);
486 if !project_match {
487 return None;
488 }
489 score += 10;
490 reasons.push(format!(
491 "scope=project matched project_id {}",
492 record.project_id.clone().unwrap_or_default()
493 ));
494 }
495 MemoryScope::Workspace => {
496 let project_match = record
498 .project_id
499 .as_deref()
500 .zip(project.map(|matched| matched.id.as_str()))
501 .map(|(record_pid, matched_pid)| record_pid == matched_pid)
502 .unwrap_or(false);
503 if !project_match {
504 return None;
505 }
506 score += 6;
507 reasons.push("scope=workspace matched project proxy".to_string());
508 }
509 MemoryScope::User | MemoryScope::Agent | MemoryScope::Team => {
510 score += 4;
511 reasons.push(format!(
512 "scope={} kept as cross-project",
513 scope_label(record.scope)
514 ));
515 }
516 }
517
518 let memory_type_weight = lifecycle_memory_type_weight(&record.memory_type);
519 if memory_type_weight != 0 {
520 score += memory_type_weight;
521 reasons.push(format!(
522 "memory_type {} adjusted score {:+}",
523 record.memory_type, memory_type_weight
524 ));
525 }
526
527 if matches!(record.state, MemoryLifecycleState::Canonical) {
528 score += 3;
529 reasons.push("state=canonical boosted".to_string());
530 }
531
532 let task_tokens = tokenize(&input.task);
533 let file_tokens: BTreeSet<String> = input
534 .files
535 .iter()
536 .flat_map(|file| tokenize(file))
537 .filter(|segment| segment.chars().count() >= 3)
538 .collect();
539 let title_lc = record.title.to_lowercase();
540 let summary_lc = record.summary.to_lowercase();
541 let task_lc = input.task.to_lowercase();
542
543 if title_lc.len() >= 4 && (task_lc.contains(&title_lc) || title_lc.contains(&task_lc)) {
544 score += 6;
545 reasons.push("title substring matched task".to_string());
546 }
547
548 let mut token_bonus = 0_i32;
549 for token in task_tokens.iter().chain(file_tokens.iter()) {
550 if token.is_empty() {
551 continue;
552 }
553 let needle = token.to_lowercase();
554 if title_lc.contains(&needle) || summary_lc.contains(&needle) {
555 token_bonus += 4;
556 reasons.push(format!("task/file token {token} matched lifecycle text"));
557 if token_bonus >= 12 {
558 break;
559 }
560 }
561 }
562 score += token_bonus.min(12);
563
564 let all_query_tokens: BTreeSet<String> = task_tokens
566 .iter()
567 .chain(file_tokens.iter())
568 .map(|t| t.to_lowercase())
569 .collect();
570
571 let mut entities_bonus = 0_i32;
573 for entity in &record.entities {
574 let entity_lc = entity.to_lowercase();
575 if all_query_tokens
576 .iter()
577 .any(|t| entity_lc.contains(t) || t.contains(&entity_lc))
578 {
579 entities_bonus += 6;
580 reasons.push(format!("entity {entity} matched query token"));
581 if entities_bonus >= 18 {
582 break;
583 }
584 }
585 }
586 score += entities_bonus.min(18);
587
588 let mut tags_bonus = 0_i32;
590 for tag in &record.tags {
591 let tag_lc = tag.to_lowercase();
592 if all_query_tokens
593 .iter()
594 .any(|t| tag_lc.contains(t) || t.contains(&tag_lc))
595 {
596 tags_bonus += 4;
597 reasons.push(format!("tag {tag} matched query token"));
598 if tags_bonus >= 12 {
599 break;
600 }
601 }
602 }
603 score += tags_bonus.min(12);
604
605 if record.memory_type == "knowledge" && record.tags.iter().any(|t| t == "domain:user-profile") {
608 score += 8;
609 reasons.push("knowledge domain:user-profile always-on boost".to_string());
610 }
611
612 let mut triggers_bonus = 0_i32;
614 for trigger in &record.triggers {
615 let trigger_lc = trigger.to_lowercase();
616 if all_query_tokens.contains(&trigger_lc) {
617 triggers_bonus += 8;
618 reasons.push(format!("trigger {trigger} exact-matched query token"));
619 if triggers_bonus >= 16 {
620 break;
621 }
622 }
623 }
624 score += triggers_bonus.min(16);
625
626 let mut files_bonus = 0_i32;
628 for related_file in &record.related_files {
629 let rf_lc = related_file.to_lowercase();
630 if input.files.iter().any(|f| {
631 let f_lc = f.to_lowercase();
632 f_lc.contains(&rf_lc) || rf_lc.contains(&f_lc)
633 }) {
634 files_bonus += 10;
635 reasons.push(format!("related_file {related_file} matched input file"));
636 if files_bonus >= 20 {
637 break;
638 }
639 }
640 }
641 score += files_bonus.min(20);
642
643 if let Some(matched_project) = project
645 && record
646 .applies_to
647 .iter()
648 .any(|a| a.eq_ignore_ascii_case(&matched_project.id))
649 {
650 score += 8;
651 reasons.push(format!("applies_to matched project {}", matched_project.id));
652 }
653
654 if score <= 0 {
655 return None;
656 }
657
658 let confidence = derive_lifecycle_confidence(
659 record.state,
660 record.origin.source_kind,
661 record.sensitivity.as_deref(),
662 );
663 let confidence_weight = match confidence {
664 ConfidenceTier::High => 5,
665 ConfidenceTier::Medium => 0,
666 ConfidenceTier::Low => -3,
667 };
668 if confidence_weight != 0 {
669 score += confidence_weight;
670 reasons.push(format!(
671 "confidence={} adjusted score {:+}",
672 confidence_label(confidence),
673 confidence_weight
674 ));
675 }
676
677 if let Some(ref_map) = reference_map {
678 let age = ref_map
679 .records
680 .get(record_id)
681 .and_then(crate::reference_tracker::age_days);
682 let penalty = crate::reference_tracker::staleness_penalty(age);
683 if penalty != 0 {
684 score += penalty;
685 reasons.push(format!(
686 "staleness penalty {:+} (age={} days)",
687 penalty,
688 age.unwrap_or(0)
689 ));
690 }
691 }
692
693 let contradicts: Vec<String> = if let Some(existing) = existing_records {
695 crate::contradiction::detect(&record.summary, &record.memory_type, existing)
696 .into_iter()
697 .map(|hit| hit.existing_record_id)
698 .collect()
699 } else {
700 Vec::new()
701 };
702
703 Some(LifecycleCandidate {
704 record_id: record_id.to_string(),
705 title: record.title.clone(),
706 summary: record.summary.clone(),
707 memory_type: record.memory_type.clone(),
708 scope: record.scope,
709 state: record.state,
710 score,
711 reasons,
712 project_id: record.project_id.clone(),
713 confidence,
714 contradicts,
715 })
716}
717
718fn scope_label(scope: MemoryScope) -> &'static str {
719 match scope {
720 MemoryScope::User => "user",
721 MemoryScope::Project => "project",
722 MemoryScope::Workspace => "workspace",
723 MemoryScope::Agent => "agent",
724 MemoryScope::Team => "team",
725 }
726}
727
728#[cfg(test)]
729mod tests {
730 use super::{score_lifecycle_candidate, score_note};
731 use crate::domain::{
732 MatchedModule, MatchedProject, MemoryLifecycleState, MemoryOrigin, MemoryRecord,
733 MemoryScope, MemorySourceKind, Note, OutputFormat, RouteInput, Section, TargetTool,
734 };
735 use serde_json::json;
736 use std::collections::BTreeMap;
737 use std::path::PathBuf;
738
739 fn make_input(task: &str, files: &[&str]) -> RouteInput {
740 RouteInput {
741 task: task.to_string(),
742 cwd: PathBuf::from("/tmp/repo"),
743 files: files.iter().map(|value| value.to_string()).collect(),
744 target: TargetTool::Codex,
745 format: OutputFormat::Prompt,
746 }
747 }
748
749 fn make_record(
750 title: &str,
751 summary: &str,
752 memory_type: &str,
753 scope: MemoryScope,
754 project_id: Option<&str>,
755 state: MemoryLifecycleState,
756 ) -> MemoryRecord {
757 MemoryRecord {
758 title: title.to_string(),
759 summary: summary.to_string(),
760 memory_type: memory_type.to_string(),
761 scope,
762 state,
763 origin: MemoryOrigin {
764 source_kind: MemorySourceKind::Manual,
765 source_ref: "test".to_string(),
766 },
767 project_id: project_id.map(|v| v.to_string()),
768 user_id: None,
769 sensitivity: None,
770 entities: Vec::new(),
771 tags: Vec::new(),
772 triggers: Vec::new(),
773 related_files: Vec::new(),
774 related_records: Vec::new(),
775 supersedes: None,
776 applies_to: Vec::new(),
777 valid_until: None,
778 }
779 }
780
781 fn make_note(
782 relative_path: &str,
783 title: &str,
784 heading: Option<&str>,
785 content: &str,
786 wikilinks: &[&str],
787 ) -> Note {
788 Note::new(
789 PathBuf::from(format!("/tmp/vault/{relative_path}")),
790 relative_path.to_string(),
791 title.to_string(),
792 BTreeMap::new(),
793 vec![Section {
794 heading: heading.map(|value| value.to_string()),
795 level: usize::from(heading.is_some()),
796 content: content.to_string(),
797 }],
798 wikilinks.iter().map(|value| value.to_string()).collect(),
799 content.to_string(),
800 )
801 }
802
803 fn make_note_with_frontmatter(
804 relative_path: &str,
805 title: &str,
806 content: &str,
807 frontmatter: BTreeMap<String, serde_json::Value>,
808 ) -> Note {
809 Note::new(
810 PathBuf::from(format!("/tmp/vault/{relative_path}")),
811 relative_path.to_string(),
812 title.to_string(),
813 frontmatter,
814 vec![Section {
815 heading: Some("Context".to_string()),
816 level: 1,
817 content: content.to_string(),
818 }],
819 Vec::new(),
820 content.to_string(),
821 )
822 }
823
824 #[test]
825 fn title_heading_and_wikilinks_should_outscore_body_only_matches() {
826 let input = make_input(
827 "Improve repo-path routing",
828 &["src/engine/project_matcher.rs"],
829 );
830 let module = MatchedModule {
831 id: "routing".to_string(),
832 reasons: vec!["task matched keyword routing".to_string()],
833 };
834
835 let rich_note = make_note(
836 "10-Projects/routing-guide.md",
837 "Repo Path Routing Guide",
838 Some("Project Matcher"),
839 "See [[Project Matcher]] for the main entry point.",
840 &["Project Matcher"],
841 );
842 let body_only_note = make_note(
843 "10-Projects/notes.md",
844 "Implementation Notes",
845 Some("Background"),
846 "This note mentions routing once in the body.",
847 &[],
848 );
849
850 let (rich_score, _, _, _) = score_note(
851 None,
852 None,
853 std::slice::from_ref(&module),
854 &[],
855 &rich_note,
856 &input,
857 );
858 let (body_score, _, _, _) = score_note(None, None, &[module], &[], &body_only_note, &input);
859
860 assert!(
861 rich_score > body_score,
862 "rich note score={rich_score}, body note score={body_score}"
863 );
864 }
865
866 #[test]
867 fn scoring_should_normalize_case_and_separator_variants() {
868 let input = make_input(
869 "Refine Repo-Path matching",
870 &["src/engine/RepoPathMatcher.rs"],
871 );
872 let project = MatchedProject {
873 id: "spool".to_string(),
874 name: "spool".to_string(),
875 reason: "test".to_string(),
876 };
877 let note = make_note(
878 "10-Projects/spool-repo_path.md",
879 "repo_path matcher",
880 Some("RepoPath"),
881 "Normalization should allow mixed case and separator variants.",
882 &["Repo Path Matcher"],
883 );
884
885 let (score, reasons, _, _) = score_note(None, Some(&project), &[], &[], ¬e, &input);
886
887 assert!(score > 0);
888 assert!(
889 reasons
890 .iter()
891 .any(|reason| reason.contains("task token") || reason.contains("file segment")),
892 "reasons were: {reasons:?}"
893 );
894 }
895
896 #[test]
897 fn structured_frontmatter_should_boost_trusted_high_priority_memory() {
898 let input = make_input("auth design review", &["src/auth/policy.rs"]);
899
900 let curated = make_note_with_frontmatter(
901 "10-Projects/spool-auth.md",
902 "Auth Constraints",
903 "Authentication design constraints.",
904 BTreeMap::from([
905 ("memory_type".to_string(), json!("constraint")),
906 ("sensitivity".to_string(), json!("internal")),
907 ("source_of_truth".to_string(), json!(true)),
908 ("retrieval_priority".to_string(), json!("high")),
909 ]),
910 );
911 let generic = make_note_with_frontmatter(
912 "10-Projects/spool-notes.md",
913 "Auth Notes",
914 "Authentication design constraints.",
915 BTreeMap::new(),
916 );
917
918 let (curated_score, curated_reasons, _, _) =
919 score_note(None, None, &[], &[], &curated, &input);
920 let (generic_score, _, _, _) = score_note(None, None, &[], &[], &generic, &input);
921
922 assert!(
923 curated_score > generic_score,
924 "curated={curated_score}, generic={generic_score}"
925 );
926 assert!(
927 curated_reasons
928 .iter()
929 .any(|reason| reason.contains("memory_type"))
930 );
931 assert!(
932 curated_reasons
933 .iter()
934 .any(|reason| reason.contains("source_of_truth"))
935 );
936 assert!(
937 curated_reasons
938 .iter()
939 .any(|reason| reason.contains("retrieval_priority"))
940 );
941 }
942
943 #[test]
944 fn secret_sensitivity_should_reduce_default_retrieval_score() {
945 let input = make_input("deploy credentials rotation", &["infra/secrets.tf"]);
946
947 let secret = make_note_with_frontmatter(
948 "10-Projects/secrets.md",
949 "Deploy Credentials",
950 "Credentials rotation checklist.",
951 BTreeMap::from([
952 ("memory_type".to_string(), json!("workflow")),
953 ("sensitivity".to_string(), json!("secret")),
954 ("retrieval_priority".to_string(), json!("high")),
955 ]),
956 );
957 let internal = make_note_with_frontmatter(
958 "10-Projects/deploy.md",
959 "Deploy Credentials",
960 "Credentials rotation checklist.",
961 BTreeMap::from([
962 ("memory_type".to_string(), json!("workflow")),
963 ("sensitivity".to_string(), json!("internal")),
964 ("retrieval_priority".to_string(), json!("high")),
965 ]),
966 );
967
968 let (secret_score, secret_reasons, _, _) =
969 score_note(None, None, &[], &[], &secret, &input);
970 let (internal_score, _, _, _) = score_note(None, None, &[], &[], &internal, &input);
971
972 assert!(
973 secret_score < internal_score,
974 "secret={secret_score}, internal={internal_score}"
975 );
976 assert!(
977 secret_reasons
978 .iter()
979 .any(|reason| reason.contains("sensitivity secret"))
980 );
981 }
982
983 #[test]
984 fn lifecycle_constraint_should_outrank_decision_preference_and_incident() {
985 let input = make_input("resume p2 retrieval", &[]);
986 let constraint = make_record(
987 "避免 mock 测试",
988 "production migration 曾因 mock 过度而失败",
989 "constraint",
990 MemoryScope::User,
991 None,
992 MemoryLifecycleState::Accepted,
993 );
994 let decision = make_record(
995 "采用 React",
996 "桌面 UI 用 React + shadcn",
997 "decision",
998 MemoryScope::User,
999 None,
1000 MemoryLifecycleState::Accepted,
1001 );
1002 let preference = make_record(
1003 "中文回复",
1004 "prefer 中文",
1005 "preference",
1006 MemoryScope::User,
1007 None,
1008 MemoryLifecycleState::Accepted,
1009 );
1010 let incident = make_record(
1011 "CSRF 回归",
1012 "上次绕过了 CSRF check",
1013 "incident",
1014 MemoryScope::User,
1015 None,
1016 MemoryLifecycleState::Accepted,
1017 );
1018
1019 let c = score_lifecycle_candidate(None, "r1", &constraint, &input, None, None).unwrap();
1020 let d = score_lifecycle_candidate(None, "r2", &decision, &input, None, None).unwrap();
1021 let p = score_lifecycle_candidate(None, "r3", &preference, &input, None, None).unwrap();
1022 let i = score_lifecycle_candidate(None, "r4", &incident, &input, None, None).unwrap();
1023
1024 assert!(
1025 c.score > d.score,
1026 "constraint={} decision={}",
1027 c.score,
1028 d.score
1029 );
1030 assert!(
1031 d.score > p.score,
1032 "decision={} preference={}",
1033 d.score,
1034 p.score
1035 );
1036 assert!(
1037 p.score > i.score,
1038 "preference={} incident={}",
1039 p.score,
1040 i.score
1041 );
1042 }
1043
1044 #[test]
1045 fn lifecycle_project_scope_should_filter_non_matching_project() {
1046 let input = make_input("project work", &[]);
1047 let project = MatchedProject {
1048 id: "spool".to_string(),
1049 name: "spool".to_string(),
1050 reason: "test".to_string(),
1051 };
1052 let matching = make_record(
1053 "spool 约束",
1054 "constraint text",
1055 "constraint",
1056 MemoryScope::Project,
1057 Some("spool"),
1058 MemoryLifecycleState::Accepted,
1059 );
1060 let other = make_record(
1061 "其他项目",
1062 "other text",
1063 "constraint",
1064 MemoryScope::Project,
1065 Some("other-repo"),
1066 MemoryLifecycleState::Accepted,
1067 );
1068
1069 assert!(
1070 score_lifecycle_candidate(Some(&project), "r1", &matching, &input, None, None)
1071 .is_some()
1072 );
1073 assert!(
1074 score_lifecycle_candidate(Some(&project), "r2", &other, &input, None, None).is_none()
1075 );
1076 }
1077
1078 #[test]
1079 fn lifecycle_user_scope_should_pass_without_project() {
1080 let input = make_input("anything", &[]);
1081 let record = make_record(
1082 "偏好",
1083 "prefer 简洁回复",
1084 "preference",
1085 MemoryScope::User,
1086 None,
1087 MemoryLifecycleState::Accepted,
1088 );
1089 let candidate = score_lifecycle_candidate(None, "r1", &record, &input, None, None).unwrap();
1090 assert!(candidate.score > 0);
1091 assert!(
1092 candidate
1093 .reasons
1094 .iter()
1095 .any(|reason| reason.contains("scope=user"))
1096 );
1097 }
1098
1099 #[test]
1100 fn lifecycle_task_token_should_bonus_when_title_or_summary_matches() {
1101 let input = make_input("重构 retrieval 管道", &[]);
1102 let matched = make_record(
1103 "retrieval 排序原则",
1104 "按 memory_type 加权",
1105 "decision",
1106 MemoryScope::User,
1107 None,
1108 MemoryLifecycleState::Accepted,
1109 );
1110 let bland = make_record(
1111 "无关标题",
1112 "无关内容",
1113 "decision",
1114 MemoryScope::User,
1115 None,
1116 MemoryLifecycleState::Accepted,
1117 );
1118 let a = score_lifecycle_candidate(None, "r1", &matched, &input, None, None).unwrap();
1119 let b = score_lifecycle_candidate(None, "r2", &bland, &input, None, None).unwrap();
1120 assert!(a.score > b.score, "matched={} bland={}", a.score, b.score);
1121 assert!(
1122 a.reasons
1123 .iter()
1124 .any(|reason| reason.contains("matched lifecycle text"))
1125 );
1126 }
1127
1128 #[test]
1129 fn lifecycle_canonical_state_should_edge_over_accepted() {
1130 let input = make_input("x", &[]);
1131 let canonical = make_record(
1132 "规范",
1133 "body",
1134 "constraint",
1135 MemoryScope::User,
1136 None,
1137 MemoryLifecycleState::Canonical,
1138 );
1139 let accepted = make_record(
1140 "规范",
1141 "body",
1142 "constraint",
1143 MemoryScope::User,
1144 None,
1145 MemoryLifecycleState::Accepted,
1146 );
1147 let c = score_lifecycle_candidate(None, "r1", &canonical, &input, None, None).unwrap();
1148 let a = score_lifecycle_candidate(None, "r2", &accepted, &input, None, None).unwrap();
1149 assert!(c.score > a.score);
1150 }
1151
1152 #[test]
1153 fn lifecycle_staleness_should_penalize_old_reference() {
1154 use crate::reference_tracker::{ReferenceEntry, ReferenceMap};
1155 use std::time::{SystemTime, UNIX_EPOCH};
1156
1157 let input = make_input("test staleness", &[]);
1158 let record = make_record(
1159 "偏好",
1160 "prefer 简洁回复",
1161 "preference",
1162 MemoryScope::User,
1163 None,
1164 MemoryLifecycleState::Accepted,
1165 );
1166
1167 let now_secs = SystemTime::now()
1169 .duration_since(UNIX_EPOCH)
1170 .unwrap()
1171 .as_secs();
1172 let sixty_days_ago = now_secs - (60 * 86400);
1173 let timestamp =
1174 crate::reference_tracker::tests::unix_secs_to_iso8601_for_test(sixty_days_ago);
1175
1176 let mut ref_map = ReferenceMap::default();
1177 ref_map.records.insert(
1178 "r1".to_string(),
1179 ReferenceEntry {
1180 last_referenced_at: timestamp,
1181 count: 3,
1182 },
1183 );
1184
1185 let with_staleness =
1186 score_lifecycle_candidate(None, "r1", &record, &input, Some(&ref_map), None).unwrap();
1187 let without_staleness =
1188 score_lifecycle_candidate(None, "r1", &record, &input, None, None).unwrap();
1189
1190 assert!(
1191 with_staleness.score < without_staleness.score,
1192 "stale={} fresh={}",
1193 with_staleness.score,
1194 without_staleness.score
1195 );
1196 assert!(
1197 with_staleness
1198 .reasons
1199 .iter()
1200 .any(|r| r.contains("staleness penalty"))
1201 );
1202 }
1203
1204 #[test]
1205 fn lifecycle_staleness_should_not_penalize_fresh_reference() {
1206 use crate::reference_tracker::{ReferenceEntry, ReferenceMap};
1207 use std::time::{SystemTime, UNIX_EPOCH};
1208
1209 let input = make_input("test staleness", &[]);
1210 let record = make_record(
1211 "偏好",
1212 "prefer 简洁回复",
1213 "preference",
1214 MemoryScope::User,
1215 None,
1216 MemoryLifecycleState::Accepted,
1217 );
1218
1219 let now_secs = SystemTime::now()
1221 .duration_since(UNIX_EPOCH)
1222 .unwrap()
1223 .as_secs();
1224 let five_days_ago = now_secs - (5 * 86400);
1225 let timestamp =
1226 crate::reference_tracker::tests::unix_secs_to_iso8601_for_test(five_days_ago);
1227
1228 let mut ref_map = ReferenceMap::default();
1229 ref_map.records.insert(
1230 "r1".to_string(),
1231 ReferenceEntry {
1232 last_referenced_at: timestamp,
1233 count: 10,
1234 },
1235 );
1236
1237 let with_ref =
1238 score_lifecycle_candidate(None, "r1", &record, &input, Some(&ref_map), None).unwrap();
1239 let without_ref =
1240 score_lifecycle_candidate(None, "r1", &record, &input, None, None).unwrap();
1241
1242 assert!(
1243 with_ref.score >= without_ref.score,
1244 "fresh reference should boost or be neutral: with_ref={} without_ref={}",
1245 with_ref.score,
1246 without_ref.score
1247 );
1248 }
1249
1250 #[test]
1251 fn lifecycle_staleness_should_not_penalize_missing_entry() {
1252 use crate::reference_tracker::ReferenceMap;
1253
1254 let input = make_input("test staleness", &[]);
1255 let record = make_record(
1256 "偏好",
1257 "prefer 简洁回复",
1258 "preference",
1259 MemoryScope::User,
1260 None,
1261 MemoryLifecycleState::Accepted,
1262 );
1263
1264 let ref_map = ReferenceMap::default();
1266
1267 let with_empty_map =
1268 score_lifecycle_candidate(None, "r1", &record, &input, Some(&ref_map), None).unwrap();
1269 let without_map =
1270 score_lifecycle_candidate(None, "r1", &record, &input, None, None).unwrap();
1271
1272 assert_eq!(
1273 with_empty_map.score, without_map.score,
1274 "missing entry should not penalize: with_map={} without_map={}",
1275 with_empty_map.score, without_map.score
1276 );
1277 }
1278
1279 #[test]
1280 fn lifecycle_contradiction_should_populate_contradicts_field() {
1281 let input = make_input("test contradiction", &[]);
1282 let existing_record = make_record(
1283 "用 cargo install",
1284 "用 cargo install 安装 binary 到 ~/.cargo/bin",
1285 "preference",
1286 MemoryScope::User,
1287 None,
1288 MemoryLifecycleState::Accepted,
1289 );
1290 let new_record = make_record(
1291 "不用 cargo install",
1292 "不用 cargo install 安装 binary",
1293 "preference",
1294 MemoryScope::User,
1295 None,
1296 MemoryLifecycleState::Accepted,
1297 );
1298 let existing = vec![("existing-1".to_string(), existing_record)];
1299
1300 let candidate =
1301 score_lifecycle_candidate(None, "new-1", &new_record, &input, None, Some(&existing))
1302 .unwrap();
1303 assert!(
1304 !candidate.contradicts.is_empty(),
1305 "contradicts should be non-empty when negation detected"
1306 );
1307 assert_eq!(candidate.contradicts[0], "existing-1");
1308 }
1309
1310 #[test]
1311 fn lifecycle_no_contradiction_should_have_empty_contradicts() {
1312 let input = make_input("test no contradiction", &[]);
1313 let existing_record = make_record(
1314 "用 cargo install",
1315 "用 cargo install 安装 binary 到 ~/.cargo/bin",
1316 "preference",
1317 MemoryScope::User,
1318 None,
1319 MemoryLifecycleState::Accepted,
1320 );
1321 let new_record = make_record(
1322 "偏好简洁回复",
1323 "prefer 简洁直接的回复风格",
1324 "preference",
1325 MemoryScope::User,
1326 None,
1327 MemoryLifecycleState::Accepted,
1328 );
1329 let existing = vec![("existing-1".to_string(), existing_record)];
1330
1331 let candidate =
1332 score_lifecycle_candidate(None, "new-1", &new_record, &input, None, Some(&existing))
1333 .unwrap();
1334 assert!(
1335 candidate.contradicts.is_empty(),
1336 "contradicts should be empty for unrelated records"
1337 );
1338 }
1339}