use serde::{Deserialize, Serialize};
use smallvec::SmallVec;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ActivityType {
Reading,
Writing,
Debugging,
Refactoring,
Reviewing,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum InferenceSource {
Explicit,
Keyword,
Tool,
Default,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActivityInference {
pub activity_type: ActivityType,
pub source: InferenceSource,
pub confidence: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocationIntuition {
pub id: u32,
pub familiarity: f64,
pub access_count: u32,
pub searches_saved: u32,
pub last_accessed_ms: f64,
pub is_pinned: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocationAssociation {
pub source: u32,
pub target: u32,
pub strength: f64,
pub co_access_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocationConfig {
pub familiarity_k: f64,
pub stale_threshold_days: u32,
pub max_decay_rate: f64,
pub decay_dampening: f64,
pub base_floor: f64,
pub sticky_bonus: f64,
pub well_known_threshold: f64,
pub task_same_activity_multiplier: f64,
pub task_diff_activity_multiplier: f64,
pub time_same_activity_multiplier: f64,
pub time_diff_activity_multiplier: f64,
pub backward_strength_factor: f64,
}
impl Default for LocationConfig {
fn default() -> Self {
Self {
familiarity_k: 0.1,
stale_threshold_days: 30,
max_decay_rate: 0.10,
decay_dampening: 0.8,
base_floor: 0.1,
sticky_bonus: 0.4,
well_known_threshold: 0.7,
task_same_activity_multiplier: 5.0,
task_diff_activity_multiplier: 3.0,
time_same_activity_multiplier: 2.0,
time_diff_activity_multiplier: 1.0,
backward_strength_factor: 0.7,
}
}
}
#[inline]
#[must_use]
pub fn compute_familiarity(access_count: u32, config: &LocationConfig) -> f64 {
let n = f64::from(access_count);
1.0 - 1.0 / config.familiarity_k.mul_add(n, 1.0)
}
#[inline]
#[must_use]
pub fn initial_familiarity(config: &LocationConfig) -> f64 {
compute_familiarity(1, config)
}
#[must_use]
pub fn compute_decayed_familiarity(
current_familiarity: f64,
last_accessed_ms: f64,
current_time_ms: f64,
is_pinned: bool,
config: &LocationConfig,
) -> f64 {
if is_pinned {
return current_familiarity;
}
if !last_accessed_ms.is_finite() || last_accessed_ms < 0.0 {
return current_familiarity;
}
let ms_per_day = 24.0 * 60.0 * 60.0 * 1000.0;
let days_since_access = (current_time_ms - last_accessed_ms) / ms_per_day;
if days_since_access < f64::from(config.stale_threshold_days) {
return current_familiarity;
}
let decay_rate =
config.max_decay_rate * current_familiarity.mul_add(-config.decay_dampening, 1.0);
let floor = if current_familiarity > 0.5 {
config
.sticky_bonus
.mul_add(current_familiarity - 0.5, config.base_floor)
} else {
config.base_floor
};
let decayed = current_familiarity * (1.0 - decay_rate);
decayed.max(floor)
}
#[must_use]
pub fn compute_batch_decay(
locations: &[LocationIntuition],
current_time_ms: f64,
config: &LocationConfig,
) -> Vec<f64> {
locations
.iter()
.map(|loc| {
compute_decayed_familiarity(
loc.familiarity,
loc.last_accessed_ms,
current_time_ms,
loc.is_pinned,
config,
)
})
.collect()
}
#[must_use]
pub fn infer_activity_type(
context: &str,
tool_name: Option<&str>,
explicit: Option<ActivityType>,
) -> ActivityInference {
if let Some(activity) = explicit {
if activity != ActivityType::Unknown {
return ActivityInference {
activity_type: activity,
source: InferenceSource::Explicit,
confidence: 1.0,
};
}
}
let lower = context.to_lowercase();
let keyword_matches: &[(ActivityType, &[&str], f64)] = &[
(
ActivityType::Debugging,
&["debug", "fix", "bug", "issue", "error", "trace"],
0.9,
),
(
ActivityType::Refactoring,
&["refactor", "clean", "reorganize", "restructure"],
0.9,
),
(
ActivityType::Reviewing,
&["review", "understand", "check", "examine", "audit"],
0.8,
),
(
ActivityType::Writing,
&["implement", "add", "create", "write", "build"],
0.7,
),
(
ActivityType::Reading,
&["read", "look", "see", "view", "inspect"],
0.6,
),
];
for (activity_type, keywords, confidence) in keyword_matches {
if keywords.iter().any(|kw| lower.contains(kw)) {
return ActivityInference {
activity_type: *activity_type,
source: InferenceSource::Keyword,
confidence: *confidence,
};
}
}
if let Some(tool) = tool_name {
let tool_activity = match tool {
"Read" | "Grep" | "Glob" => Some(ActivityType::Reading),
"Edit" | "Write" => Some(ActivityType::Writing),
_ => None,
};
if let Some(activity) = tool_activity {
return ActivityInference {
activity_type: activity,
source: InferenceSource::Tool,
confidence: 0.5,
};
}
}
ActivityInference {
activity_type: ActivityType::Unknown,
source: InferenceSource::Default,
confidence: 0.0,
}
}
#[inline]
#[must_use]
pub fn compute_association_strength(
current_count: u32,
multiplier: f64,
config: &LocationConfig,
) -> f64 {
let effective_count = f64::from(current_count) * multiplier;
1.0 - 1.0 / config.familiarity_k.mul_add(effective_count, 1.0)
}
#[inline]
#[must_use]
#[allow(clippy::missing_const_for_fn)] pub fn association_multiplier(
is_same_task: bool,
is_same_activity: bool,
config: &LocationConfig,
) -> f64 {
match (is_same_task, is_same_activity) {
(true, true) => config.task_same_activity_multiplier,
(true, false) => config.task_diff_activity_multiplier,
(false, true) => config.time_same_activity_multiplier,
(false, false) => config.time_diff_activity_multiplier,
}
}
use crate::spreading::{spread_activation, Association, SpreadingConfig};
#[must_use]
pub fn spread_location_activation(
num_locations: usize,
seed_location: u32,
seed_activation: f64,
associations: &[LocationAssociation],
location_config: &LocationConfig,
spreading_config: &SpreadingConfig,
) -> Vec<f64> {
let core_associations: Vec<Association> = associations
.iter()
.map(|la| Association {
source: la.source as usize,
target: la.target as usize,
forward_strength: la.strength,
backward_strength: la.strength * location_config.backward_strength_factor,
})
.collect();
let result = spread_activation(
num_locations,
&core_associations,
&[seed_location as usize],
&[seed_activation],
spreading_config,
spreading_config.max_nodes.min(3), );
result.activations
}
#[must_use]
pub fn get_associated_locations(
location_id: u32,
associations: &[LocationAssociation],
limit: usize,
) -> SmallVec<[(u32, f64); 16]> {
let mut results: SmallVec<[(u32, f64); 16]> = associations
.iter()
.filter(|a| a.source == location_id)
.map(|a| (a.target, a.strength))
.collect();
results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
results.truncate(limit);
results
}
#[inline]
#[must_use]
pub fn is_well_known(familiarity: f64, config: &LocationConfig) -> bool {
familiarity >= config.well_known_threshold
}
#[cfg(test)]
#[allow(clippy::float_cmp, clippy::suboptimal_flops)]
mod tests {
use super::*;
#[test]
fn familiarity_curve_matches_specification() {
let config = LocationConfig::default();
assert!((compute_familiarity(1, &config) - 0.091).abs() < 0.001);
assert!((compute_familiarity(10, &config) - 0.5).abs() < 0.01);
assert!(compute_familiarity(24, &config) >= 0.7);
assert!(compute_familiarity(1000, &config) > 0.99);
assert!(compute_familiarity(1000, &config) < 1.0);
}
#[test]
fn decay_respects_stale_threshold() {
let config = LocationConfig::default();
let current_time = 1000.0 * 60.0 * 60.0 * 24.0 * 100.0;
let recent = current_time - (10.0 * 24.0 * 60.0 * 60.0 * 1000.0);
assert_eq!(
compute_decayed_familiarity(0.8, recent, current_time, false, &config),
0.8
);
let old = current_time - (60.0 * 24.0 * 60.0 * 60.0 * 1000.0);
assert!(compute_decayed_familiarity(0.8, old, current_time, false, &config) < 0.8);
}
#[test]
fn high_familiarity_has_sticky_floor() {
let config = LocationConfig::default();
let current_time = 1000.0 * 60.0 * 60.0 * 24.0 * 365.0; let very_old = 0.0;
let decayed = compute_decayed_familiarity(0.9, very_old, current_time, false, &config);
let expected_floor = config.base_floor + config.sticky_bonus * (0.9 - 0.5);
assert!(decayed >= expected_floor);
}
#[test]
fn pinned_locations_never_decay() {
let config = LocationConfig::default();
let current_time = 1000.0 * 60.0 * 60.0 * 24.0 * 365.0; let very_old = 0.0;
let decayed = compute_decayed_familiarity(0.5, very_old, current_time, true, &config);
assert_eq!(decayed, 0.5);
}
#[test]
fn handles_invalid_timestamps() {
let config = LocationConfig::default();
let current_time = 1000.0 * 60.0 * 60.0 * 24.0 * 100.0;
let result = compute_decayed_familiarity(0.7, f64::NAN, current_time, false, &config);
assert_eq!(result, 0.7);
let result = compute_decayed_familiarity(0.7, f64::INFINITY, current_time, false, &config);
assert_eq!(result, 0.7);
let result = compute_decayed_familiarity(0.7, -1000.0, current_time, false, &config);
assert_eq!(result, 0.7);
}
#[test]
fn activity_inference_precedence() {
let result =
infer_activity_type("reading code", Some("Read"), Some(ActivityType::Debugging));
assert_eq!(result.activity_type, ActivityType::Debugging);
assert_eq!(result.source, InferenceSource::Explicit);
let result = infer_activity_type("debugging the issue", Some("Read"), None);
assert_eq!(result.activity_type, ActivityType::Debugging);
assert_eq!(result.source, InferenceSource::Keyword);
let result = infer_activity_type("opening the file", Some("Read"), None);
assert_eq!(result.activity_type, ActivityType::Reading);
assert_eq!(result.source, InferenceSource::Tool);
let result = infer_activity_type("doing something", Some("Edit"), None);
assert_eq!(result.activity_type, ActivityType::Writing);
assert_eq!(result.source, InferenceSource::Tool);
let result = infer_activity_type("doing stuff", None, None);
assert_eq!(result.activity_type, ActivityType::Unknown);
assert_eq!(result.source, InferenceSource::Default);
}
#[test]
fn task_associations_stronger_than_time() {
let config = LocationConfig::default();
let task_same = association_multiplier(true, true, &config);
let task_diff = association_multiplier(true, false, &config);
let time_same = association_multiplier(false, true, &config);
let time_diff = association_multiplier(false, false, &config);
assert!(task_same > task_diff);
assert!(task_diff > time_same);
assert!(time_same > time_diff);
}
#[test]
fn association_strength_follows_asymptotic_curve() {
let config = LocationConfig::default();
let strength = compute_association_strength(2, 5.0, &config);
assert!((strength - 0.5).abs() < 0.01);
let weak_strength = compute_association_strength(2, 1.0, &config);
assert!(weak_strength < strength);
}
#[test]
fn well_known_threshold() {
let config = LocationConfig::default();
assert!(!is_well_known(0.5, &config));
assert!(!is_well_known(0.69, &config));
assert!(is_well_known(0.7, &config));
assert!(is_well_known(0.9, &config));
}
#[test]
fn get_associated_returns_sorted_by_strength() {
let associations = vec![
LocationAssociation {
source: 0,
target: 1,
strength: 0.5,
co_access_count: 5,
},
LocationAssociation {
source: 0,
target: 2,
strength: 0.9,
co_access_count: 10,
},
LocationAssociation {
source: 0,
target: 3,
strength: 0.3,
co_access_count: 3,
},
];
let results = get_associated_locations(0, &associations, 10);
assert_eq!(results.len(), 3);
assert_eq!(results[0], (2, 0.9)); assert_eq!(results[1], (1, 0.5));
assert_eq!(results[2], (3, 0.3));
}
#[test]
fn batch_decay_applies_to_all() {
let config = LocationConfig::default();
let current_time = 1000.0 * 60.0 * 60.0 * 24.0 * 100.0;
let old_time = current_time - (60.0 * 24.0 * 60.0 * 60.0 * 1000.0);
let locations = vec![
LocationIntuition {
id: 0,
familiarity: 0.8,
access_count: 20,
searches_saved: 5,
last_accessed_ms: old_time,
is_pinned: false,
},
LocationIntuition {
id: 1,
familiarity: 0.5,
access_count: 10,
searches_saved: 2,
last_accessed_ms: old_time,
is_pinned: true, },
];
let decayed = compute_batch_decay(&locations, current_time, &config);
assert!(decayed[0] < 0.8); assert_eq!(decayed[1], 0.5); }
}