mempal 0.6.1

Project memory for coding agents. Single binary, hybrid search, knowledge graph.
Documentation
use anyhow::{Context, Result, bail};
use serde::Serialize;

use crate::core::{
    db::Database,
    types::{Drawer, KnowledgeStatus, KnowledgeTier, MemoryKind},
};

#[derive(Debug, Clone, Serialize)]
pub struct GateReport {
    pub drawer_id: String,
    pub tier: String,
    pub status: String,
    pub target_status: String,
    pub allowed: bool,
    pub reasons: Vec<String>,
    pub requirements: GateRequirements,
    pub evidence_counts: GateEvidenceCounts,
}

#[derive(Debug, Clone, Serialize)]
pub struct GateRequirements {
    pub min_supporting_refs: usize,
    pub min_verification_refs: usize,
    pub min_teaching_refs: usize,
    pub reviewer_required: bool,
    pub counterexamples_block: bool,
}

#[derive(Debug, Clone, Serialize)]
pub struct GateEvidenceCounts {
    pub supporting: usize,
    pub counterexample: usize,
    pub teaching: usize,
    pub verification: usize,
}

#[derive(Debug, Clone, Serialize)]
pub struct PromotionPolicyEntry {
    pub tier: String,
    pub target_status: String,
    pub requirements: GateRequirements,
}

pub fn promotion_policy() -> Vec<PromotionPolicyEntry> {
    [
        (KnowledgeTier::DaoTian, KnowledgeStatus::Canonical),
        (KnowledgeTier::DaoRen, KnowledgeStatus::Promoted),
        (KnowledgeTier::Shu, KnowledgeStatus::Promoted),
        (KnowledgeTier::Qi, KnowledgeStatus::Promoted),
    ]
    .into_iter()
    .map(|(tier, target_status)| PromotionPolicyEntry {
        tier: tier_slug(&tier).to_string(),
        target_status: status_slug(&target_status).to_string(),
        requirements: gate_requirements_for_policy(&tier, &target_status),
    })
    .collect()
}

pub fn evaluate_gate_by_id(
    db: &Database,
    drawer_id: &str,
    target_status: Option<&str>,
    reviewer: Option<&str>,
    allow_counterexamples: bool,
) -> Result<GateReport> {
    let drawer = load_gate_knowledge_drawer(db, drawer_id)?;
    let tier = drawer.tier.as_ref().expect("knowledge drawer has tier");
    let target_status = match target_status {
        Some(value) => parse_status(value)?,
        None => default_target_status(tier),
    };
    validate_tier_status(tier, &target_status)?;
    evaluate_gate(db, &drawer, &target_status, reviewer, allow_counterexamples)
}

pub fn evaluate_gate_for_drawer(
    db: &Database,
    drawer: &Drawer,
    target_status: &KnowledgeStatus,
    reviewer: Option<&str>,
    allow_counterexamples: bool,
) -> Result<GateReport> {
    if drawer.memory_kind != MemoryKind::Knowledge {
        bail!("knowledge gate requires a knowledge drawer");
    }
    let tier = drawer
        .tier
        .as_ref()
        .context("knowledge gate requires tier metadata")?;
    validate_tier_status(tier, target_status)?;
    evaluate_gate(db, drawer, target_status, reviewer, allow_counterexamples)
}

fn load_gate_knowledge_drawer(db: &Database, drawer_id: &str) -> Result<Drawer> {
    let drawer = db
        .get_drawer(drawer_id)
        .context("failed to look up drawer")?
        .with_context(|| format!("drawer not found: {drawer_id}"))?;
    if drawer.memory_kind != MemoryKind::Knowledge {
        bail!("knowledge gate requires a knowledge drawer");
    }
    if drawer.tier.is_none() || drawer.status.is_none() {
        bail!("knowledge gate requires tier and status metadata");
    }
    Ok(drawer)
}

fn parse_status(value: &str) -> Result<KnowledgeStatus> {
    match value.trim() {
        "candidate" => Ok(KnowledgeStatus::Candidate),
        "promoted" => Ok(KnowledgeStatus::Promoted),
        "canonical" => Ok(KnowledgeStatus::Canonical),
        "demoted" => Ok(KnowledgeStatus::Demoted),
        "retired" => Ok(KnowledgeStatus::Retired),
        other => bail!("unsupported knowledge status: {other}"),
    }
}

fn default_target_status(tier: &KnowledgeTier) -> KnowledgeStatus {
    match tier {
        KnowledgeTier::DaoTian => KnowledgeStatus::Canonical,
        KnowledgeTier::DaoRen | KnowledgeTier::Shu | KnowledgeTier::Qi => KnowledgeStatus::Promoted,
    }
}

fn validate_tier_status(tier: &KnowledgeTier, status: &KnowledgeStatus) -> Result<()> {
    let allowed = match tier {
        KnowledgeTier::DaoTian => &[KnowledgeStatus::Canonical, KnowledgeStatus::Demoted][..],
        KnowledgeTier::DaoRen => &[
            KnowledgeStatus::Candidate,
            KnowledgeStatus::Promoted,
            KnowledgeStatus::Demoted,
            KnowledgeStatus::Retired,
        ][..],
        KnowledgeTier::Shu => &[
            KnowledgeStatus::Promoted,
            KnowledgeStatus::Demoted,
            KnowledgeStatus::Retired,
        ][..],
        KnowledgeTier::Qi => &[
            KnowledgeStatus::Candidate,
            KnowledgeStatus::Promoted,
            KnowledgeStatus::Demoted,
            KnowledgeStatus::Retired,
        ][..],
    };

    if allowed.contains(status) {
        return Ok(());
    }

    match tier {
        KnowledgeTier::DaoTian => bail!("dao_tian only allows canonical or demoted"),
        KnowledgeTier::DaoRen => {
            bail!("dao_ren only allows candidate, promoted, demoted, or retired")
        }
        KnowledgeTier::Shu => bail!("shu only allows promoted, demoted, or retired"),
        KnowledgeTier::Qi => bail!("qi only allows candidate, promoted, demoted, or retired"),
    }
}

