car-memgine 0.48.0

Memgine — graph-based memory engine for Common Agent Runtime
//! Runtime boundary for approved guidance packs.
//!
//! This wraps MemgineEngine with:
//! - an immutable approved pack artifact,
//! - a name-to-ID mapping for loaded skills,
//! - a mutable runtime overlay for outcomes and review signals.

use crate::config::MemgineConfig;
use crate::engine::MemgineEngine;
use crate::graph::{SkillMeta, SkillOutcome};
use crate::guidance::{ApprovedSkillPack, RuntimeSkillOverlay, RuntimeSkillStats};
use std::collections::HashMap;

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApprovedSkillRuntimeError {
    DuplicateSkillTitle { title: String },
    UnknownSkillId { skill_id: String },
}

#[derive(Debug, Clone, PartialEq)]
pub struct ApprovedSkillMatch {
    pub skill_id: String,
    pub skill_name: String,
    pub score: f64,
    pub meta: SkillMeta,
}

/// Runtime session backed by an immutable approved pack and a mutable overlay.
pub struct ApprovedSkillRuntime {
    engine: MemgineEngine,
    approved_pack: ApprovedSkillPack,
    overlay: RuntimeSkillOverlay,
    skill_name_to_id: HashMap<String, String>,
    skill_id_to_name: HashMap<String, String>,
}

impl ApprovedSkillRuntime {
    pub fn new(
        approved_pack: ApprovedSkillPack,
        config: Option<MemgineConfig>,
    ) -> Result<Self, ApprovedSkillRuntimeError> {
        Self::with_engine(MemgineEngine::new(config), approved_pack)
    }

    pub fn with_engine(
        mut engine: MemgineEngine,
        approved_pack: ApprovedSkillPack,
    ) -> Result<Self, ApprovedSkillRuntimeError> {
        let skill_name_to_id = build_skill_name_to_id(&approved_pack)?;
        let skill_id_to_name = invert_mapping(&skill_name_to_id);
        let loaded_skill_ids = approved_pack.materialize_into_memgine(&mut engine);
        let overlay = RuntimeSkillOverlay::new(loaded_skill_ids);

        Ok(Self {
            engine,
            approved_pack,
            overlay,
            skill_name_to_id,
            skill_id_to_name,
        })
    }

    pub fn approved_pack(&self) -> &ApprovedSkillPack {
        &self.approved_pack
    }

    pub fn overlay(&self) -> &RuntimeSkillOverlay {
        &self.overlay
    }

    pub fn overlay_mut(&mut self) -> &mut RuntimeSkillOverlay {
        &mut self.overlay
    }

    pub fn engine(&self) -> &MemgineEngine {
        &self.engine
    }

    pub fn engine_mut(&mut self) -> &mut MemgineEngine {
        &mut self.engine
    }

    pub fn skill_name_for_id(&self, skill_id: &str) -> Option<&str> {
        self.skill_id_to_name.get(skill_id).map(String::as_str)
    }

    pub fn skill_id_for_name(&self, skill_name: &str) -> Option<&str> {
        self.skill_name_to_id.get(skill_name).map(String::as_str)
    }

    pub fn find_skills(
        &self,
        persona: &str,
        url: &str,
        task: &str,
        max_results: usize,
    ) -> Vec<ApprovedSkillMatch> {
        self.engine
            .find_skill(persona, url, task, max_results)
            .into_iter()
            .filter_map(|(meta, score)| {
                let skill_id = self.skill_name_to_id.get(&meta.name)?.clone();
                Some(ApprovedSkillMatch {
                    skill_id,
                    skill_name: meta.name.clone(),
                    score,
                    meta,
                })
            })
            .collect()
    }

    pub fn record_outcome(
        &mut self,
        skill_id: &str,
        outcome: SkillOutcome,
    ) -> Result<RuntimeSkillStats, ApprovedSkillRuntimeError> {
        let skill_name = self
            .skill_id_to_name
            .get(skill_id)
            .cloned()
            .ok_or_else(|| ApprovedSkillRuntimeError::UnknownSkillId {
                skill_id: skill_id.to_string(),
            })?;

        self.engine.report_outcome(&skill_name, outcome.clone());
        let stats = self.overlay.record_outcome(skill_id, outcome).clone();
        Ok(stats)
    }

    pub fn record_match_outcome(
        &mut self,
        selected: &ApprovedSkillMatch,
        outcome: SkillOutcome,
    ) -> Result<RuntimeSkillStats, ApprovedSkillRuntimeError> {
        self.record_outcome(&selected.skill_id, outcome)
    }
}

