ainl-mission 0.1.0

Host-neutral mission engine: state machine, DAG, scheduler, stall, task ledger (zero armaraos-* deps)
Documentation
//! Task ledger: Facts / Guesses / Plan as tagged [`ainl_memory`] semantic nodes.

use ainl_memory::{AinlMemoryNode, AinlNodeType, GraphMemory, MemoryCategory};
use thiserror::Error;
use uuid::Uuid;

/// Ledger section tag suffix (`ledger:facts`, `ledger:guesses`, `ledger:plan`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LedgerKind {
    Facts,
    Guesses,
    Plan,
}

impl LedgerKind {
    pub fn tag_suffix(self) -> &'static str {
        match self {
            Self::Facts => "ledger:facts",
            Self::Guesses => "ledger:guesses",
            Self::Plan => "ledger:plan",
        }
    }
}

fn mission_tag(mission_id: &str) -> String {
    format!("mission:{mission_id}")
}

fn ledger_tags(mission_id: &str, kind: LedgerKind) -> Vec<String> {
    vec![mission_tag(mission_id), kind.tag_suffix().to_string()]
}

/// One ledger entry read back from the graph.
#[derive(Debug, Clone, PartialEq)]
pub struct LedgerEntry {
    pub node_id: Uuid,
    pub kind: LedgerKind,
    pub text: String,
    pub confidence: f32,
}

/// Task ledger error.
#[derive(Debug, Error)]
pub enum TaskLedgerError {
    #[error("memory: {0}")]
    Memory(String),
}

/// Write a fact to the mission ledger.
pub fn write_fact(
    memory: &GraphMemory,
    agent_id: &str,
    mission_id: &str,
    fact: &str,
    confidence: f32,
    source_turn_id: Uuid,
) -> Result<Uuid, TaskLedgerError> {
    write_ledger(
        memory,
        agent_id,
        mission_id,
        LedgerKind::Facts,
        fact,
        confidence,
        source_turn_id,
    )
}

/// Write a guess (lower-trust hypothesis) to the mission ledger.
pub fn write_guess(
    memory: &GraphMemory,
    agent_id: &str,
    mission_id: &str,
    guess: &str,
    confidence: f32,
    source_turn_id: Uuid,
) -> Result<Uuid, TaskLedgerError> {
    write_ledger(
        memory,
        agent_id,
        mission_id,
        LedgerKind::Guesses,
        guess,
        confidence,
        source_turn_id,
    )
}

/// Write or replace the current plan block (stored as a single high-confidence semantic row).
pub fn write_plan(
    memory: &GraphMemory,
    agent_id: &str,
    mission_id: &str,
    plan_md: &str,
    source_turn_id: Uuid,
) -> Result<Uuid, TaskLedgerError> {
    write_ledger(
        memory,
        agent_id,
        mission_id,
        LedgerKind::Plan,
        plan_md,
        1.0,
        source_turn_id,
    )
}

fn write_ledger(
    memory: &GraphMemory,
    agent_id: &str,
    mission_id: &str,
    kind: LedgerKind,
    text: &str,
    confidence: f32,
    source_turn_id: Uuid,
) -> Result<Uuid, TaskLedgerError> {
    let tags = ledger_tags(mission_id, kind);
    let mut node = AinlMemoryNode::new_fact(text.to_string(), confidence.clamp(0.0, 1.0), source_turn_id);
    node.agent_id = agent_id.to_string();
    node.memory_category = MemoryCategory::Semantic;
    if let AinlNodeType::Semantic { ref mut semantic } = node.node_type {
        semantic.tags = tags;
        semantic.topic_cluster = Some(format!("mission-ledger-{}", kind.tag_suffix()));
    }
    let id = node.id;
    memory
        .write_node(&node)
        .map_err(TaskLedgerError::Memory)?;
    Ok(id)
}

/// Read ledger entries for a mission (newest semantic rows first per kind filter).
pub fn read_ledger(
    memory: &GraphMemory,
    agent_id: &str,
    mission_id: &str,
    kind: Option<LedgerKind>,
) -> Result<Vec<LedgerEntry>, TaskLedgerError> {
    let mission_prefix = mission_tag(mission_id);
    let nodes = memory
        .recall_by_type(ainl_memory::AinlNodeKind::Semantic, 60 * 60 * 24 * 365 * 5)
        .map_err(TaskLedgerError::Memory)?;

    let mut out = Vec::new();
    for n in nodes {
        if n.agent_id != agent_id {
            continue;
        }
        let Some(semantic) = n.semantic() else {
            continue;
        };
        if !semantic.tags.iter().any(|t| t == &mission_prefix) {
            continue;
        }
        let entry_kind = semantic
            .tags
            .iter()
            .find_map(|t| match t.as_str() {
                "ledger:facts" => Some(LedgerKind::Facts),
                "ledger:guesses" => Some(LedgerKind::Guesses),
                "ledger:plan" => Some(LedgerKind::Plan),
                _ => None,
            });
        let Some(entry_kind) = entry_kind else {
            continue;
        };
        if let Some(filter) = kind {
            if filter != entry_kind {
                continue;
            }
        }
        out.push(LedgerEntry {
            node_id: n.id,
            kind: entry_kind,
            text: semantic.fact.clone(),
            confidence: semantic.confidence,
        });
    }
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn write_and_read_fact_plan() {
        let dir = tempfile::tempdir().unwrap();
        let memory = GraphMemory::new(&dir.path().join("ledger.db")).unwrap();
        let turn = Uuid::new_v4();
        write_fact(
            &memory,
            "agent-1",
            "mission-a",
            "API returns 401 without token",
            0.9,
            turn,
        )
        .unwrap();
        write_plan(
            &memory,
            "agent-1",
            "mission-a",
            "1. Auth middleware\n2. Tests",
            turn,
        )
        .unwrap();

        let facts = read_ledger(&memory, "agent-1", "mission-a", Some(LedgerKind::Facts)).unwrap();
        assert_eq!(facts.len(), 1);
        assert!(facts[0].text.contains("401"));

        let plan = read_ledger(&memory, "agent-1", "mission-a", Some(LedgerKind::Plan)).unwrap();
        assert_eq!(plan.len(), 1);
        assert!(plan[0].text.contains("Auth middleware"));
    }
}