Skip to main content

ainl_persona/
engine.rs

1//! `EvolutionEngine` — persona axes with explicit extract / ingest / snapshot / write phases.
2//!
3//! Callers can compose these steps for dry runs (skip [`Self::write_persona_node`]),
4//! correction ticks (`correction_tick` → [`Self::snapshot`] → [`Self::write_persona_node`]), or
5//! a full pass via [`Self::evolve`].
6
7use crate::axes::{default_axis_map, AxisState, PersonaAxis};
8use crate::extractor::GraphExtractor;
9use crate::fitness::PersonaSnapshot;
10use crate::persona_node;
11use crate::signals::RawSignal;
12use ainl_memory::SqliteGraphStore;
13use chrono::Utc;
14use std::collections::HashMap;
15
16/// Minimum absolute score delta on an axis for a signal to count as "applied" in [`EvolutionEngine::ingest_signals`].
17pub const INGEST_SCORE_EPSILON: f32 = 0.001;
18
19pub struct EvolutionEngine {
20    pub agent_id: String,
21    pub axes: HashMap<PersonaAxis, AxisState>,
22}
23
24impl EvolutionEngine {
25    pub fn new(agent_id: impl Into<String>) -> Self {
26        let agent_id = agent_id.into();
27        Self {
28            axes: default_axis_map(0.5),
29            agent_id,
30        }
31    }
32
33    /// Read signals from the graph store for this agent (no writes).
34    pub fn extract_signals(&self, store: &SqliteGraphStore) -> Result<Vec<RawSignal>, String> {
35        GraphExtractor::extract(store, &self.agent_id)
36    }
37
38    /// Applies each signal to its axis EMA. Returns how many signals produced a score change
39    /// larger than [`INGEST_SCORE_EPSILON`] on an existing axis (skipped axes do not count).
40    pub fn ingest_signals(&mut self, signals: Vec<RawSignal>) -> usize {
41        let mut applied = 0usize;
42        for sig in signals {
43            if let Some(state) = self.axes.get_mut(&sig.axis) {
44                let prior = state.score;
45                state.update_weighted(sig.reward, sig.weight);
46                if (state.score - prior).abs() > INGEST_SCORE_EPSILON {
47                    applied += 1;
48                }
49            }
50        }
51        applied
52    }
53
54    /// Current axes as a snapshot (no writes).
55    pub fn snapshot(&self) -> PersonaSnapshot {
56        PersonaSnapshot {
57            agent_id: self.agent_id.clone(),
58            axes: self.axes.clone(),
59            captured_at: Utc::now(),
60        }
61    }
62
63    /// Persist `snapshot` to the evolution [`PersonaNode`](ainl_memory::PersonaNode) row.
64    ///
65    /// `snapshot.agent_id` must match this engine's agent id.
66    pub fn write_persona_node(
67        &self,
68        store: &SqliteGraphStore,
69        snapshot: &PersonaSnapshot,
70    ) -> Result<(), String> {
71        if snapshot.agent_id != self.agent_id {
72            return Err(format!(
73                "PersonaSnapshot agent_id {:?} does not match engine agent_id {:?}",
74                snapshot.agent_id, self.agent_id
75            ));
76        }
77        persona_node::write_evolved_persona_snapshot(store, &self.agent_id, &snapshot.axes)
78    }
79
80    /// Full evolution pass: extract → ingest → snapshot → write.
81    pub fn evolve(&mut self, store: &SqliteGraphStore) -> Result<PersonaSnapshot, String> {
82        let signals = self.extract_signals(store)?;
83        self.ingest_signals(signals);
84        let snapshot = self.snapshot();
85        self.write_persona_node(store, &snapshot)?;
86        Ok(snapshot)
87    }
88
89    pub fn correction_tick(&mut self, axis: PersonaAxis, correction: f32) {
90        if let Some(state) = self.axes.get_mut(&axis) {
91            state.update_weighted(correction.clamp(0.0, 1.0), 1.0);
92        }
93    }
94}