fn gate_requirements(tier: &KnowledgeTier, target_status: &KnowledgeStatus) -> GateRequirements {
    promotion_policy()
        .into_iter()
        .find(|entry| {
            entry.tier == tier_slug(tier) && entry.target_status == status_slug(target_status)
        })
        .map(|entry| entry.requirements)
        .unwrap_or_else(|| GateRequirements {
            min_supporting_refs: 0,
            min_verification_refs: 0,
            min_teaching_refs: 0,
            reviewer_required: false,
            counterexamples_block: true,
        })
}

fn gate_requirements_for_policy(
    tier: &KnowledgeTier,
    target_status: &KnowledgeStatus,
) -> GateRequirements {
    match (tier, target_status) {
        (KnowledgeTier::DaoTian, KnowledgeStatus::Canonical) => GateRequirements {
            min_supporting_refs: 3,
            min_verification_refs: 2,
            min_teaching_refs: 1,
            reviewer_required: true,
            counterexamples_block: true,
        },
        (KnowledgeTier::DaoRen, KnowledgeStatus::Promoted) => GateRequirements {
            min_supporting_refs: 2,
            min_verification_refs: 1,
            min_teaching_refs: 0,
            reviewer_required: false,
            counterexamples_block: true,
        },
        (KnowledgeTier::Shu | KnowledgeTier::Qi, KnowledgeStatus::Promoted) => GateRequirements {
            min_supporting_refs: 1,
            min_verification_refs: 1,
            min_teaching_refs: 0,
            reviewer_required: false,
            counterexamples_block: true,
        },
        _ => unreachable!("promotion_policy only requests defined gate policy entries"),
    }
}

fn evaluate_gate(
    db: &Database,
    drawer: &Drawer,
    target_status: &KnowledgeStatus,
    reviewer: Option<&str>,
    allow_counterexamples: bool,
) -> Result<GateReport> {
    validate_gate_refs(db, &drawer.supporting_refs)?;
    validate_gate_refs(db, &drawer.counterexample_refs)?;
    validate_gate_refs(db, &drawer.teaching_refs)?;
    validate_gate_refs(db, &drawer.verification_refs)?;

    let tier = drawer.tier.as_ref().expect("knowledge drawer has tier");
    let status = drawer.status.as_ref().expect("knowledge drawer has status");
    let requirements = gate_requirements(tier, target_status);
    let evidence_counts = GateEvidenceCounts {
        supporting: drawer.supporting_refs.len(),
        counterexample: drawer.counterexample_refs.len(),
        teaching: drawer.teaching_refs.len(),
        verification: drawer.verification_refs.len(),
    };
    let mut reasons = Vec::new();
    if evidence_counts.supporting < requirements.min_supporting_refs {
        reasons.push(format!(
            "supporting evidence refs below requirement: have {}, need {}",
            evidence_counts.supporting, requirements.min_supporting_refs
        ));
    }
    if evidence_counts.verification < requirements.min_verification_refs {
        reasons.push(format!(
            "verification evidence refs below requirement: have {}, need {}",
            evidence_counts.verification, requirements.min_verification_refs
        ));
    }
    if evidence_counts.teaching < requirements.min_teaching_refs {
        reasons.push(format!(
            "teaching evidence refs below requirement: have {}, need {}",
            evidence_counts.teaching, requirements.min_teaching_refs
        ));
    }
    if requirements.reviewer_required
        && reviewer
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .is_none()
    {
        reasons.push("reviewer is required for this gate".to_string());
    }
    if requirements.counterexamples_block
        && evidence_counts.counterexample > 0
        && !allow_counterexamples
    {
        reasons.push(format!(
            "counterexample refs present: {}",
            evidence_counts.counterexample
        ));
    }

    Ok(GateReport {
        drawer_id: drawer.id.clone(),
        tier: tier_slug(tier).to_string(),
        status: status_slug(status).to_string(),
        target_status: status_slug(target_status).to_string(),
        allowed: reasons.is_empty(),
        reasons,
        requirements,
        evidence_counts,
    })
}

fn validate_gate_refs(db: &Database, refs: &[String]) -> Result<()> {
    for drawer_id in refs {
        if !drawer_id.starts_with("drawer_") {
            bail!("gate refs must contain drawer ids");
        }
        let drawer = db
            .get_drawer(drawer_id)
            .with_context(|| format!("failed to load ref drawer {drawer_id}"))?
            .with_context(|| format!("ref drawer not found: {drawer_id}"))?;
        if drawer.memory_kind != MemoryKind::Evidence {
            bail!("gate refs must point to evidence drawers");
        }
    }
    Ok(())
}

fn tier_slug(value: &KnowledgeTier) -> &'static str {
    match value {
        KnowledgeTier::Qi => "qi",
        KnowledgeTier::Shu => "shu",
        KnowledgeTier::DaoRen => "dao_ren",
        KnowledgeTier::DaoTian => "dao_tian",
    }
}

fn status_slug(value: &KnowledgeStatus) -> &'static str {
    match value {
        KnowledgeStatus::Candidate => "candidate",
        KnowledgeStatus::Promoted => "promoted",
        KnowledgeStatus::Canonical => "canonical",
        KnowledgeStatus::Demoted => "demoted",
        KnowledgeStatus::Retired => "retired",
    }
}