use crate::config::ProjectConfig;
use crate::domain::note::tokenize;
use crate::domain::{
ConfidenceTier, LifecycleCandidate, MatchedModule, MatchedProject, MatchedScene,
MemoryLifecycleState, MemoryRecord, MemoryScope, MemorySourceKind, Note, RouteInput,
ScoreContribution, ScoreSource,
};
use std::collections::BTreeSet;
fn extract_tags(value: &serde_json::Value) -> Vec<&str> {
match value {
serde_json::Value::String(tag) => vec![tag.as_str()],
serde_json::Value::Array(tags) => tags.iter().filter_map(|tag| tag.as_str()).collect(),
_ => Vec::new(),
}
}
struct Accumulator {
score: i32,
reasons: Vec<String>,
breakdown: Vec<ScoreContribution>,
}
impl Accumulator {
fn new() -> Self {
Self {
score: 0,
reasons: Vec::new(),
breakdown: Vec::new(),
}
}
fn add(&mut self, source: ScoreSource, field: &str, term: &str, weight: i32, reason: String) {
self.score += weight;
if !self.reasons.iter().any(|existing| existing == &reason) {
self.reasons.push(reason);
}
self.breakdown.push(ScoreContribution {
source,
field: field.to_string(),
term: term.to_string(),
weight,
});
}
}
fn apply_structured_frontmatter_score(
acc: &mut Accumulator,
note: &Note,
project: Option<&MatchedProject>,
) {
if let Some(memory_type) = note.memory_type() {
let delta = match memory_type {
"constraint" => 14,
"decision" => 12,
"project" => 10,
"preference" => 9,
"incident" => 8,
"workflow" => 7,
"pattern" => 6,
"person" => 4,
"session" => -2,
_ => 0,
};
if delta != 0 {
acc.add(
ScoreSource::MemoryType,
"memory_type",
memory_type,
delta,
format!("memory_type {memory_type} adjusted score {delta:+}"),
);
}
}
if let Some(project_id) = note.frontmatter_str("project_id")
&& let Some(project) = project
&& project_id == project.id
{
acc.add(
ScoreSource::Frontmatter,
"project_id",
&project.id,
12,
format!("frontmatter project_id matched {}", project.id),
);
}
if note.source_of_truth() {
acc.add(
ScoreSource::Frontmatter,
"source_of_truth",
"true",
10,
"source_of_truth boosted retrieval".to_string(),
);
}
if let Some(priority) = note.frontmatter_str("retrieval_priority") {
let delta = match priority {
"high" => 10,
"medium" => 4,
"low" => -4,
_ => 0,
};
if delta != 0 {
acc.add(
ScoreSource::Frontmatter,
"retrieval_priority",
priority,
delta,
format!("retrieval_priority {priority} adjusted score {delta:+}"),
);
}
}
if let Some(sensitivity) = note.sensitivity() {
let delta = match sensitivity {
"public" => 2,
"internal" => 0,
"confidential" => -4,
"secret" => -12,
_ => 0,
};
if delta != 0 {
acc.add(
ScoreSource::Sensitivity,
"sensitivity",
sensitivity,
delta,
format!("sensitivity {sensitivity} adjusted score {delta:+}"),
);
} else {
if !acc
.reasons
.iter()
.any(|r| r == &format!("sensitivity {sensitivity} acknowledged"))
{
acc.reasons
.push(format!("sensitivity {sensitivity} acknowledged"));
}
}
}
}
fn score_named_match(acc: &mut Accumulator, note: &Note, label: &str, term: &str) {
if note.search_index.matches_title(term) {
acc.add(
ScoreSource::NamedMatch,
"title",
term,
18,
format!("{label} matched title {term}"),
);
}
if note.search_index.matches_heading(term) {
acc.add(
ScoreSource::NamedMatch,
"heading",
term,
14,
format!("{label} matched heading {term}"),
);
}
if note.search_index.matches_wikilink(term) {
acc.add(
ScoreSource::NamedMatch,
"wikilink",
term,
12,
format!("{label} matched wikilink {term}"),
);
}
if note.search_index.matches_path(term) {
acc.add(
ScoreSource::NamedMatch,
"path",
term,
10,
format!("{label} matched path {term}"),
);
}
if note.search_index.matches_body(term) {
acc.add(
ScoreSource::NamedMatch,
"body",
term,
8,
format!("{label} matched body {term}"),
);
}
}
fn confidence_label(tier: ConfidenceTier) -> &'static str {
match tier {
ConfidenceTier::High => "high",
ConfidenceTier::Medium => "medium",
ConfidenceTier::Low => "low",
}
}
fn derive_note_confidence(note: &Note) -> ConfidenceTier {
if note.source_of_truth() {
return ConfidenceTier::High;
}
let sensitivity = note.sensitivity().unwrap_or("internal");
if sensitivity == "secret" {
return ConfidenceTier::Low;
}
let priority = note
.frontmatter_str("retrieval_priority")
.unwrap_or("medium");
if priority == "low" {
return ConfidenceTier::Low;
}
let memory_type = note.memory_type().unwrap_or("");
if priority == "high" && matches!(memory_type, "constraint" | "decision" | "project") {
return ConfidenceTier::High;
}
ConfidenceTier::Medium
}
fn derive_lifecycle_confidence(
state: MemoryLifecycleState,
source_kind: MemorySourceKind,
sensitivity: Option<&str>,
) -> ConfidenceTier {
let base = match state {
MemoryLifecycleState::Canonical => ConfidenceTier::High,
MemoryLifecycleState::Accepted => match source_kind {
MemorySourceKind::Manual => ConfidenceTier::High,
_ => ConfidenceTier::Medium,
},
MemoryLifecycleState::Candidate => ConfidenceTier::Low,
_ => ConfidenceTier::Medium,
};
if sensitivity == Some("secret") {
match base {
ConfidenceTier::High => return ConfidenceTier::Medium,
_ => return ConfidenceTier::Low,
}
}
base
}
fn query_terms(input: &RouteInput) -> (BTreeSet<String>, BTreeSet<String>) {
let task_terms = tokenize(&input.task);
let file_terms = input
.files
.iter()
.flat_map(|file| tokenize(file))
.filter(|segment| segment.chars().count() >= 3)
.collect();
(task_terms, file_terms)
}
pub fn score_note(
project_config: Option<&ProjectConfig>,
project: Option<&MatchedProject>,
modules: &[MatchedModule],
scenes: &[MatchedScene],
note: &Note,
input: &RouteInput,
) -> (i32, Vec<String>, Vec<ScoreContribution>, ConfidenceTier) {
let mut acc = Accumulator::new();
let (task_terms, file_terms) = query_terms(input);
apply_structured_frontmatter_score(&mut acc, note, project);
if let Some(project) = project {
score_named_match(&mut acc, note, "project", &project.id);
score_named_match(&mut acc, note, "project", &project.name);
}
if let Some(project_config) = project_config {
for root in &project_config.note_roots {
if note.relative_path.starts_with(root) {
acc.add(
ScoreSource::DefaultTag,
"note_root",
root,
10,
format!("note under preferred root {root}"),
);
}
}
let note_tags = note
.frontmatter
.get("tags")
.into_iter()
.flat_map(extract_tags);
for tag in &project_config.default_tags {
if note_tags.clone().any(|note_tag| note_tag == tag.as_str()) {
acc.add(
ScoreSource::DefaultTag,
"tag",
tag,
6,
format!("matched frontmatter tag {tag}"),
);
}
}
}
for module in modules {
score_named_match(&mut acc, note, "module", &module.id);
}
for scene in scenes {
score_named_match(&mut acc, note, "scene", &scene.id);
if scene
.preferred_notes
.iter()
.any(|preferred| preferred == ¬e.relative_path)
{
acc.add(
ScoreSource::ScenePreferred,
"preferred_note",
&scene.id,
25,
format!("preferred by scene {}", scene.id),
);
}
}
for term in file_terms {
if note.search_index.matches_title(&term) {
acc.add(
ScoreSource::TaskToken,
"title",
&term,
7,
format!("matched file segment {term} in title"),
);
}
if note.search_index.matches_heading(&term) {
acc.add(
ScoreSource::TaskToken,
"heading",
&term,
5,
format!("matched file segment {term} in heading"),
);
}
if note.search_index.matches_wikilink(&term) {
acc.add(
ScoreSource::TaskToken,
"wikilink",
&term,
5,
format!("matched file segment {term} in wikilink"),
);
}
if note.search_index.matches_body(&term) || note.search_index.matches_path(&term) {
acc.add(
ScoreSource::TaskToken,
"body_or_path",
&term,
3,
format!("matched file segment {term}"),
);
}
}
for token in task_terms {
if note.search_index.matches_title(&token) {
acc.add(
ScoreSource::TaskToken,
"title",
&token,
7,
format!("matched task token {token} in title"),
);
}
if note.search_index.matches_heading(&token) {
acc.add(
ScoreSource::TaskToken,
"heading",
&token,
5,
format!("matched task token {token} in heading"),
);
}
if note.search_index.matches_wikilink(&token) {
acc.add(
ScoreSource::TaskToken,
"wikilink",
&token,
5,
format!("matched task token {token} in wikilink"),
);
}
if note.search_index.matches_body(&token) || note.search_index.matches_path(&token) {
acc.add(
ScoreSource::TaskToken,
"body_or_path",
&token,
3,
format!("matched task token {token}"),
);
}
}
let confidence = derive_note_confidence(note);
let confidence_weight = match confidence {
ConfidenceTier::High => 6,
ConfidenceTier::Medium => 0,
ConfidenceTier::Low => -4,
};
if confidence_weight != 0 {
acc.add(
ScoreSource::Confidence,
"confidence",
confidence_label(confidence),
confidence_weight,
format!(
"confidence={} adjusted score {:+}",
confidence_label(confidence),
confidence_weight
),
);
}
(acc.score, acc.reasons, acc.breakdown, confidence)
}
fn lifecycle_memory_type_weight(memory_type: &str) -> i32 {
match memory_type {
"knowledge" => 16,
"constraint" => 14,
"decision" => 12,
"project" => 10,
"preference" => 9,
"incident" => 8,
"workflow" => 7,
"pattern" => 6,
"person" => 4,
"session" => -2,
_ => 0,
}
}
pub fn score_lifecycle_candidate(
project: Option<&MatchedProject>,
record_id: &str,
record: &MemoryRecord,
input: &RouteInput,
reference_map: Option<&crate::reference_tracker::ReferenceMap>,
existing_records: Option<&[(String, MemoryRecord)]>,
) -> Option<LifecycleCandidate> {
if matches!(
record.state,
MemoryLifecycleState::Archived | MemoryLifecycleState::Draft
) {
return None;
}
let mut score = 0;
let mut reasons: Vec<String> = Vec::new();
match record.scope {
MemoryScope::Project => {
let project_match = record
.project_id
.as_deref()
.zip(project.map(|matched| matched.id.as_str()))
.map(|(record_pid, matched_pid)| record_pid == matched_pid)
.unwrap_or(false);
if !project_match {
return None;
}
score += 10;
reasons.push(format!(
"scope=project matched project_id {}",
record.project_id.clone().unwrap_or_default()
));
}
MemoryScope::Workspace => {
let project_match = record
.project_id
.as_deref()
.zip(project.map(|matched| matched.id.as_str()))
.map(|(record_pid, matched_pid)| record_pid == matched_pid)
.unwrap_or(false);
if !project_match {
return None;
}
score += 6;
reasons.push("scope=workspace matched project proxy".to_string());
}
MemoryScope::User | MemoryScope::Agent | MemoryScope::Team => {
score += 4;
reasons.push(format!(
"scope={} kept as cross-project",
scope_label(record.scope)
));
}
}
let memory_type_weight = lifecycle_memory_type_weight(&record.memory_type);
if memory_type_weight != 0 {
score += memory_type_weight;
reasons.push(format!(
"memory_type {} adjusted score {:+}",
record.memory_type, memory_type_weight
));
}
if matches!(record.state, MemoryLifecycleState::Canonical) {
score += 3;
reasons.push("state=canonical boosted".to_string());
}
let task_tokens = tokenize(&input.task);
let file_tokens: BTreeSet<String> = input
.files
.iter()
.flat_map(|file| tokenize(file))
.filter(|segment| segment.chars().count() >= 3)
.collect();
let title_lc = record.title.to_lowercase();
let summary_lc = record.summary.to_lowercase();
let task_lc = input.task.to_lowercase();
if title_lc.len() >= 4 && (task_lc.contains(&title_lc) || title_lc.contains(&task_lc)) {
score += 6;
reasons.push("title substring matched task".to_string());
}
let mut token_bonus = 0_i32;
for token in task_tokens.iter().chain(file_tokens.iter()) {
if token.is_empty() {
continue;
}
let needle = token.to_lowercase();
if title_lc.contains(&needle) || summary_lc.contains(&needle) {
token_bonus += 4;
reasons.push(format!("task/file token {token} matched lifecycle text"));
if token_bonus >= 12 {
break;
}
}
}
score += token_bonus.min(12);
let all_query_tokens: BTreeSet<String> = task_tokens
.iter()
.chain(file_tokens.iter())
.map(|t| t.to_lowercase())
.collect();
let mut entities_bonus = 0_i32;
for entity in &record.entities {
let entity_lc = entity.to_lowercase();
if all_query_tokens
.iter()
.any(|t| entity_lc.contains(t) || t.contains(&entity_lc))
{
entities_bonus += 6;
reasons.push(format!("entity {entity} matched query token"));
if entities_bonus >= 18 {
break;
}
}
}
score += entities_bonus.min(18);
let mut tags_bonus = 0_i32;
for tag in &record.tags {
let tag_lc = tag.to_lowercase();
if all_query_tokens
.iter()
.any(|t| tag_lc.contains(t) || t.contains(&tag_lc))
{
tags_bonus += 4;
reasons.push(format!("tag {tag} matched query token"));
if tags_bonus >= 12 {
break;
}
}
}
score += tags_bonus.min(12);
if record.memory_type == "knowledge" && record.tags.iter().any(|t| t == "domain:user-profile") {
score += 8;
reasons.push("knowledge domain:user-profile always-on boost".to_string());
}
let mut triggers_bonus = 0_i32;
for trigger in &record.triggers {
let trigger_lc = trigger.to_lowercase();
if all_query_tokens.contains(&trigger_lc) {
triggers_bonus += 8;
reasons.push(format!("trigger {trigger} exact-matched query token"));
if triggers_bonus >= 16 {
break;
}
}
}
score += triggers_bonus.min(16);
let mut files_bonus = 0_i32;
for related_file in &record.related_files {
let rf_lc = related_file.to_lowercase();
if input.files.iter().any(|f| {
let f_lc = f.to_lowercase();
f_lc.contains(&rf_lc) || rf_lc.contains(&f_lc)
}) {
files_bonus += 10;
reasons.push(format!("related_file {related_file} matched input file"));
if files_bonus >= 20 {
break;
}
}
}
score += files_bonus.min(20);
if let Some(matched_project) = project
&& record
.applies_to
.iter()
.any(|a| a.eq_ignore_ascii_case(&matched_project.id))
{
score += 8;
reasons.push(format!("applies_to matched project {}", matched_project.id));
}
if score <= 0 {
return None;
}
let confidence = derive_lifecycle_confidence(
record.state,
record.origin.source_kind,
record.sensitivity.as_deref(),
);
let confidence_weight = match confidence {
ConfidenceTier::High => 5,
ConfidenceTier::Medium => 0,
ConfidenceTier::Low => -3,
};
if confidence_weight != 0 {
score += confidence_weight;
reasons.push(format!(
"confidence={} adjusted score {:+}",
confidence_label(confidence),
confidence_weight
));
}
if let Some(ref_map) = reference_map {
let age = ref_map
.records
.get(record_id)
.and_then(crate::reference_tracker::age_days);
let penalty = crate::reference_tracker::staleness_penalty(age);
if penalty != 0 {
score += penalty;
reasons.push(format!(
"staleness penalty {:+} (age={} days)",
penalty,
age.unwrap_or(0)
));
}
}
let contradicts: Vec<String> = if let Some(existing) = existing_records {
crate::contradiction::detect(&record.summary, &record.memory_type, existing)
.into_iter()
.map(|hit| hit.existing_record_id)
.collect()
} else {
Vec::new()
};
Some(LifecycleCandidate {
record_id: record_id.to_string(),
title: record.title.clone(),
summary: record.summary.clone(),
memory_type: record.memory_type.clone(),
scope: record.scope,
state: record.state,
score,
reasons,
project_id: record.project_id.clone(),
confidence,
contradicts,
})
}
fn scope_label(scope: MemoryScope) -> &'static str {
match scope {
MemoryScope::User => "user",
MemoryScope::Project => "project",
MemoryScope::Workspace => "workspace",
MemoryScope::Agent => "agent",
MemoryScope::Team => "team",
}
}
#[cfg(test)]
mod tests {
use super::{score_lifecycle_candidate, score_note};
use crate::domain::{
MatchedModule, MatchedProject, MemoryLifecycleState, MemoryOrigin, MemoryRecord,
MemoryScope, MemorySourceKind, Note, OutputFormat, RouteInput, Section, TargetTool,
};
use serde_json::json;
use std::collections::BTreeMap;
use std::path::PathBuf;
fn make_input(task: &str, files: &[&str]) -> RouteInput {
RouteInput {
task: task.to_string(),
cwd: PathBuf::from("/tmp/repo"),
files: files.iter().map(|value| value.to_string()).collect(),
target: TargetTool::Codex,
format: OutputFormat::Prompt,
}
}
fn make_record(
title: &str,
summary: &str,
memory_type: &str,
scope: MemoryScope,
project_id: Option<&str>,
state: MemoryLifecycleState,
) -> MemoryRecord {
MemoryRecord {
title: title.to_string(),
summary: summary.to_string(),
memory_type: memory_type.to_string(),
scope,
state,
origin: MemoryOrigin {
source_kind: MemorySourceKind::Manual,
source_ref: "test".to_string(),
},
project_id: project_id.map(|v| v.to_string()),
user_id: None,
sensitivity: None,
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
}
}
fn make_note(
relative_path: &str,
title: &str,
heading: Option<&str>,
content: &str,
wikilinks: &[&str],
) -> Note {
Note::new(
PathBuf::from(format!("/tmp/vault/{relative_path}")),
relative_path.to_string(),
title.to_string(),
BTreeMap::new(),
vec![Section {
heading: heading.map(|value| value.to_string()),
level: usize::from(heading.is_some()),
content: content.to_string(),
}],
wikilinks.iter().map(|value| value.to_string()).collect(),
content.to_string(),
)
}
fn make_note_with_frontmatter(
relative_path: &str,
title: &str,
content: &str,
frontmatter: BTreeMap<String, serde_json::Value>,
) -> Note {
Note::new(
PathBuf::from(format!("/tmp/vault/{relative_path}")),
relative_path.to_string(),
title.to_string(),
frontmatter,
vec![Section {
heading: Some("Context".to_string()),
level: 1,
content: content.to_string(),
}],
Vec::new(),
content.to_string(),
)
}
#[test]
fn title_heading_and_wikilinks_should_outscore_body_only_matches() {
let input = make_input(
"Improve repo-path routing",
&["src/engine/project_matcher.rs"],
);
let module = MatchedModule {
id: "routing".to_string(),
reasons: vec!["task matched keyword routing".to_string()],
};
let rich_note = make_note(
"10-Projects/routing-guide.md",
"Repo Path Routing Guide",
Some("Project Matcher"),
"See [[Project Matcher]] for the main entry point.",
&["Project Matcher"],
);
let body_only_note = make_note(
"10-Projects/notes.md",
"Implementation Notes",
Some("Background"),
"This note mentions routing once in the body.",
&[],
);
let (rich_score, _, _, _) = score_note(
None,
None,
std::slice::from_ref(&module),
&[],
&rich_note,
&input,
);
let (body_score, _, _, _) = score_note(None, None, &[module], &[], &body_only_note, &input);
assert!(
rich_score > body_score,
"rich note score={rich_score}, body note score={body_score}"
);
}
#[test]
fn scoring_should_normalize_case_and_separator_variants() {
let input = make_input(
"Refine Repo-Path matching",
&["src/engine/RepoPathMatcher.rs"],
);
let project = MatchedProject {
id: "spool".to_string(),
name: "spool".to_string(),
reason: "test".to_string(),
};
let note = make_note(
"10-Projects/spool-repo_path.md",
"repo_path matcher",
Some("RepoPath"),
"Normalization should allow mixed case and separator variants.",
&["Repo Path Matcher"],
);
let (score, reasons, _, _) = score_note(None, Some(&project), &[], &[], ¬e, &input);
assert!(score > 0);
assert!(
reasons
.iter()
.any(|reason| reason.contains("task token") || reason.contains("file segment")),
"reasons were: {reasons:?}"
);
}
#[test]
fn structured_frontmatter_should_boost_trusted_high_priority_memory() {
let input = make_input("auth design review", &["src/auth/policy.rs"]);
let curated = make_note_with_frontmatter(
"10-Projects/spool-auth.md",
"Auth Constraints",
"Authentication design constraints.",
BTreeMap::from([
("memory_type".to_string(), json!("constraint")),
("sensitivity".to_string(), json!("internal")),
("source_of_truth".to_string(), json!(true)),
("retrieval_priority".to_string(), json!("high")),
]),
);
let generic = make_note_with_frontmatter(
"10-Projects/spool-notes.md",
"Auth Notes",
"Authentication design constraints.",
BTreeMap::new(),
);
let (curated_score, curated_reasons, _, _) =
score_note(None, None, &[], &[], &curated, &input);
let (generic_score, _, _, _) = score_note(None, None, &[], &[], &generic, &input);
assert!(
curated_score > generic_score,
"curated={curated_score}, generic={generic_score}"
);
assert!(
curated_reasons
.iter()
.any(|reason| reason.contains("memory_type"))
);
assert!(
curated_reasons
.iter()
.any(|reason| reason.contains("source_of_truth"))
);
assert!(
curated_reasons
.iter()
.any(|reason| reason.contains("retrieval_priority"))
);
}
#[test]
fn secret_sensitivity_should_reduce_default_retrieval_score() {
let input = make_input("deploy credentials rotation", &["infra/secrets.tf"]);
let secret = make_note_with_frontmatter(
"10-Projects/secrets.md",
"Deploy Credentials",
"Credentials rotation checklist.",
BTreeMap::from([
("memory_type".to_string(), json!("workflow")),
("sensitivity".to_string(), json!("secret")),
("retrieval_priority".to_string(), json!("high")),
]),
);
let internal = make_note_with_frontmatter(
"10-Projects/deploy.md",
"Deploy Credentials",
"Credentials rotation checklist.",
BTreeMap::from([
("memory_type".to_string(), json!("workflow")),
("sensitivity".to_string(), json!("internal")),
("retrieval_priority".to_string(), json!("high")),
]),
);
let (secret_score, secret_reasons, _, _) =
score_note(None, None, &[], &[], &secret, &input);
let (internal_score, _, _, _) = score_note(None, None, &[], &[], &internal, &input);
assert!(
secret_score < internal_score,
"secret={secret_score}, internal={internal_score}"
);
assert!(
secret_reasons
.iter()
.any(|reason| reason.contains("sensitivity secret"))
);
}
#[test]
fn lifecycle_constraint_should_outrank_decision_preference_and_incident() {
let input = make_input("resume p2 retrieval", &[]);
let constraint = make_record(
"避免 mock 测试",
"production migration 曾因 mock 过度而失败",
"constraint",
MemoryScope::User,
None,
MemoryLifecycleState::Accepted,
);
let decision = make_record(
"采用 React",
"桌面 UI 用 React + shadcn",
"decision",
MemoryScope::User,
None,
MemoryLifecycleState::Accepted,
);
let preference = make_record(
"中文回复",
"prefer 中文",
"preference",
MemoryScope::User,
None,
MemoryLifecycleState::Accepted,
);
let incident = make_record(
"CSRF 回归",
"上次绕过了 CSRF check",
"incident",
MemoryScope::User,
None,
MemoryLifecycleState::Accepted,
);
let c = score_lifecycle_candidate(None, "r1", &constraint, &input, None, None).unwrap();
let d = score_lifecycle_candidate(None, "r2", &decision, &input, None, None).unwrap();
let p = score_lifecycle_candidate(None, "r3", &preference, &input, None, None).unwrap();
let i = score_lifecycle_candidate(None, "r4", &incident, &input, None, None).unwrap();
assert!(
c.score > d.score,
"constraint={} decision={}",
c.score,
d.score
);
assert!(
d.score > p.score,
"decision={} preference={}",
d.score,
p.score
);
assert!(
p.score > i.score,
"preference={} incident={}",
p.score,
i.score
);
}
#[test]
fn lifecycle_project_scope_should_filter_non_matching_project() {
let input = make_input("project work", &[]);
let project = MatchedProject {
id: "spool".to_string(),
name: "spool".to_string(),
reason: "test".to_string(),
};
let matching = make_record(
"spool 约束",
"constraint text",
"constraint",
MemoryScope::Project,
Some("spool"),
MemoryLifecycleState::Accepted,
);
let other = make_record(
"其他项目",
"other text",
"constraint",
MemoryScope::Project,
Some("other-repo"),
MemoryLifecycleState::Accepted,
);
assert!(
score_lifecycle_candidate(Some(&project), "r1", &matching, &input, None, None)
.is_some()
);
assert!(
score_lifecycle_candidate(Some(&project), "r2", &other, &input, None, None).is_none()
);
}
#[test]
fn lifecycle_user_scope_should_pass_without_project() {
let input = make_input("anything", &[]);
let record = make_record(
"偏好",
"prefer 简洁回复",
"preference",
MemoryScope::User,
None,
MemoryLifecycleState::Accepted,
);
let candidate = score_lifecycle_candidate(None, "r1", &record, &input, None, None).unwrap();
assert!(candidate.score > 0);
assert!(
candidate
.reasons
.iter()
.any(|reason| reason.contains("scope=user"))
);
}
#[test]
fn lifecycle_task_token_should_bonus_when_title_or_summary_matches() {
let input = make_input("重构 retrieval 管道", &[]);
let matched = make_record(
"retrieval 排序原则",
"按 memory_type 加权",
"decision",
MemoryScope::User,
None,
MemoryLifecycleState::Accepted,
);
let bland = make_record(
"无关标题",
"无关内容",
"decision",
MemoryScope::User,
None,
MemoryLifecycleState::Accepted,
);
let a = score_lifecycle_candidate(None, "r1", &matched, &input, None, None).unwrap();
let b = score_lifecycle_candidate(None, "r2", &bland, &input, None, None).unwrap();
assert!(a.score > b.score, "matched={} bland={}", a.score, b.score);
assert!(
a.reasons
.iter()
.any(|reason| reason.contains("matched lifecycle text"))
);
}
#[test]
fn lifecycle_canonical_state_should_edge_over_accepted() {
let input = make_input("x", &[]);
let canonical = make_record(
"规范",
"body",
"constraint",
MemoryScope::User,
None,
MemoryLifecycleState::Canonical,
);
let accepted = make_record(
"规范",
"body",
"constraint",
MemoryScope::User,
None,
MemoryLifecycleState::Accepted,
);
let c = score_lifecycle_candidate(None, "r1", &canonical, &input, None, None).unwrap();
let a = score_lifecycle_candidate(None, "r2", &accepted, &input, None, None).unwrap();
assert!(c.score > a.score);
}
#[test]
fn lifecycle_staleness_should_penalize_old_reference() {
use crate::reference_tracker::{ReferenceEntry, ReferenceMap};
use std::time::{SystemTime, UNIX_EPOCH};
let input = make_input("test staleness", &[]);
let record = make_record(
"偏好",
"prefer 简洁回复",
"preference",
MemoryScope::User,
None,
MemoryLifecycleState::Accepted,
);
let now_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let sixty_days_ago = now_secs - (60 * 86400);
let timestamp =
crate::reference_tracker::tests::unix_secs_to_iso8601_for_test(sixty_days_ago);
let mut ref_map = ReferenceMap::default();
ref_map.records.insert(
"r1".to_string(),
ReferenceEntry {
last_referenced_at: timestamp,
count: 3,
},
);
let with_staleness =
score_lifecycle_candidate(None, "r1", &record, &input, Some(&ref_map), None).unwrap();
let without_staleness =
score_lifecycle_candidate(None, "r1", &record, &input, None, None).unwrap();
assert!(
with_staleness.score < without_staleness.score,
"stale={} fresh={}",
with_staleness.score,
without_staleness.score
);
assert!(
with_staleness
.reasons
.iter()
.any(|r| r.contains("staleness penalty"))
);
}
#[test]
fn lifecycle_staleness_should_not_penalize_fresh_reference() {
use crate::reference_tracker::{ReferenceEntry, ReferenceMap};
use std::time::{SystemTime, UNIX_EPOCH};
let input = make_input("test staleness", &[]);
let record = make_record(
"偏好",
"prefer 简洁回复",
"preference",
MemoryScope::User,
None,
MemoryLifecycleState::Accepted,
);
let now_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let five_days_ago = now_secs - (5 * 86400);
let timestamp =
crate::reference_tracker::tests::unix_secs_to_iso8601_for_test(five_days_ago);
let mut ref_map = ReferenceMap::default();
ref_map.records.insert(
"r1".to_string(),
ReferenceEntry {
last_referenced_at: timestamp,
count: 10,
},
);
let with_ref =
score_lifecycle_candidate(None, "r1", &record, &input, Some(&ref_map), None).unwrap();
let without_ref =
score_lifecycle_candidate(None, "r1", &record, &input, None, None).unwrap();
assert!(
with_ref.score >= without_ref.score,
"fresh reference should boost or be neutral: with_ref={} without_ref={}",
with_ref.score,
without_ref.score
);
}
#[test]
fn lifecycle_staleness_should_not_penalize_missing_entry() {
use crate::reference_tracker::ReferenceMap;
let input = make_input("test staleness", &[]);
let record = make_record(
"偏好",
"prefer 简洁回复",
"preference",
MemoryScope::User,
None,
MemoryLifecycleState::Accepted,
);
let ref_map = ReferenceMap::default();
let with_empty_map =
score_lifecycle_candidate(None, "r1", &record, &input, Some(&ref_map), None).unwrap();
let without_map =
score_lifecycle_candidate(None, "r1", &record, &input, None, None).unwrap();
assert_eq!(
with_empty_map.score, without_map.score,
"missing entry should not penalize: with_map={} without_map={}",
with_empty_map.score, without_map.score
);
}
#[test]
fn lifecycle_contradiction_should_populate_contradicts_field() {
let input = make_input("test contradiction", &[]);
let existing_record = make_record(
"用 cargo install",
"用 cargo install 安装 binary 到 ~/.cargo/bin",
"preference",
MemoryScope::User,
None,
MemoryLifecycleState::Accepted,
);
let new_record = make_record(
"不用 cargo install",
"不用 cargo install 安装 binary",
"preference",
MemoryScope::User,
None,
MemoryLifecycleState::Accepted,
);
let existing = vec![("existing-1".to_string(), existing_record)];
let candidate =
score_lifecycle_candidate(None, "new-1", &new_record, &input, None, Some(&existing))
.unwrap();
assert!(
!candidate.contradicts.is_empty(),
"contradicts should be non-empty when negation detected"
);
assert_eq!(candidate.contradicts[0], "existing-1");
}
#[test]
fn lifecycle_no_contradiction_should_have_empty_contradicts() {
let input = make_input("test no contradiction", &[]);
let existing_record = make_record(
"用 cargo install",
"用 cargo install 安装 binary 到 ~/.cargo/bin",
"preference",
MemoryScope::User,
None,
MemoryLifecycleState::Accepted,
);
let new_record = make_record(
"偏好简洁回复",
"prefer 简洁直接的回复风格",
"preference",
MemoryScope::User,
None,
MemoryLifecycleState::Accepted,
);
let existing = vec![("existing-1".to_string(), existing_record)];
let candidate =
score_lifecycle_candidate(None, "new-1", &new_record, &input, None, Some(&existing))
.unwrap();
assert!(
candidate.contradicts.is_empty(),
"contradicts should be empty for unrelated records"
);
}
}