Skip to main content

spool/engine/
scorer.rs

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
18/// Single scoring loop's running totals. Owns the legacy
19/// human-readable `reasons` strings (still emitted to keep markdown
20/// output stable) AND the structured `breakdown` rows (source +
21/// field + term + weight) used by explain JSON and downstream
22/// evaluation tools. Always mutate via [`Self::add`] so reason and
23/// breakdown stay in sync with `score`.
24struct 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            // Acknowledge the value without changing score so the
141            // explain output still surfaces it.
142            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 == &note.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
451/// 按 scope / project 过滤一条 lifecycle 记忆,未通过返回 None。
452/// 通过的记忆返回包含 score + reasons + 原始 memory_type 等信息的 `LifecycleCandidate`。
453///
454/// `reference_map` 提供 staleness 信息:如果某条记忆长时间未被检索,
455/// 会施加负分惩罚(-2 到 -8),让更活跃的记忆排在前面。
456///
457/// `existing_records` 用于矛盾检测:当提供时,会检查当前记忆是否与已有记忆矛盾,
458/// 并填充 `contradicts` 字段(仅可见性标注,不影响分数)。
459pub 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    // Archived and Draft records are not retrievable.
468    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            // 当前 ledger 没独立 workspace 路径字段,保守用 project_id 近似。
497            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    // Structured field scoring: entities, tags, triggers, related_files, applies_to
565    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    // entities match task tokens → +6 per match (cap +18)
572    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    // tags match task tokens → +4 per match (cap +12)
589    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    // Knowledge pages with domain:user-profile always get a baseline boost
606    // so they surface in any session (user habits, preferences, etc.)
607    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    // triggers exact match task/file tokens → +8 per match (cap +16)
613    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    // related_files match current files → +10 per match (cap +20)
627    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    // applies_to match current project → +8
644    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    // Contradiction detection — populate contradicts field (visibility only, no score change)
694    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), &[], &[], &note, &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        // 60 days ago
1168        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        // 5 days ago
1220        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        // Empty reference map — record not tracked at all
1265        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}