Skip to main content

ainl_graph_extractor/
extractor.rs

1//! One-shot extraction pass wired to [`ainl_persona::EvolutionEngine`].
2//!
3//! Instrumentality from episode tools is emitted by [`ainl_persona::GraphExtractor`] when the
4//! episode is processable; `extract_pass` skips its redundant `tool_affinity` leg in that case
5//! (see [`crate::persona_signals::extract_pass`]).
6
7use crate::persona_signals::{extract_pass, PersonaSignalExtractorState};
8use crate::recurrence::update_semantic_recurrence;
9use ainl_memory::{GraphStore, SqliteGraphStore};
10use ainl_persona::{EvolutionEngine, PersonaSnapshot, RawSignal};
11use chrono::{DateTime, Utc};
12
13fn store_has_any_persona_for_agent(store: &SqliteGraphStore, agent_id: &str) -> Result<bool, String> {
14    for n in store.find_by_type("persona")? {
15        if n.agent_id == agent_id {
16            return Ok(true);
17        }
18    }
19    Ok(false)
20}
21
22pub struct GraphExtractorTask {
23    pub agent_id: String,
24    pub evolution_engine: EvolutionEngine,
25    pub signal_state: PersonaSignalExtractorState,
26}
27
28#[derive(Debug, Clone)]
29pub struct ExtractionReport {
30    pub agent_id: String,
31    pub semantic_nodes_updated: usize,
32    /// Raw signals returned from the store this pass (diagnostics / "what the agent saw").
33    pub signals_extracted: usize,
34    /// Signals that moved an axis EMA by more than the persona ingest epsilon (sparkline input).
35    pub signals_applied: usize,
36    /// Merged graph + pattern signals ingested this pass (diagnostics, tests).
37    pub merged_signals: Vec<RawSignal>,
38    pub persona_snapshot: PersonaSnapshot,
39    pub timestamp: DateTime<Utc>,
40}
41
42impl GraphExtractorTask {
43    pub fn new(agent_id: &str) -> Self {
44        Self {
45            agent_id: agent_id.to_string(),
46            evolution_engine: EvolutionEngine::new(agent_id),
47            signal_state: PersonaSignalExtractorState::new(),
48        }
49    }
50
51    /// Semantic recurrence updates, then extract → ingest → snapshot → explicit persona write.
52    pub fn run_pass(&mut self, store: &SqliteGraphStore) -> Result<ExtractionReport, String> {
53        let semantic_nodes_updated = update_semantic_recurrence(store, &self.agent_id)?;
54        let mut signals = self.evolution_engine.extract_signals(store)?;
55        signals.extend(extract_pass(store, &self.agent_id, &mut self.signal_state)?);
56        let signals_extracted = signals.len();
57        let merged_signals = signals.clone();
58        let signals_applied = self.evolution_engine.ingest_signals(signals);
59        let persona_snapshot = self.evolution_engine.snapshot();
60        // Avoid persisting a default 0.5-axis evolution node on a totally cold graph (no signals,
61        // no recurrence work, no prior persona). Still persist when this pass touched semantics or
62        // the agent already has any persona row so follow-up passes remain idempotent.
63        let should_persist_persona = signals_extracted > 0
64            || semantic_nodes_updated > 0
65            || store_has_any_persona_for_agent(store, &self.agent_id)?;
66        if should_persist_persona {
67            self.evolution_engine
68                .write_persona_node(store, &persona_snapshot)?;
69        }
70        Ok(ExtractionReport {
71            agent_id: self.agent_id.clone(),
72            semantic_nodes_updated,
73            signals_extracted,
74            signals_applied,
75            merged_signals,
76            persona_snapshot,
77            timestamp: Utc::now(),
78        })
79    }
80}