fn build_skill_name_to_id(
    pack: &ApprovedSkillPack,
) -> Result<HashMap<String, String>, ApprovedSkillRuntimeError> {
    let mut mapping = HashMap::new();

    for skill in &pack.general_skills {
        insert_skill_mapping(&mut mapping, &skill.title, &skill.id)?;
    }

    for skills in pack.task_specific_skills.values() {
        for skill in skills {
            insert_skill_mapping(&mut mapping, &skill.title, &skill.id)?;
        }
    }

    Ok(mapping)
}

fn insert_skill_mapping(
    mapping: &mut HashMap<String, String>,
    title: &str,
    id: &str,
) -> Result<(), ApprovedSkillRuntimeError> {
    if mapping.insert(title.to_string(), id.to_string()).is_some() {
        return Err(ApprovedSkillRuntimeError::DuplicateSkillTitle {
            title: title.to_string(),
        });
    }
    Ok(())
}

fn invert_mapping(mapping: &HashMap<String, String>) -> HashMap<String, String> {
    mapping
        .iter()
        .map(|(title, id)| (id.clone(), title.clone()))
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::graph::{SkillScope, SkillTrigger};
    use crate::guidance::ApprovedSkill;

    fn sample_skill(id: &str, title: &str, keyword: &str) -> ApprovedSkill {
        ApprovedSkill {
            id: id.to_string(),
            title: title.to_string(),
            description: format!("Description for {title}"),
            when_to_apply: format!("Use when task involves {keyword}"),
            when_not_to_apply: "Do not use in unrelated flows".to_string(),
            scope: SkillScope::Global,
            trigger: SkillTrigger {
                persona: String::new(),
                url_pattern: String::new(),
                task_keywords: vec![keyword.to_string()],
                structured: None,
            },
            code: format!("// code for {title}"),
            source_candidate_ids: vec!["candidate-1".to_string()],
        }
    }

    #[test]
    fn runtime_loads_pack_and_finds_skills() {
        let mut pack = ApprovedSkillPack::empty(1);
        pack.general_skills
            .push(sample_skill("skill-1", "triage_issue", "triage"));

        let runtime = ApprovedSkillRuntime::new(pack, None).expect("runtime should load");
        let matches = runtime.find_skills("", "", "triage this bug", 3);

        assert_eq!(matches.len(), 1);
        assert_eq!(matches[0].skill_id, "skill-1");
        assert_eq!(matches[0].skill_name, "triage_issue");
        assert_eq!(
            runtime.overlay().loaded_skill_ids,
            vec!["skill-1".to_string()]
        );
    }

    #[test]
    fn runtime_records_outcomes_in_overlay_and_engine() {
        let mut pack = ApprovedSkillPack::empty(1);
        pack.general_skills
            .push(sample_skill("skill-1", "triage_issue", "triage"));

        let mut runtime = ApprovedSkillRuntime::new(pack, None).expect("runtime should load");
        let selected = runtime.find_skills("", "", "triage this bug", 1).remove(0);

        let stats = runtime
            .record_match_outcome(&selected, SkillOutcome::Fail)
            .expect("skill should be tracked");

        assert_eq!(stats.fail_count, 1);
        assert_eq!(stats.success_count, 0);

        let engine_stats = runtime
            .engine()
            .find_skill("", "", "triage this bug", 1)
            .into_iter()
            .next()
            .expect("skill should still exist")
            .0
            .stats;
        assert_eq!(engine_stats.fail_count, 1);

        let pack_skill = &runtime.approved_pack().general_skills[0];
        assert_eq!(pack_skill.id, "skill-1");
        assert_eq!(pack_skill.title, "triage_issue");
    }

    #[test]
    fn runtime_rejects_duplicate_titles() {
        let mut pack = ApprovedSkillPack::empty(1);
        pack.general_skills
            .push(sample_skill("skill-1", "triage_issue", "triage"));
        pack.general_skills
            .push(sample_skill("skill-2", "triage_issue", "debug"));

        let err = match ApprovedSkillRuntime::new(pack, None) {
            Ok(_) => panic!("duplicate titles should fail"),
            Err(err) => err,
        };
        assert_eq!(
            err,
            ApprovedSkillRuntimeError::DuplicateSkillTitle {
                title: "triage_issue".to_string()
            }
        );
    }
}