use crate::candidates::CandidateResult;
use hippmem_core::config::AlgoParams;
use hippmem_core::score::UnitScore;
const W_LEXICAL: f32 = 0.18;
const W_BINARY: f32 = 0.10;
fn cold_start_factor(total_memories: u32, params: &AlgoParams) -> f32 {
let cold = params.cold_start_count;
if total_memories < cold {
params.single_semantic_penalty
} else if total_memories >= cold * 2 {
1.0
} else {
let t = (total_memories - cold) as f32 / cold as f32;
params.single_semantic_penalty + (1.0 - params.single_semantic_penalty) * t
}
}
pub fn associate_score(
c: &CandidateResult,
total_dim_hits: usize,
total_memory_count: u32,
params: &AlgoParams,
) -> UnitScore {
let n = total_dim_hits.max(1) as f32;
let mut raw = params.w_entity * c.entity_jaccard
+ params.w_topic * c.topic_jaccard
+ params.w_goal * c.goal_jaccard
+ params.w_event * c.event_jaccard
+ params.w_temporal * overlap_ratio(c.temporal_overlap, n)
+ params.w_causal * overlap_ratio(c.causal_overlap, n)
+ params.w_emotion * overlap_ratio(c.emotion_overlap, n)
+ params.w_importance * c.importance_value
+ params.w_context * c.co_context_score;
if !c.matched_dimensions.is_empty() {
raw += W_LEXICAL * c.lexical_similarity + W_BINARY * c.semantic_binary_similarity;
}
let dim_count = c.matched_dimensions.len();
if dim_count >= params.multi_dim_min_dims as usize {
raw += params.multi_dim_bonus;
}
if dim_count == 1
&& c.matched_dimensions
.contains(&hippmem_core::model::links::MatchDimension::Semantic)
{
raw *= cold_start_factor(total_memory_count, params);
}
UnitScore::new(raw.clamp(0.0, 1.0))
}
fn overlap_ratio(overlap: usize, norm: f32) -> f32 {
if overlap == 0 {
0.0
} else {
(overlap as f32 / norm).min(1.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use hippmem_core::model::links::MatchDimension;
#[test]
fn zero_overlap_gives_low_score() {
let c = CandidateResult {
matched_dimensions: vec![],
entity_jaccard: 0.0,
topic_jaccard: 0.0,
temporal_overlap: 0,
goal_jaccard: 0.0,
event_jaccard: 0.0,
causal_overlap: 0,
emotion_overlap: 0,
importance_value: 0.0,
co_context_score: 0.0,
lexical_similarity: 0.0,
semantic_binary_similarity: 0.0,
};
let s = associate_score(&c, 1, 1000, &AlgoParams::default());
assert_eq!(s.value(), 0.0);
}
#[test]
fn jaccard_exact_values() {
let c = CandidateResult {
matched_dimensions: vec![MatchDimension::Entity],
entity_jaccard: 0.5, topic_jaccard: 0.0,
temporal_overlap: 0,
goal_jaccard: 0.0,
event_jaccard: 0.0,
causal_overlap: 0,
emotion_overlap: 0,
importance_value: 0.0,
co_context_score: 0.0,
lexical_similarity: 0.0,
semantic_binary_similarity: 0.0,
};
let s = associate_score(&c, 1, 1000, &AlgoParams::default());
let expected = 0.20 * 0.5; assert!(
(s.value() - expected).abs() < 0.001,
"entity_jaccard=0.5 → score ≈ {} (W_ENTITY={})",
expected,
0.20
);
}
#[test]
fn multi_dim_boost() {
let c = CandidateResult {
matched_dimensions: vec![
MatchDimension::Entity,
MatchDimension::Topic,
MatchDimension::Temporal,
],
entity_jaccard: 0.6,
topic_jaccard: 0.5,
temporal_overlap: 1,
goal_jaccard: 0.0,
event_jaccard: 0.0,
causal_overlap: 0,
emotion_overlap: 0,
importance_value: 0.0,
co_context_score: 0.0,
lexical_similarity: 0.8,
semantic_binary_similarity: 0.0,
};
let s = associate_score(&c, 3, 1000, &AlgoParams::default());
assert!(s.value() > 0.3, "multi-dim should get a bonus");
}
fn make_semantic_only() -> CandidateResult {
CandidateResult {
matched_dimensions: vec![MatchDimension::Semantic],
entity_jaccard: 0.0,
topic_jaccard: 0.0,
temporal_overlap: 0,
goal_jaccard: 0.0,
event_jaccard: 0.0,
causal_overlap: 0,
emotion_overlap: 0,
importance_value: 0.0,
co_context_score: 0.0,
lexical_similarity: 0.5,
semantic_binary_similarity: 0.0,
}
}
#[test]
fn cold_start_below_threshold_penalty_applies() {
let params = AlgoParams::default();
let c = make_semantic_only();
let s = associate_score(&c, 1, 100, ¶ms);
assert!(s.value() > 0.0);
assert!(
s.value() < 0.1,
"single-semantic should be penalized during cold start"
);
}
#[test]
fn cold_start_at_threshold_penalty_applies() {
let params = AlgoParams::default();
let c = make_semantic_only();
let s_cold = associate_score(&c, 1, 500, ¶ms);
assert!(s_cold.value() > 0.0);
assert!(s_cold.value() < 0.1);
}
#[test]
fn cold_start_warming_up_linear() {
let params = AlgoParams::default();
let c = make_semantic_only();
let s_mid = associate_score(&c, 1, 750, ¶ms);
let s_cold = associate_score(&c, 1, 500, ¶ms);
assert!(
s_mid.value() > s_cold.value(),
"warm-up score({}) > cold-start score({})",
s_mid.value(),
s_cold.value()
);
}
#[test]
fn cold_start_fully_warmed_no_penalty() {
let params = AlgoParams::default();
let c = make_semantic_only();
let s_warm = associate_score(&c, 1, 1000, ¶ms);
let s_cold = associate_score(&c, 1, 100, ¶ms);
let expected_ratio = s_warm.value() / s_cold.value();
assert!(
expected_ratio > 1.5,
"fully-warmed score should be much greater than cold-start: warm={}, cold={}, ratio={}",
s_warm.value(),
s_cold.value(),
expected_ratio
);
}
#[test]
fn cold_start_no_effect_on_multi_dim() {
let params = AlgoParams::default();
let c = CandidateResult {
matched_dimensions: vec![MatchDimension::Entity, MatchDimension::Topic],
entity_jaccard: 0.6,
topic_jaccard: 0.5,
temporal_overlap: 0,
goal_jaccard: 0.0,
event_jaccard: 0.0,
causal_overlap: 0,
emotion_overlap: 0,
importance_value: 0.0,
co_context_score: 0.0,
lexical_similarity: 0.8,
semantic_binary_similarity: 0.0,
};
let s_cold = associate_score(&c, 2, 100, ¶ms);
let s_warm = associate_score(&c, 2, 1000, ¶ms);
assert!(
(s_cold.value() - s_warm.value()).abs() < 0.001,
"cold start should not affect score on multi-dim hit"
);
}
}