use std::time::{SystemTime, UNIX_EPOCH};
use crate::store::{ConfidenceScore, Record};
const HALF_LIFE_DAYS: f64 = 90.0;
const SECS_PER_DAY: f64 = 86_400.0;
const LOG_FACTOR_CAP: f32 = 2.0;
const MAX_CONTRIBUTORS: f32 = 3.0;
const REF_BOOST: f32 = 1.5;
pub fn recompute(record: &Record) -> ConfidenceScore {
let conf = &record.confidence;
let base = ConfidenceScore::base_for_source(&record.source);
let log_factor = ((conf.confirmation_count as f32 + 2.0).log2()).min(LOG_FACTOR_CAP);
let contributor_factor =
(conf.contributor_count.max(1) as f32).min(MAX_CONTRIBUTORS) / MAX_CONTRIBUTORS;
let recency = recency_weight(record.last_accessed, record.created_at);
let ref_boost = if record.ref_url.is_some() {
REF_BOOST
} else {
1.0
};
let value = (base * log_factor * contributor_factor * recency * ref_boost).clamp(0.0, 1.0);
ConfidenceScore {
value,
confirmation_count: conf.confirmation_count,
contributor_count: conf.contributor_count,
last_challenged: conf.last_challenged,
challenge_count: conf.challenge_count,
}
}
fn recency_weight(last_accessed: u64, created_at: u64) -> f32 {
let reference_time = if last_accessed == 0 {
created_at
} else {
last_accessed
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if reference_time >= now {
return 1.0;
}
let days_elapsed = (now - reference_time) as f64 / SECS_PER_DAY;
let weight = 2.0_f64.powf(-days_elapsed / HALF_LIFE_DAYS);
weight as f32
}
#[cfg(test)]
fn recency_weight_at(last_accessed: u64, created_at: u64, now: u64) -> f32 {
let reference_time = if last_accessed == 0 {
created_at
} else {
last_accessed
};
if reference_time >= now {
return 1.0;
}
let days_elapsed = (now - reference_time) as f64 / SECS_PER_DAY;
let weight = 2.0_f64.powf(-days_elapsed / HALF_LIFE_DAYS);
weight as f32
}
#[cfg(test)]
fn recompute_at(record: &Record, now: u64) -> ConfidenceScore {
let conf = &record.confidence;
let base = ConfidenceScore::base_for_source(&record.source);
let log_factor = ((conf.confirmation_count as f32 + 2.0).log2()).min(LOG_FACTOR_CAP);
let contributor_factor =
(conf.contributor_count.max(1) as f32).min(MAX_CONTRIBUTORS) / MAX_CONTRIBUTORS;
let recency = recency_weight_at(record.last_accessed, record.created_at, now);
let ref_boost = if record.ref_url.is_some() {
REF_BOOST
} else {
1.0
};
let value = (base * log_factor * contributor_factor * recency * ref_boost).clamp(0.0, 1.0);
ConfidenceScore {
value,
confirmation_count: conf.confirmation_count,
contributor_count: conf.contributor_count,
last_challenged: conf.last_challenged,
challenge_count: conf.challenge_count,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::Priority;
use crate::store::{
Category, QualityScore, RecordLifecycle, RecordSource, RecordVersion, StalenessScore,
};
use uuid::Uuid;
const NOW: u64 = 1_710_520_800;
fn device_id() -> Uuid {
Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()
}
fn make_record(
source: RecordSource,
confirmation_count: u32,
contributor_count: u32,
ref_url: Option<&str>,
) -> Record {
Record {
key: "gotcha:test".into(),
value: "Test record value".into(),
category: Category::Gotcha,
priority: Priority::Normal,
tags: vec![],
created_at: NOW,
updated_at: NOW,
ref_url: ref_url.map(|s| s.into()),
staleness: StalenessScore::fresh(),
lifecycle: RecordLifecycle::Active,
version: RecordVersion {
device_id: device_id(),
logical_clock: 1,
wall_clock: NOW,
},
quality: QualityScore::layer0_default(),
access_count: 1,
last_accessed: NOW,
source,
confidence: ConfidenceScore {
value: 0.0, confirmation_count,
contributor_count,
last_challenged: None,
challenge_count: 0,
},
gap_analysis_score: 0.0,
payload: None,
}
}
#[test]
fn developer_manual_zero_confirmations_gives_base() {
let r = make_record(RecordSource::DeveloperManual, 0, 1, None);
let score = recompute_at(&r, NOW);
assert!(
(score.value - 0.2667).abs() < 0.01,
"expected ~0.27, got {:.4}",
score.value
);
}
#[test]
fn developer_manual_full_contributors_gives_base() {
let r = make_record(RecordSource::DeveloperManual, 0, 3, None);
let score = recompute_at(&r, NOW);
assert!(
(score.value - 0.80).abs() < 0.01,
"expected ~0.80, got {:.4}",
score.value
);
}
#[test]
fn static_analysis_gives_low_score() {
let r = make_record(RecordSource::StaticAnalysis, 0, 1, None);
let score = recompute_at(&r, NOW);
assert!(
score.value < 0.10,
"expected low score for StaticAnalysis, got {:.4}",
score.value
);
}
#[test]
fn confirmations_raise_score() {
let r0 = make_record(RecordSource::StaticAnalysis, 0, 1, None);
let r1 = make_record(RecordSource::StaticAnalysis, 1, 1, None);
let r2 = make_record(RecordSource::StaticAnalysis, 2, 1, None);
let s0 = recompute_at(&r0, NOW);
let s1 = recompute_at(&r1, NOW);
let s2 = recompute_at(&r2, NOW);
assert!(
s1.value > s0.value,
"1 confirmation ({:.4}) should beat 0 ({:.4})",
s1.value,
s0.value
);
assert!(
s2.value > s1.value,
"2 confirmations ({:.4}) should beat 1 ({:.4})",
s2.value,
s1.value
);
}
#[test]
fn log_factor_is_capped_at_2() {
let r = make_record(RecordSource::DeveloperManual, 1000, 3, None);
let score = recompute_at(&r, NOW);
assert!(
(score.value - 1.0).abs() < f32::EPSILON,
"expected clamped 1.0, got {:.4}",
score.value
);
}
#[test]
fn contributor_count_up_to_3_raises_score() {
let r1 = make_record(RecordSource::DeveloperManual, 0, 1, None);
let r2 = make_record(RecordSource::DeveloperManual, 0, 2, None);
let r3 = make_record(RecordSource::DeveloperManual, 0, 3, None);
let s1 = recompute_at(&r1, NOW);
let s2 = recompute_at(&r2, NOW);
let s3 = recompute_at(&r3, NOW);
assert!(s2.value > s1.value, "2 contributors should beat 1");
assert!(s3.value > s2.value, "3 contributors should beat 2");
}
#[test]
fn contributor_count_beyond_3_no_additional_effect() {
let r3 = make_record(RecordSource::DeveloperManual, 0, 3, None);
let r5 = make_record(RecordSource::DeveloperManual, 0, 5, None);
let r10 = make_record(RecordSource::DeveloperManual, 0, 10, None);
let s3 = recompute_at(&r3, NOW);
let s5 = recompute_at(&r5, NOW);
let s10 = recompute_at(&r10, NOW);
assert!(
(s3.value - s5.value).abs() < f32::EPSILON,
"5 contributors ({:.4}) should equal 3 ({:.4})",
s5.value,
s3.value
);
assert!(
(s3.value - s10.value).abs() < f32::EPSILON,
"10 contributors ({:.4}) should equal 3 ({:.4})",
s10.value,
s3.value
);
}
#[test]
fn old_access_significantly_reduces_score() {
let days_180 = 180 * 86_400;
let mut r = make_record(RecordSource::DeveloperManual, 0, 3, None);
r.last_accessed = NOW - days_180;
let score = recompute_at(&r, NOW);
assert!(
(score.value - 0.20).abs() < 0.02,
"180-day-old record should score ~0.20, got {:.4}",
score.value
);
}
#[test]
fn ninety_day_old_access_halves_recency() {
let days_90 = 90 * 86_400;
let recency = recency_weight_at(NOW - days_90, NOW - days_90, NOW);
assert!(
(recency - 0.5).abs() < 0.01,
"90-day half-life should give 0.5, got {:.4}",
recency
);
}
#[test]
fn recent_access_gives_full_recency() {
let recency = recency_weight_at(NOW, NOW, NOW);
assert!(
(recency - 1.0).abs() < f32::EPSILON,
"same-time access should give 1.0, got {:.4}",
recency
);
}
#[test]
fn ref_url_boost_increases_score() {
let r_no_ref = make_record(RecordSource::StaticAnalysis, 0, 1, None);
let r_with_ref = make_record(
RecordSource::StaticAnalysis,
0,
1,
Some("https://github.com/example/issue/42"),
);
let s_no_ref = recompute_at(&r_no_ref, NOW);
let s_with_ref = recompute_at(&r_with_ref, NOW);
let ratio = s_with_ref.value / s_no_ref.value;
assert!(
(ratio - 1.5).abs() < 0.01,
"ref_url should give ~1.5× boost, got ratio {:.4}",
ratio
);
}
#[test]
fn result_is_clamped_to_0_1() {
let r = make_record(
RecordSource::DeveloperManual,
1000,
3,
Some("https://example.com"),
);
let score = recompute_at(&r, NOW);
assert!(
score.value <= 1.0,
"score should be clamped to 1.0, got {:.4}",
score.value
);
assert!(
score.value >= 0.0,
"score should be >= 0.0, got {:.4}",
score.value
);
}
#[test]
fn never_accessed_uses_created_at() {
let mut r = make_record(RecordSource::DeveloperManual, 0, 3, None);
r.last_accessed = 0;
r.created_at = NOW;
let score = recompute_at(&r, NOW);
assert!(
(score.value - 0.80).abs() < 0.01,
"never-accessed record created now should score ~0.80, got {:.4}",
score.value
);
}
#[test]
fn never_accessed_old_created_at_decays() {
let days_180 = 180 * 86_400;
let mut r = make_record(RecordSource::DeveloperManual, 0, 3, None);
r.last_accessed = 0;
r.created_at = NOW - days_180;
let score = recompute_at(&r, NOW);
assert!(
(score.value - 0.20).abs() < 0.02,
"never-accessed 180-day-old record should score ~0.20, got {:.4}",
score.value
);
}
#[test]
fn recompute_preserves_confidence_metadata() {
let mut r = make_record(RecordSource::DeveloperManual, 5, 2, None);
r.confidence.last_challenged = Some(1_710_000_000);
r.confidence.challenge_count = 3;
let score = recompute_at(&r, NOW);
assert_eq!(score.confirmation_count, 5);
assert_eq!(score.contributor_count, 2);
assert_eq!(score.last_challenged, Some(1_710_000_000));
assert_eq!(score.challenge_count, 3);
}
#[test]
fn source_ordering_matches_base_scores() {
let sources = [
RecordSource::StaticAnalysis,
RecordSource::SessionHook,
RecordSource::ClaudeEnrich,
RecordSource::Import,
RecordSource::DeveloperManual,
];
let scores: Vec<f32> = sources
.iter()
.map(|s| {
let r = make_record(s.clone(), 0, 3, None);
recompute_at(&r, NOW).value
})
.collect();
for i in 1..scores.len() {
assert!(
scores[i] > scores[i - 1],
"{:?} ({:.4}) should score higher than {:?} ({:.4})",
sources[i],
scores[i],
sources[i - 1],
scores[i - 1]
);
}
}
}