Skip to main content

ainl_mission/
task_ledger.rs

1//! Task ledger: Facts / Guesses / Plan as tagged [`ainl_memory`] semantic nodes.
2
3use ainl_memory::{AinlMemoryNode, AinlNodeType, GraphMemory, MemoryCategory};
4use thiserror::Error;
5use uuid::Uuid;
6
7/// Ledger section tag suffix (`ledger:facts`, `ledger:guesses`, `ledger:plan`).
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum LedgerKind {
10    Facts,
11    Guesses,
12    Plan,
13}
14
15impl LedgerKind {
16    pub fn tag_suffix(self) -> &'static str {
17        match self {
18            Self::Facts => "ledger:facts",
19            Self::Guesses => "ledger:guesses",
20            Self::Plan => "ledger:plan",
21        }
22    }
23}
24
25fn mission_tag(mission_id: &str) -> String {
26    format!("mission:{mission_id}")
27}
28
29fn ledger_tags(mission_id: &str, kind: LedgerKind) -> Vec<String> {
30    vec![mission_tag(mission_id), kind.tag_suffix().to_string()]
31}
32
33/// One ledger entry read back from the graph.
34#[derive(Debug, Clone, PartialEq)]
35pub struct LedgerEntry {
36    pub node_id: Uuid,
37    pub kind: LedgerKind,
38    pub text: String,
39    pub confidence: f32,
40}
41
42/// Task ledger error.
43#[derive(Debug, Error)]
44pub enum TaskLedgerError {
45    #[error("memory: {0}")]
46    Memory(String),
47}
48
49/// Write a fact to the mission ledger.
50pub fn write_fact(
51    memory: &GraphMemory,
52    agent_id: &str,
53    mission_id: &str,
54    fact: &str,
55    confidence: f32,
56    source_turn_id: Uuid,
57) -> Result<Uuid, TaskLedgerError> {
58    write_ledger(
59        memory,
60        agent_id,
61        mission_id,
62        LedgerKind::Facts,
63        fact,
64        confidence,
65        source_turn_id,
66    )
67}
68
69/// Write a guess (lower-trust hypothesis) to the mission ledger.
70pub fn write_guess(
71    memory: &GraphMemory,
72    agent_id: &str,
73    mission_id: &str,
74    guess: &str,
75    confidence: f32,
76    source_turn_id: Uuid,
77) -> Result<Uuid, TaskLedgerError> {
78    write_ledger(
79        memory,
80        agent_id,
81        mission_id,
82        LedgerKind::Guesses,
83        guess,
84        confidence,
85        source_turn_id,
86    )
87}
88
89/// Write or replace the current plan block (stored as a single high-confidence semantic row).
90pub fn write_plan(
91    memory: &GraphMemory,
92    agent_id: &str,
93    mission_id: &str,
94    plan_md: &str,
95    source_turn_id: Uuid,
96) -> Result<Uuid, TaskLedgerError> {
97    write_ledger(
98        memory,
99        agent_id,
100        mission_id,
101        LedgerKind::Plan,
102        plan_md,
103        1.0,
104        source_turn_id,
105    )
106}
107
108fn write_ledger(
109    memory: &GraphMemory,
110    agent_id: &str,
111    mission_id: &str,
112    kind: LedgerKind,
113    text: &str,
114    confidence: f32,
115    source_turn_id: Uuid,
116) -> Result<Uuid, TaskLedgerError> {
117    let tags = ledger_tags(mission_id, kind);
118    let mut node = AinlMemoryNode::new_fact(text.to_string(), confidence.clamp(0.0, 1.0), source_turn_id);
119    node.agent_id = agent_id.to_string();
120    node.memory_category = MemoryCategory::Semantic;
121    if let AinlNodeType::Semantic { ref mut semantic } = node.node_type {
122        semantic.tags = tags;
123        semantic.topic_cluster = Some(format!("mission-ledger-{}", kind.tag_suffix()));
124    }
125    let id = node.id;
126    memory
127        .write_node(&node)
128        .map_err(TaskLedgerError::Memory)?;
129    Ok(id)
130}
131
132/// Read ledger entries for a mission (newest semantic rows first per kind filter).
133pub fn read_ledger(
134    memory: &GraphMemory,
135    agent_id: &str,
136    mission_id: &str,
137    kind: Option<LedgerKind>,
138) -> Result<Vec<LedgerEntry>, TaskLedgerError> {
139    let mission_prefix = mission_tag(mission_id);
140    let nodes = memory
141        .recall_by_type(ainl_memory::AinlNodeKind::Semantic, 60 * 60 * 24 * 365 * 5)
142        .map_err(TaskLedgerError::Memory)?;
143
144    let mut out = Vec::new();
145    for n in nodes {
146        if n.agent_id != agent_id {
147            continue;
148        }
149        let Some(semantic) = n.semantic() else {
150            continue;
151        };
152        if !semantic.tags.iter().any(|t| t == &mission_prefix) {
153            continue;
154        }
155        let entry_kind = semantic
156            .tags
157            .iter()
158            .find_map(|t| match t.as_str() {
159                "ledger:facts" => Some(LedgerKind::Facts),
160                "ledger:guesses" => Some(LedgerKind::Guesses),
161                "ledger:plan" => Some(LedgerKind::Plan),
162                _ => None,
163            });
164        let Some(entry_kind) = entry_kind else {
165            continue;
166        };
167        if let Some(filter) = kind {
168            if filter != entry_kind {
169                continue;
170            }
171        }
172        out.push(LedgerEntry {
173            node_id: n.id,
174            kind: entry_kind,
175            text: semantic.fact.clone(),
176            confidence: semantic.confidence,
177        });
178    }
179    Ok(out)
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn write_and_read_fact_plan() {
188        let dir = tempfile::tempdir().unwrap();
189        let memory = GraphMemory::new(&dir.path().join("ledger.db")).unwrap();
190        let turn = Uuid::new_v4();
191        write_fact(
192            &memory,
193            "agent-1",
194            "mission-a",
195            "API returns 401 without token",
196            0.9,
197            turn,
198        )
199        .unwrap();
200        write_plan(
201            &memory,
202            "agent-1",
203            "mission-a",
204            "1. Auth middleware\n2. Tests",
205            turn,
206        )
207        .unwrap();
208
209        let facts = read_ledger(&memory, "agent-1", "mission-a", Some(LedgerKind::Facts)).unwrap();
210        assert_eq!(facts.len(), 1);
211        assert!(facts[0].text.contains("401"));
212
213        let plan = read_ledger(&memory, "agent-1", "mission-a", Some(LedgerKind::Plan)).unwrap();
214        assert_eq!(plan.len(), 1);
215        assert!(plan[0].text.contains("Auth middleware"));
216    }
217}