use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use super::observer::{EventBuffer, EventKind, SystemEvent, SystemEventData};
pub type SkillId = u64;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SkillOrigin {
AppSequence,
ToolChain,
SuggestionPattern,
NotificationResponse,
UserTaught,
}
impl SkillOrigin {
pub fn as_str(self) -> &'static str {
match self {
Self::AppSequence => "app_sequence",
Self::ToolChain => "tool_chain",
Self::SuggestionPattern => "suggestion_pattern",
Self::NotificationResponse => "notification_response",
Self::UserTaught => "user_taught",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SkillStage {
Candidate,
Validated,
Reliable,
Promoted,
Stale,
Deprecated,
}
impl SkillStage {
pub fn from_confidence(confidence: f64, last_used_age_secs: f64, deprecated: bool) -> Self {
if deprecated {
return Self::Deprecated;
}
if last_used_age_secs > 30.0 * 86400.0 && confidence < 0.9 {
return Self::Stale;
}
match confidence {
c if c >= 0.9 => Self::Promoted,
c if c >= 0.7 => Self::Reliable,
c if c >= 0.5 => Self::Validated,
_ => Self::Candidate,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillStep {
pub ordinal: u16,
pub action_kind: String,
pub app_id: Option<u16>,
pub tool_name: Option<String>,
pub expected_duration_ms: u64,
pub optional: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillTrigger {
pub time_bins: Vec<u8>,
pub initiating_app_id: Option<u16>,
pub preceding_transition: Option<(u16, u16)>,
pub day_of_week: Vec<u8>,
pub min_session_duration_secs: Option<f64>,
pub preceded_by_idle: Option<bool>,
}
impl SkillTrigger {
pub fn new() -> Self {
Self {
time_bins: Vec::new(),
initiating_app_id: None,
preceding_transition: None,
day_of_week: Vec::new(),
min_session_duration_secs: None,
preceded_by_idle: None,
}
}
pub fn specificity(&self) -> u8 {
let mut s = 0u8;
if !self.time_bins.is_empty() {
s += 1;
}
if self.initiating_app_id.is_some() {
s += 1;
}
if self.preceding_transition.is_some() {
s += 1;
}
if !self.day_of_week.is_empty() {
s += 1;
}
if self.min_session_duration_secs.is_some() {
s += 1;
}
if self.preceded_by_idle.is_some() {
s += 1;
}
s
}
pub fn match_score(
&self,
hour: u8,
day: u8,
current_app: Option<u16>,
recent_transition: Option<(u16, u16)>,
session_duration_secs: f64,
was_idle: bool,
) -> f64 {
let spec = self.specificity();
if spec == 0 {
return 1.0;
}
let mut matches = 0u8;
let mut total = 0u8;
if !self.time_bins.is_empty() {
total += 1;
if self.time_bins.contains(&hour) {
matches += 1;
}
}
if let Some(app) = self.initiating_app_id {
total += 1;
if current_app == Some(app) {
matches += 1;
}
}
if let Some(expected) = self.preceding_transition {
total += 1;
if recent_transition == Some(expected) {
matches += 1;
}
}
if !self.day_of_week.is_empty() {
total += 1;
if self.day_of_week.contains(&day) {
matches += 1;
}
}
if let Some(min_dur) = self.min_session_duration_secs {
total += 1;
if session_duration_secs >= min_dur {
matches += 1;
}
}
if let Some(expect_idle) = self.preceded_by_idle {
total += 1;
if was_idle == expect_idle {
matches += 1;
}
}
if total == 0 {
1.0
} else {
matches as f64 / total as f64
}
}
}
impl Default for SkillTrigger {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LearnedSkill {
pub id: SkillId,
pub description: String,
pub origin: SkillOrigin,
pub stage: SkillStage,
pub dedup_key: String,
pub steps: Vec<SkillStep>,
pub trigger: SkillTrigger,
pub confidence: f64,
pub observation_count: u32,
pub offer_count: u32,
pub acceptance_count: u32,
pub success_count: u32,
pub failure_count: u32,
pub discovered_at: f64,
pub last_seen_at: f64,
pub deprecated: bool,
pub promoted_schema_id: Option<u32>,
}
impl LearnedSkill {
pub fn new(
description: String,
origin: SkillOrigin,
dedup_key: String,
steps: Vec<SkillStep>,
trigger: SkillTrigger,
observation_count: u32,
now: f64,
) -> Self {
let id = hash_dedup_key(&dedup_key);
Self {
id,
description,
origin,
stage: SkillStage::Candidate,
dedup_key,
steps,
trigger,
confidence: 0.3,
observation_count,
offer_count: 0,
acceptance_count: 0,
success_count: 0,
failure_count: 0,
discovered_at: now,
last_seen_at: now,
deprecated: false,
promoted_schema_id: None,
}
}
pub fn observe(&mut self, now: f64) {
self.observation_count += 1;
self.last_seen_at = now;
let learning_rate = 0.05;
self.confidence += (1.0 - self.confidence) * learning_rate;
self.update_stage(now);
}
pub fn record_offer(&mut self) {
self.offer_count += 1;
}
pub fn record_acceptance(&mut self, now: f64) {
self.acceptance_count += 1;
self.last_seen_at = now;
let learning_rate = 0.10;
self.confidence += (1.0 - self.confidence) * learning_rate;
self.update_stage(now);
}
pub fn record_success(&mut self, now: f64) {
self.success_count += 1;
self.last_seen_at = now;
let learning_rate = 0.08;
self.confidence += (1.0 - self.confidence) * learning_rate;
self.update_stage(now);
}
pub fn record_failure(&mut self, now: f64) {
self.failure_count += 1;
self.last_seen_at = now;
let decay_rate = 0.15;
self.confidence -= self.confidence * decay_rate;
self.update_stage(now);
}
pub fn record_rejection(&mut self, now: f64) {
self.last_seen_at = now;
let decay_rate = 0.12;
self.confidence -= self.confidence * decay_rate;
self.update_stage(now);
}
pub fn deprecate(&mut self) {
self.deprecated = true;
self.stage = SkillStage::Deprecated;
}
pub fn success_rate(&self) -> f64 {
let total = self.success_count + self.failure_count;
if total == 0 {
0.5 } else {
self.success_count as f64 / total as f64
}
}
pub fn acceptance_rate(&self) -> f64 {
if self.offer_count == 0 {
0.5 } else {
self.acceptance_count as f64 / self.offer_count as f64
}
}
pub fn is_offerable(&self) -> bool {
matches!(self.stage, SkillStage::Reliable | SkillStage::Promoted)
&& !self.deprecated
&& self.confidence >= 0.7
}
pub fn is_promotable(&self) -> bool {
self.confidence >= 0.9
&& self.observation_count >= 10
&& self.success_rate() >= 0.75
&& self.promoted_schema_id.is_none()
&& !self.deprecated
}
pub fn step_count(&self) -> usize {
self.steps.len()
}
fn update_stage(&mut self, now: f64) {
let age = now - self.last_seen_at;
self.stage = SkillStage::from_confidence(self.confidence, age, self.deprecated);
}
}
fn hash_dedup_key(key: &str) -> SkillId {
let mut hash: u64 = 0xcbf29ce484222325;
for byte in key.as_bytes() {
hash ^= *byte as u64;
hash = hash.wrapping_mul(0x100000001b3);
}
hash
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillConfig {
pub min_observations: u32,
pub max_skills: usize,
pub max_steps_per_skill: usize,
pub min_offer_confidence: f64,
pub promotion_confidence: f64,
pub promotion_min_observations: u32,
pub promotion_min_success_rate: f64,
pub candidate_ttl_secs: f64,
pub stale_threshold_secs: f64,
pub max_sequence_gap_ms: u64,
pub auto_deprecate: bool,
pub auto_deprecate_threshold: f64,
pub auto_deprecate_min_offers: u32,
}
impl Default for SkillConfig {
fn default() -> Self {
Self {
min_observations: 3,
max_skills: 200,
max_steps_per_skill: 8,
min_offer_confidence: 0.7,
promotion_confidence: 0.9,
promotion_min_observations: 10,
promotion_min_success_rate: 0.75,
candidate_ttl_secs: 14.0 * 86400.0, stale_threshold_secs: 30.0 * 86400.0, max_sequence_gap_ms: 30_000, auto_deprecate: true,
auto_deprecate_threshold: 0.15,
auto_deprecate_min_offers: 5,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillRegistry {
pub skills: HashMap<String, LearnedSkill>,
pub total_discovered: u64,
pub total_promoted: u64,
pub total_deprecated: u64,
pub last_discovery_at: f64,
}
impl SkillRegistry {
pub fn new() -> Self {
Self {
skills: HashMap::new(),
total_discovered: 0,
total_promoted: 0,
total_deprecated: 0,
last_discovery_at: 0.0,
}
}
pub fn find(&self, dedup_key: &str) -> Option<&LearnedSkill> {
self.skills.get(dedup_key)
}
pub fn find_mut(&mut self, dedup_key: &str) -> Option<&mut LearnedSkill> {
self.skills.get_mut(dedup_key)
}
pub fn find_by_id(&self, id: SkillId) -> Option<&LearnedSkill> {
self.skills.values().find(|s| s.id == id)
}
pub fn active_count(&self) -> usize {
self.skills.values().filter(|s| !s.deprecated).count()
}
pub fn offerable(&self) -> Vec<&LearnedSkill> {
self.skills.values().filter(|s| s.is_offerable()).collect()
}
pub fn promotable(&self) -> Vec<&LearnedSkill> {
self.skills.values().filter(|s| s.is_promotable()).collect()
}
pub fn by_origin(&self, origin: SkillOrigin) -> Vec<&LearnedSkill> {
self.skills
.values()
.filter(|s| s.origin == origin && !s.deprecated)
.collect()
}
pub fn by_stage(&self, stage: SkillStage) -> Vec<&LearnedSkill> {
self.skills
.values()
.filter(|s| std::mem::discriminant(&s.stage) == std::mem::discriminant(&stage))
.collect()
}
pub fn len(&self) -> usize {
self.skills.len()
}
pub fn is_empty(&self) -> bool {
self.skills.is_empty()
}
}
impl Default for SkillRegistry {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscoveryResult {
pub new_skills: Vec<String>,
pub reinforced: Vec<String>,
pub auto_deprecated: Vec<String>,
pub pruned: Vec<String>,
pub total_skills: usize,
}
pub fn discover_skills(
buffer: &EventBuffer,
registry: &mut SkillRegistry,
config: &SkillConfig,
now: f64,
) -> DiscoveryResult {
let mut result = DiscoveryResult {
new_skills: Vec::new(),
reinforced: Vec::new(),
auto_deprecated: Vec::new(),
pruned: Vec::new(),
total_skills: 0,
};
discover_app_sequence_skills(buffer, registry, config, now, &mut result);
discover_tool_chain_skills(buffer, registry, config, now, &mut result);
discover_suggestion_pattern_skills(buffer, registry, config, now, &mut result);
discover_notification_response_skills(buffer, registry, config, now, &mut result);
if config.auto_deprecate {
auto_deprecate_skills(registry, config, &mut result);
}
prune_expired_candidates(registry, config, now, &mut result);
enforce_max_skills(registry, config);
registry.last_discovery_at = now;
result.total_skills = registry.len();
result
}
fn discover_app_sequence_skills(
buffer: &EventBuffer,
registry: &mut SkillRegistry,
config: &SkillConfig,
now: f64,
result: &mut DiscoveryResult,
) {
let chains = extract_app_chains(
buffer,
config.max_sequence_gap_ms,
config.max_steps_per_skill,
);
let mut chain_counts: HashMap<Vec<u16>, u32> = HashMap::new();
let mut chain_times: HashMap<Vec<u16>, Vec<f64>> = HashMap::new();
for (chain, timestamp) in &chains {
if chain.len() >= 2 {
*chain_counts.entry(chain.clone()).or_insert(0) += 1;
chain_times
.entry(chain.clone())
.or_default()
.push(*timestamp);
}
}
for (chain, count) in &chain_counts {
if *count < config.min_observations {
continue;
}
let dedup_key = format!(
"app_seq:{}",
chain
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join("→")
);
if let Some(skill) = registry.find_mut(&dedup_key) {
skill.observe(now);
result.reinforced.push(dedup_key);
} else {
let steps: Vec<SkillStep> = chain
.iter()
.enumerate()
.map(|(i, &app_id)| {
SkillStep {
ordinal: i as u16,
action_kind: "app_open".to_string(),
app_id: Some(app_id),
tool_name: None,
expected_duration_ms: 0, optional: false,
}
})
.collect();
let trigger = infer_trigger_from_timestamps(
chain_times.get(chain).unwrap_or(&Vec::new()),
Some(chain[0]),
);
let description = format!(
"App workflow: {}",
chain
.iter()
.map(|id| format!("app#{}", id))
.collect::<Vec<_>>()
.join(" → ")
);
let skill = LearnedSkill::new(
description,
SkillOrigin::AppSequence,
dedup_key.clone(),
steps,
trigger,
*count,
now,
);
registry.skills.insert(dedup_key.clone(), skill);
registry.total_discovered += 1;
result.new_skills.push(dedup_key);
}
}
}
fn discover_tool_chain_skills(
buffer: &EventBuffer,
registry: &mut SkillRegistry,
config: &SkillConfig,
now: f64,
result: &mut DiscoveryResult,
) {
let chains = extract_tool_chains(
buffer,
config.max_sequence_gap_ms,
config.max_steps_per_skill,
);
let mut chain_counts: HashMap<Vec<String>, u32> = HashMap::new();
let mut chain_times: HashMap<Vec<String>, Vec<f64>> = HashMap::new();
for (chain, timestamp) in &chains {
if chain.len() >= 2 {
*chain_counts.entry(chain.clone()).or_insert(0) += 1;
chain_times
.entry(chain.clone())
.or_default()
.push(*timestamp);
}
}
for (chain, count) in &chain_counts {
if *count < config.min_observations {
continue;
}
let dedup_key = format!("tool_chain:{}", chain.join("→"));
if let Some(skill) = registry.find_mut(&dedup_key) {
skill.observe(now);
result.reinforced.push(dedup_key);
} else {
let steps: Vec<SkillStep> = chain
.iter()
.enumerate()
.map(|(i, tool)| SkillStep {
ordinal: i as u16,
action_kind: "tool_call".to_string(),
app_id: None,
tool_name: Some(tool.clone()),
expected_duration_ms: 0,
optional: false,
})
.collect();
let trigger =
infer_trigger_from_timestamps(chain_times.get(chain).unwrap_or(&Vec::new()), None);
let description = format!("Tool chain: {}", chain.join(" → "));
let skill = LearnedSkill::new(
description,
SkillOrigin::ToolChain,
dedup_key.clone(),
steps,
trigger,
*count,
now,
);
registry.skills.insert(dedup_key.clone(), skill);
registry.total_discovered += 1;
result.new_skills.push(dedup_key);
}
}
}
fn discover_suggestion_pattern_skills(
buffer: &EventBuffer,
registry: &mut SkillRegistry,
config: &SkillConfig,
now: f64,
result: &mut DiscoveryResult,
) {
let mut accept_counts: HashMap<String, u32> = HashMap::new();
let mut reject_counts: HashMap<String, u32> = HashMap::new();
let mut accept_times: HashMap<String, Vec<f64>> = HashMap::new();
for event in buffer.recent(buffer.len()) {
match &event.data {
SystemEventData::SuggestionAccepted { action_kind, .. } => {
*accept_counts.entry(action_kind.clone()).or_insert(0) += 1;
accept_times
.entry(action_kind.clone())
.or_default()
.push(event.timestamp);
}
SystemEventData::SuggestionRejected { action_kind, .. } => {
*reject_counts.entry(action_kind.clone()).or_insert(0) += 1;
}
_ => {}
}
}
for (action_kind, accepts) in &accept_counts {
let rejects = reject_counts.get(action_kind).copied().unwrap_or(0);
let total = accepts + rejects;
if total < config.min_observations {
continue;
}
let acceptance_rate = *accepts as f64 / total as f64;
if acceptance_rate < 0.75 {
continue;
}
let dedup_key = format!("suggestion_pattern:{}", action_kind);
if let Some(skill) = registry.find_mut(&dedup_key) {
skill.observe(now);
result.reinforced.push(dedup_key);
} else {
let steps = vec![SkillStep {
ordinal: 0,
action_kind: action_kind.clone(),
app_id: None,
tool_name: None,
expected_duration_ms: 0,
optional: false,
}];
let trigger = infer_trigger_from_timestamps(
accept_times.get(action_kind).unwrap_or(&Vec::new()),
None,
);
let description = format!(
"Auto-action: {} ({}% acceptance rate)",
action_kind,
(acceptance_rate * 100.0) as u32,
);
let mut skill = LearnedSkill::new(
description,
SkillOrigin::SuggestionPattern,
dedup_key.clone(),
steps,
trigger,
*accepts,
now,
);
skill.confidence = 0.3 + acceptance_rate * 0.3;
registry.skills.insert(dedup_key.clone(), skill);
registry.total_discovered += 1;
result.new_skills.push(dedup_key);
}
}
}
fn discover_notification_response_skills(
buffer: &EventBuffer,
registry: &mut SkillRegistry,
config: &SkillConfig,
now: f64,
result: &mut DiscoveryResult,
) {
let mut response_counts: HashMap<(String, String), u32> = HashMap::new();
let mut response_times: HashMap<(String, String), Vec<f64>> = HashMap::new();
for event in buffer.recent(buffer.len()) {
if let SystemEventData::NotificationActedOn {
notif_type,
action_taken,
} = &event.data
{
let key = (notif_type.clone(), action_taken.clone());
*response_counts.entry(key.clone()).or_insert(0) += 1;
response_times.entry(key).or_default().push(event.timestamp);
}
}
for ((notif_type, action_taken), count) in &response_counts {
if *count < config.min_observations {
continue;
}
let dedup_key = format!("notif_response:{}→{}", notif_type, action_taken);
if let Some(skill) = registry.find_mut(&dedup_key) {
skill.observe(now);
result.reinforced.push(dedup_key);
} else {
let steps = vec![
SkillStep {
ordinal: 0,
action_kind: format!("receive_{}", notif_type),
app_id: None,
tool_name: None,
expected_duration_ms: 0,
optional: false,
},
SkillStep {
ordinal: 1,
action_kind: action_taken.clone(),
app_id: None,
tool_name: None,
expected_duration_ms: 0,
optional: false,
},
];
let key = (notif_type.clone(), action_taken.clone());
let trigger = infer_trigger_from_timestamps(
response_times.get(&key).unwrap_or(&Vec::new()),
None,
);
let description = format!(
"Auto-respond: {} notification → {}",
notif_type, action_taken
);
let skill = LearnedSkill::new(
description,
SkillOrigin::NotificationResponse,
dedup_key.clone(),
steps,
trigger,
*count,
now,
);
registry.skills.insert(dedup_key.clone(), skill);
registry.total_discovered += 1;
result.new_skills.push(dedup_key);
}
}
}
fn extract_app_chains(
buffer: &EventBuffer,
max_gap_ms: u64,
max_steps: usize,
) -> Vec<(Vec<u16>, f64)> {
let mut events = buffer.recent(buffer.len());
events.reverse(); let mut chains: Vec<(Vec<u16>, f64)> = Vec::new();
let mut current_chain: Vec<u16> = Vec::new();
let mut chain_start: f64 = 0.0;
let mut last_ts: f64 = 0.0;
for event in events {
match &event.data {
SystemEventData::AppOpened { app_id } => {
let gap_ms = ((event.timestamp - last_ts) * 1000.0) as u64;
if !current_chain.is_empty()
&& (gap_ms > max_gap_ms || current_chain.len() >= max_steps)
{
if current_chain.len() >= 2 {
chains.push((current_chain.clone(), chain_start));
}
current_chain.clear();
}
if current_chain.is_empty() {
chain_start = event.timestamp;
}
if current_chain.last() != Some(app_id) {
current_chain.push(*app_id);
}
last_ts = event.timestamp;
}
SystemEventData::AppSequence {
from_app, to_app, ..
} => {
let inter_event_gap_ms = if last_ts > 0.0 {
((event.timestamp - last_ts) * 1000.0) as u64
} else {
0
};
if !current_chain.is_empty()
&& (inter_event_gap_ms > max_gap_ms || current_chain.len() >= max_steps)
{
if current_chain.len() >= 2 {
chains.push((current_chain.clone(), chain_start));
}
current_chain.clear();
}
if current_chain.is_empty() {
chain_start = event.timestamp;
current_chain.push(*from_app);
}
if current_chain.last() != Some(to_app) {
current_chain.push(*to_app);
}
if current_chain.len() >= max_steps {
chains.push((current_chain.clone(), chain_start));
current_chain.clear();
}
last_ts = event.timestamp;
}
_ => {}
}
}
if current_chain.len() >= 2 {
chains.push((current_chain, chain_start));
}
chains
}
fn extract_tool_chains(
buffer: &EventBuffer,
max_gap_ms: u64,
max_steps: usize,
) -> Vec<(Vec<String>, f64)> {
let mut events = buffer.recent(buffer.len());
events.reverse(); let mut chains: Vec<(Vec<String>, f64)> = Vec::new();
let mut current_chain: Vec<String> = Vec::new();
let mut chain_start: f64 = 0.0;
let mut last_ts: f64 = 0.0;
for event in events {
if let SystemEventData::ToolCallCompleted {
tool_name, success, ..
} = &event.data
{
let gap_ms = ((event.timestamp - last_ts) * 1000.0) as u64;
if !current_chain.is_empty()
&& (gap_ms > max_gap_ms || current_chain.len() >= max_steps)
{
if current_chain.len() >= 2 {
chains.push((current_chain.clone(), chain_start));
}
current_chain.clear();
}
if current_chain.is_empty() {
chain_start = event.timestamp;
}
if *success {
current_chain.push(tool_name.clone());
} else {
if current_chain.len() >= 2 {
chains.push((current_chain.clone(), chain_start));
}
current_chain.clear();
}
last_ts = event.timestamp;
}
}
if current_chain.len() >= 2 {
chains.push((current_chain, chain_start));
}
chains
}
fn infer_trigger_from_timestamps(timestamps: &[f64], initiating_app: Option<u16>) -> SkillTrigger {
let mut trigger = SkillTrigger::new();
trigger.initiating_app_id = initiating_app;
if timestamps.is_empty() {
return trigger;
}
let mut hour_counts = [0u32; 24];
let mut day_counts = [0u32; 7];
for &ts in timestamps {
let secs = ts as u64;
let hour = ((secs % 86400) / 3600) as u8;
let day = ((secs / 86400 + 3) % 7) as u8; hour_counts[hour as usize] += 1;
day_counts[day as usize] += 1;
}
let total_obs = timestamps.len() as f64;
let uniform_rate = total_obs / 24.0;
for (hour, &count) in hour_counts.iter().enumerate() {
if count as f64 > uniform_rate * 2.0 && count >= 2 {
trigger.time_bins.push(hour as u8);
}
}
let day_uniform = total_obs / 7.0;
for (day, &count) in day_counts.iter().enumerate() {
if count as f64 > day_uniform * 1.5 && count >= 2 {
trigger.day_of_week.push(day as u8);
}
}
trigger
}
fn auto_deprecate_skills(
registry: &mut SkillRegistry,
config: &SkillConfig,
result: &mut DiscoveryResult,
) {
let keys: Vec<String> = registry
.skills
.iter()
.filter(|(_, s)| {
!s.deprecated
&& s.offer_count >= config.auto_deprecate_min_offers
&& s.acceptance_rate() < config.auto_deprecate_threshold
})
.map(|(k, _)| k.clone())
.collect();
for key in keys {
if let Some(skill) = registry.skills.get_mut(&key) {
skill.deprecate();
registry.total_deprecated += 1;
result.auto_deprecated.push(key);
}
}
}
fn prune_expired_candidates(
registry: &mut SkillRegistry,
config: &SkillConfig,
now: f64,
result: &mut DiscoveryResult,
) {
let expired: Vec<String> = registry
.skills
.iter()
.filter(|(_, s)| {
matches!(s.stage, SkillStage::Candidate)
&& (now - s.last_seen_at) > config.candidate_ttl_secs
})
.map(|(k, _)| k.clone())
.collect();
for key in expired {
registry.skills.remove(&key);
result.pruned.push(key);
}
}
fn enforce_max_skills(registry: &mut SkillRegistry, config: &SkillConfig) {
if registry.skills.len() <= config.max_skills {
return;
}
let mut ranked: Vec<(String, f64, u8)> = registry
.skills
.iter()
.map(|(k, s)| {
let priority = match s.stage {
SkillStage::Deprecated => 0,
SkillStage::Stale => 1,
SkillStage::Candidate => 2,
SkillStage::Validated => 3,
SkillStage::Reliable => 4,
SkillStage::Promoted => 5,
};
(k.clone(), s.confidence, priority)
})
.collect();
ranked.sort_by(|a, b| a.2.cmp(&b.2).then(a.1.total_cmp(&b.1)));
let to_remove = registry.skills.len() - config.max_skills;
for (key, _, _) in ranked.into_iter().take(to_remove) {
registry.skills.remove(&key);
}
}
#[derive(Debug, Clone)]
pub struct SkillMatch {
pub dedup_key: String,
pub skill_id: SkillId,
pub trigger_score: f64,
pub confidence: f64,
pub relevance: f64,
pub description: String,
pub step_count: usize,
}
pub fn find_matching_skills(
registry: &SkillRegistry,
hour: u8,
day: u8,
current_app: Option<u16>,
recent_transition: Option<(u16, u16)>,
session_duration_secs: f64,
was_idle: bool,
min_trigger_score: f64,
max_results: usize,
) -> Vec<SkillMatch> {
let mut matches: Vec<SkillMatch> = registry
.skills
.iter()
.filter(|(_, s)| s.is_offerable())
.filter_map(|(key, skill)| {
let trigger_score = skill.trigger.match_score(
hour,
day,
current_app,
recent_transition,
session_duration_secs,
was_idle,
);
if trigger_score < min_trigger_score {
return None;
}
let relevance = trigger_score * skill.confidence;
Some(SkillMatch {
dedup_key: key.clone(),
skill_id: skill.id,
trigger_score,
confidence: skill.confidence,
relevance,
description: skill.description.clone(),
step_count: skill.step_count(),
})
})
.collect();
matches.sort_by(|a, b| b.relevance.total_cmp(&a.relevance));
matches.truncate(max_results);
matches
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillSummary {
pub total: usize,
pub active: usize,
pub candidates: usize,
pub validated: usize,
pub reliable: usize,
pub promoted: usize,
pub stale: usize,
pub deprecated: usize,
pub total_discovered: u64,
pub total_promoted: u64,
pub total_deprecated: u64,
pub by_origin: HashMap<String, usize>,
}
pub fn summarize_skills(registry: &SkillRegistry) -> SkillSummary {
let mut by_origin: HashMap<String, usize> = HashMap::new();
let mut candidates = 0usize;
let mut validated = 0usize;
let mut reliable = 0usize;
let mut promoted = 0usize;
let mut stale = 0usize;
let mut deprecated = 0usize;
for skill in registry.skills.values() {
*by_origin
.entry(skill.origin.as_str().to_string())
.or_insert(0) += 1;
match skill.stage {
SkillStage::Candidate => candidates += 1,
SkillStage::Validated => validated += 1,
SkillStage::Reliable => reliable += 1,
SkillStage::Promoted => promoted += 1,
SkillStage::Stale => stale += 1,
SkillStage::Deprecated => deprecated += 1,
}
}
SkillSummary {
total: registry.len(),
active: registry.active_count(),
candidates,
validated,
reliable,
promoted,
stale,
deprecated,
total_discovered: registry.total_discovered,
total_promoted: registry.total_promoted,
total_deprecated: registry.total_deprecated,
by_origin,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ts(offset: f64) -> f64 {
86400.0 * 100.0 + offset
}
fn make_buffer(events: Vec<SystemEvent>) -> EventBuffer {
let mut buffer = EventBuffer::new(10_000);
for event in events {
buffer.push(event);
}
buffer
}
fn app_open(time: f64, app_id: u16) -> SystemEvent {
SystemEvent::new(time, SystemEventData::AppOpened { app_id })
}
fn app_seq(time: f64, from: u16, to: u16, gap: u64) -> SystemEvent {
SystemEvent::new(
time,
SystemEventData::AppSequence {
from_app: from,
to_app: to,
gap_ms: gap,
},
)
}
fn tool_call(time: f64, name: &str, success: bool) -> SystemEvent {
SystemEvent::new(
time,
SystemEventData::ToolCallCompleted {
tool_name: name.to_string(),
success,
duration_ms: 100,
},
)
}
fn suggestion_accept(time: f64, kind: &str) -> SystemEvent {
SystemEvent::new(
time,
SystemEventData::SuggestionAccepted {
suggestion_id: 1,
action_kind: kind.to_string(),
latency_ms: 200,
},
)
}
fn suggestion_reject(time: f64, kind: &str) -> SystemEvent {
SystemEvent::new(
time,
SystemEventData::SuggestionRejected {
suggestion_id: 2,
action_kind: kind.to_string(),
},
)
}
fn notif_acted(time: f64, notif_type: &str, action: &str) -> SystemEvent {
SystemEvent::new(
time,
SystemEventData::NotificationActedOn {
notif_type: notif_type.to_string(),
action_taken: action.to_string(),
},
)
}
#[test]
fn test_skill_creation() {
let skill = LearnedSkill::new(
"Test skill".to_string(),
SkillOrigin::AppSequence,
"test:key".to_string(),
vec![SkillStep {
ordinal: 0,
action_kind: "app_open".to_string(),
app_id: Some(14),
tool_name: None,
expected_duration_ms: 0,
optional: false,
}],
SkillTrigger::new(),
3,
ts(0.0),
);
assert_eq!(skill.stage, SkillStage::Candidate);
assert!((skill.confidence - 0.3).abs() < 0.01);
assert_eq!(skill.observation_count, 3);
assert!(!skill.is_offerable());
}
#[test]
fn test_skill_confidence_lifecycle() {
let mut skill = LearnedSkill::new(
"Test".to_string(),
SkillOrigin::ToolChain,
"test:lifecycle".to_string(),
vec![],
SkillTrigger::new(),
1,
ts(0.0),
);
for i in 1..30 {
skill.observe(ts(i as f64));
}
assert!(
skill.confidence > 0.5,
"Should gain confidence: {}",
skill.confidence
);
for i in 30..40 {
skill.record_acceptance(ts(i as f64));
}
assert!(
skill.confidence > 0.7,
"Should be reliable: {}",
skill.confidence
);
for i in 40..50 {
skill.record_failure(ts(i as f64));
}
assert!(
skill.confidence < 0.7,
"Failures should reduce confidence: {}",
skill.confidence
);
}
#[test]
fn test_skill_success_rate() {
let mut skill = LearnedSkill::new(
"Test".to_string(),
SkillOrigin::AppSequence,
"test:rate".to_string(),
vec![],
SkillTrigger::new(),
1,
ts(0.0),
);
assert!((skill.success_rate() - 0.5).abs() < 0.01);
skill.success_count = 7;
skill.failure_count = 3;
assert!((skill.success_rate() - 0.7).abs() < 0.01);
}
#[test]
fn test_skill_deprecation() {
let mut skill = LearnedSkill::new(
"Test".to_string(),
SkillOrigin::AppSequence,
"test:deprecate".to_string(),
vec![],
SkillTrigger::new(),
1,
ts(0.0),
);
skill.confidence = 0.8;
skill.stage = SkillStage::Reliable;
assert!(skill.is_offerable());
skill.deprecate();
assert_eq!(skill.stage, SkillStage::Deprecated);
assert!(!skill.is_offerable());
}
#[test]
fn test_skill_promotability() {
let mut skill = LearnedSkill::new(
"Test".to_string(),
SkillOrigin::AppSequence,
"test:promote".to_string(),
vec![],
SkillTrigger::new(),
12,
ts(0.0),
);
skill.confidence = 0.95;
skill.success_count = 10;
skill.failure_count = 1;
assert!(skill.is_promotable());
skill.promoted_schema_id = Some(42);
assert!(!skill.is_promotable());
}
#[test]
fn test_trigger_universal() {
let trigger = SkillTrigger::new();
let score = trigger.match_score(12, 3, None, None, 0.0, false);
assert!(
(score - 1.0).abs() < 0.01,
"Universal trigger should always match"
);
}
#[test]
fn test_trigger_specificity() {
let mut trigger = SkillTrigger::new();
assert_eq!(trigger.specificity(), 0);
trigger.time_bins = vec![9, 10, 11];
assert_eq!(trigger.specificity(), 1);
trigger.initiating_app_id = Some(14);
assert_eq!(trigger.specificity(), 2);
}
#[test]
fn test_trigger_matching() {
let trigger = SkillTrigger {
time_bins: vec![9, 10, 11],
initiating_app_id: Some(14),
preceding_transition: None,
day_of_week: vec![0, 1, 2, 3, 4], min_session_duration_secs: None,
preceded_by_idle: None,
};
let score = trigger.match_score(10, 2, Some(14), None, 0.0, false);
assert!((score - 1.0).abs() < 0.01, "All conditions met: {}", score);
let score = trigger.match_score(22, 2, Some(14), None, 0.0, false);
assert!((score - 2.0 / 3.0).abs() < 0.01, "2/3 match: {}", score);
let score = trigger.match_score(22, 5, Some(99), None, 0.0, false);
assert!((score).abs() < 0.01, "0/3 match: {}", score);
}
#[test]
fn test_discover_app_sequences() {
let mut events = Vec::new();
for round in 0..3 {
let base = round as f64 * 100.0;
events.push(app_seq(ts(base + 1.0), 14, 20, 2000));
events.push(app_seq(ts(base + 3.0), 20, 15, 3000));
}
let buffer = make_buffer(events);
let mut registry = SkillRegistry::new();
let config = SkillConfig::default();
let result = discover_skills(&buffer, &mut registry, &config, ts(500.0));
assert!(
!result.new_skills.is_empty(),
"Should discover app sequence skills"
);
let key = result.new_skills.iter().find(|k| k.starts_with("app_seq:"));
assert!(key.is_some(), "Should have app_seq skill");
let skill = registry.find(key.unwrap()).unwrap();
assert_eq!(skill.origin, SkillOrigin::AppSequence);
assert!(skill.steps.len() >= 2);
}
#[test]
fn test_discover_reinforces_existing() {
let mut events = Vec::new();
for round in 0..5 {
let base = round as f64 * 100.0;
events.push(app_seq(ts(base + 1.0), 1, 2, 1000));
events.push(app_seq(ts(base + 2.0), 2, 3, 1000));
}
let buffer = make_buffer(events);
let mut registry = SkillRegistry::new();
let config = SkillConfig::default();
let result1 = discover_skills(&buffer, &mut registry, &config, ts(600.0));
assert!(!result1.new_skills.is_empty());
let initial_confidence = registry.skills.values().next().unwrap().confidence;
let result2 = discover_skills(&buffer, &mut registry, &config, ts(700.0));
assert!(!result2.reinforced.is_empty());
let reinforced_confidence = registry.skills.values().next().unwrap().confidence;
assert!(
reinforced_confidence > initial_confidence,
"Reinforcement should increase confidence"
);
}
#[test]
fn test_discover_tool_chains() {
let mut events = Vec::new();
for round in 0..3 {
let base = round as f64 * 100.0;
events.push(tool_call(ts(base + 1.0), "fetch_data", true));
events.push(tool_call(ts(base + 2.0), "transform", true));
events.push(tool_call(ts(base + 3.0), "store", true));
}
let buffer = make_buffer(events);
let mut registry = SkillRegistry::new();
let config = SkillConfig::default();
let result = discover_skills(&buffer, &mut registry, &config, ts(500.0));
let tool_skills: Vec<_> = result
.new_skills
.iter()
.filter(|k| k.starts_with("tool_chain:"))
.collect();
assert!(!tool_skills.is_empty(), "Should discover tool chain skills");
}
#[test]
fn test_tool_chain_broken_by_failure() {
let mut events = Vec::new();
for round in 0..3 {
let base = round as f64 * 100.0;
events.push(tool_call(ts(base + 1.0), "fetch", true));
events.push(tool_call(ts(base + 2.0), "broken", false)); events.push(tool_call(ts(base + 3.0), "store", true));
}
let buffer = make_buffer(events);
let mut registry = SkillRegistry::new();
let config = SkillConfig::default();
let result = discover_skills(&buffer, &mut registry, &config, ts(500.0));
for key in &result.new_skills {
if key.starts_with("tool_chain:") {
let skill = registry.find(key).unwrap();
assert!(skill.steps.len() <= 2, "Failure should break chain");
}
}
}
#[test]
fn test_discover_suggestion_patterns() {
let mut events = Vec::new();
for i in 0..8 {
events.push(suggestion_accept(ts(i as f64), "remind"));
}
for i in 0..2 {
events.push(suggestion_reject(ts(10.0 + i as f64), "remind"));
}
let buffer = make_buffer(events);
let mut registry = SkillRegistry::new();
let config = SkillConfig::default();
let result = discover_skills(&buffer, &mut registry, &config, ts(500.0));
let pattern_skills: Vec<_> = result
.new_skills
.iter()
.filter(|k| k.starts_with("suggestion_pattern:"))
.collect();
assert!(
!pattern_skills.is_empty(),
"Should discover suggestion pattern"
);
let skill = registry.find("suggestion_pattern:remind").unwrap();
assert!(
skill.confidence > 0.3,
"High acceptance should boost initial confidence"
);
}
#[test]
fn test_suggestion_pattern_low_acceptance_ignored() {
let mut events = Vec::new();
for i in 0..2 {
events.push(suggestion_accept(ts(i as f64), "nag"));
}
for i in 0..8 {
events.push(suggestion_reject(ts(10.0 + i as f64), "nag"));
}
let buffer = make_buffer(events);
let mut registry = SkillRegistry::new();
let config = SkillConfig::default();
let result = discover_skills(&buffer, &mut registry, &config, ts(500.0));
assert!(
!result
.new_skills
.contains(&"suggestion_pattern:nag".to_string()),
"Low acceptance should not create skill"
);
}
#[test]
fn test_discover_notification_responses() {
let mut events = Vec::new();
for i in 0..5 {
events.push(notif_acted(ts(i as f64), "email", "archive"));
}
let buffer = make_buffer(events);
let mut registry = SkillRegistry::new();
let config = SkillConfig::default();
let result = discover_skills(&buffer, &mut registry, &config, ts(500.0));
let notif_skills: Vec<_> = result
.new_skills
.iter()
.filter(|k| k.starts_with("notif_response:"))
.collect();
assert!(
!notif_skills.is_empty(),
"Should discover notification response skill"
);
let skill = registry.find("notif_response:email→archive").unwrap();
assert_eq!(skill.origin, SkillOrigin::NotificationResponse);
assert_eq!(skill.steps.len(), 2);
}
#[test]
fn test_auto_deprecation() {
let mut registry = SkillRegistry::new();
let config = SkillConfig::default();
let mut skill = LearnedSkill::new(
"Bad skill".to_string(),
SkillOrigin::AppSequence,
"test:bad".to_string(),
vec![],
SkillTrigger::new(),
10,
ts(0.0),
);
skill.confidence = 0.8;
skill.stage = SkillStage::Reliable;
skill.offer_count = 10;
skill.acceptance_count = 1; registry.skills.insert("test:bad".to_string(), skill);
let buffer = make_buffer(vec![]);
let result = discover_skills(&buffer, &mut registry, &config, ts(500.0));
assert!(
result.auto_deprecated.contains(&"test:bad".to_string()),
"Low acceptance should trigger auto-deprecation"
);
assert!(registry.find("test:bad").unwrap().deprecated);
}
#[test]
fn test_candidate_pruning() {
let mut registry = SkillRegistry::new();
let config = SkillConfig::default();
let skill = LearnedSkill::new(
"Old candidate".to_string(),
SkillOrigin::AppSequence,
"test:old".to_string(),
vec![],
SkillTrigger::new(),
1,
ts(0.0),
);
registry.skills.insert("test:old".to_string(), skill);
let buffer = make_buffer(vec![]);
let future = ts(0.0) + config.candidate_ttl_secs + 1000.0;
let result = discover_skills(&buffer, &mut registry, &config, future);
assert!(
result.pruned.contains(&"test:old".to_string()),
"Expired candidates should be pruned"
);
assert!(registry.find("test:old").is_none());
}
#[test]
fn test_enforce_max_skills() {
let mut registry = SkillRegistry::new();
let mut config = SkillConfig::default();
config.max_skills = 3;
for i in 0..5 {
let mut skill = LearnedSkill::new(
format!("Skill {}", i),
SkillOrigin::AppSequence,
format!("test:max_{}", i),
vec![],
SkillTrigger::new(),
1,
ts(0.0),
);
skill.confidence = 0.3 + i as f64 * 0.1;
registry.skills.insert(format!("test:max_{}", i), skill);
}
enforce_max_skills(&mut registry, &config);
assert_eq!(registry.len(), 3, "Should enforce max_skills limit");
assert!(registry.find("test:max_4").is_some());
assert!(registry.find("test:max_3").is_some());
assert!(registry.find("test:max_2").is_some());
}
#[test]
fn test_find_matching_skills() {
let mut registry = SkillRegistry::new();
let mut skill1 = LearnedSkill::new(
"Morning workflow".to_string(),
SkillOrigin::AppSequence,
"test:morning".to_string(),
vec![SkillStep {
ordinal: 0,
action_kind: "app_open".to_string(),
app_id: Some(14),
tool_name: None,
expected_duration_ms: 0,
optional: false,
}],
SkillTrigger {
time_bins: vec![8, 9, 10],
initiating_app_id: Some(14),
preceding_transition: None,
day_of_week: vec![],
min_session_duration_secs: None,
preceded_by_idle: None,
},
10,
ts(0.0),
);
skill1.confidence = 0.85;
skill1.stage = SkillStage::Reliable;
let mut skill2 = LearnedSkill::new(
"Evening routine".to_string(),
SkillOrigin::AppSequence,
"test:evening".to_string(),
vec![],
SkillTrigger {
time_bins: vec![20, 21, 22],
initiating_app_id: None,
preceding_transition: None,
day_of_week: vec![],
min_session_duration_secs: None,
preceded_by_idle: None,
},
10,
ts(0.0),
);
skill2.confidence = 0.75;
skill2.stage = SkillStage::Reliable;
registry.skills.insert("test:morning".to_string(), skill1);
registry.skills.insert("test:evening".to_string(), skill2);
let matches = find_matching_skills(®istry, 9, 2, Some(14), None, 3600.0, false, 0.3, 10);
assert!(!matches.is_empty());
assert_eq!(
matches[0].dedup_key, "test:morning",
"Morning skill should rank highest"
);
assert!(matches[0].relevance > 0.5);
}
#[test]
fn test_find_matching_excludes_non_offerable() {
let mut registry = SkillRegistry::new();
let skill = LearnedSkill::new(
"Candidate".to_string(),
SkillOrigin::AppSequence,
"test:candidate".to_string(),
vec![],
SkillTrigger::new(),
1,
ts(0.0),
);
registry.skills.insert("test:candidate".to_string(), skill);
let matches = find_matching_skills(®istry, 12, 3, None, None, 0.0, false, 0.0, 10);
assert!(matches.is_empty(), "Candidates should not be matched");
}
#[test]
fn test_skill_summary() {
let mut registry = SkillRegistry::new();
registry.total_discovered = 5;
registry.total_promoted = 1;
registry.total_deprecated = 1;
let mut s1 = LearnedSkill::new(
"A".to_string(),
SkillOrigin::AppSequence,
"a".to_string(),
vec![],
SkillTrigger::new(),
1,
ts(0.0),
);
s1.stage = SkillStage::Reliable;
registry.skills.insert("a".to_string(), s1);
let mut s2 = LearnedSkill::new(
"B".to_string(),
SkillOrigin::ToolChain,
"b".to_string(),
vec![],
SkillTrigger::new(),
1,
ts(0.0),
);
s2.stage = SkillStage::Candidate;
registry.skills.insert("b".to_string(), s2);
let mut s3 = LearnedSkill::new(
"C".to_string(),
SkillOrigin::AppSequence,
"c".to_string(),
vec![],
SkillTrigger::new(),
1,
ts(0.0),
);
s3.deprecated = true;
s3.stage = SkillStage::Deprecated;
registry.skills.insert("c".to_string(), s3);
let summary = summarize_skills(®istry);
assert_eq!(summary.total, 3);
assert_eq!(summary.active, 2);
assert_eq!(summary.reliable, 1);
assert_eq!(summary.candidates, 1);
assert_eq!(summary.deprecated, 1);
assert_eq!(summary.by_origin.get("app_sequence"), Some(&2));
assert_eq!(summary.by_origin.get("tool_chain"), Some(&1));
}
#[test]
fn test_registry_queries() {
let mut registry = SkillRegistry::new();
let mut s1 = LearnedSkill::new(
"A".to_string(),
SkillOrigin::AppSequence,
"a".to_string(),
vec![],
SkillTrigger::new(),
5,
ts(0.0),
);
s1.confidence = 0.8;
s1.stage = SkillStage::Reliable;
registry.skills.insert("a".to_string(), s1);
let s2 = LearnedSkill::new(
"B".to_string(),
SkillOrigin::ToolChain,
"b".to_string(),
vec![],
SkillTrigger::new(),
1,
ts(0.0),
);
registry.skills.insert("b".to_string(), s2);
assert_eq!(registry.active_count(), 2);
assert_eq!(registry.offerable().len(), 1);
assert_eq!(registry.by_origin(SkillOrigin::AppSequence).len(), 1);
assert_eq!(registry.by_origin(SkillOrigin::ToolChain).len(), 1);
assert!(registry.find("a").is_some());
assert!(registry.find("nonexistent").is_none());
}
#[test]
fn test_dedup_key_hash_stable() {
let h1 = hash_dedup_key("app_seq:14→20→15");
let h2 = hash_dedup_key("app_seq:14→20→15");
let h3 = hash_dedup_key("app_seq:14→20→16");
assert_eq!(h1, h2, "Same key should produce same hash");
assert_ne!(h1, h3, "Different keys should produce different hashes");
}
#[test]
fn test_trigger_inference() {
let timestamps: Vec<f64> = (0..10)
.map(|i| {
86400.0 * (100 + i) as f64 + 32400.0 })
.collect();
let trigger = infer_trigger_from_timestamps(×tamps, Some(14));
assert!(
trigger.time_bins.contains(&9),
"Should detect 9am peak: {:?}",
trigger.time_bins
);
assert_eq!(trigger.initiating_app_id, Some(14));
}
#[test]
fn test_trigger_empty_timestamps() {
let trigger = infer_trigger_from_timestamps(&[], None);
assert_eq!(trigger.specificity(), 0);
}
#[test]
fn test_empty_buffer_discovery() {
let buffer = make_buffer(vec![]);
let mut registry = SkillRegistry::new();
let config = SkillConfig::default();
let result = discover_skills(&buffer, &mut registry, &config, ts(0.0));
assert!(result.new_skills.is_empty());
assert!(result.reinforced.is_empty());
assert_eq!(result.total_skills, 0);
}
#[test]
fn test_below_minimum_observations() {
let mut events = Vec::new();
for round in 0..2 {
let base = round as f64 * 100.0;
events.push(app_seq(ts(base + 1.0), 14, 20, 2000));
}
let buffer = make_buffer(events);
let mut registry = SkillRegistry::new();
let config = SkillConfig::default();
let result = discover_skills(&buffer, &mut registry, &config, ts(500.0));
assert!(
result.new_skills.is_empty(),
"Below min_observations should not create skill"
);
}
#[test]
fn test_skill_stage_from_confidence() {
assert_eq!(
SkillStage::from_confidence(0.3, 0.0, false),
SkillStage::Candidate
);
assert_eq!(
SkillStage::from_confidence(0.6, 0.0, false),
SkillStage::Validated
);
assert_eq!(
SkillStage::from_confidence(0.8, 0.0, false),
SkillStage::Reliable
);
assert_eq!(
SkillStage::from_confidence(0.95, 0.0, false),
SkillStage::Promoted
);
assert_eq!(
SkillStage::from_confidence(0.8, 31.0 * 86400.0, false),
SkillStage::Stale
);
assert_eq!(
SkillStage::from_confidence(0.8, 0.0, true),
SkillStage::Deprecated
);
}
}