Skip to main content

ainl_memory/
node.rs

1//! AINL graph node types - the vocabulary of agent memory.
2//!
3//! Core memory types: Episode (episodic), Semantic, Procedural, Persona, Trajectory (execution trace),
4//! typed **Failure** nodes for operator recall, plus `RuntimeState` for ainl-runtime session counters.
5//! Designed to be standalone (zero ArmaraOS deps) yet compatible with
6//! OrchestrationTraceEvent serialization.
7
8use ainl_contracts::{TrajectoryOutcome, TrajectoryStep};
9use serde::{Deserialize, Deserializer, Serialize};
10use std::collections::HashMap;
11use std::fmt;
12use uuid::Uuid;
13
14fn deserialize_updated_at<'de, D>(deserializer: D) -> Result<i64, D::Error>
15where
16    D: Deserializer<'de>,
17{
18    use serde::de::{self, Visitor};
19
20    struct TsVisitor;
21    impl<'de> Visitor<'de> for TsVisitor {
22        type Value = i64;
23
24        fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
25            f.write_str("unix timestamp or RFC3339 string")
26        }
27
28        fn visit_i64<E: de::Error>(self, v: i64) -> Result<i64, E> {
29            Ok(v)
30        }
31
32        fn visit_u64<E: de::Error>(self, v: u64) -> Result<i64, E> {
33            i64::try_from(v).map_err(de::Error::custom)
34        }
35
36        fn visit_str<E: de::Error>(self, v: &str) -> Result<i64, E> {
37            if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(v) {
38                return Ok(dt.timestamp());
39            }
40            v.parse::<i64>().map_err(de::Error::custom)
41        }
42
43        fn visit_string<E: de::Error>(self, v: String) -> Result<i64, E> {
44            self.visit_str(&v)
45        }
46    }
47
48    deserializer.deserialize_any(TsVisitor)
49}
50
51/// Coarse node kind for store queries (matches `node_type` column values).
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum AinlNodeKind {
54    Episode,
55    Semantic,
56    Procedural,
57    Persona,
58    /// Agent-scoped persisted session counters / persona cache (see [`RuntimeStateNode`]).
59    RuntimeState,
60    /// Step-level execution trace, typically linked to an [`EpisodicNode`].
61    Trajectory,
62    /// Typed failure (e.g. loop guard) for search / dashboards.
63    Failure,
64}
65
66impl AinlNodeKind {
67    pub fn as_str(&self) -> &'static str {
68        match self {
69            Self::Episode => "episode",
70            Self::Semantic => "semantic",
71            Self::Procedural => "procedural",
72            Self::Persona => "persona",
73            Self::RuntimeState => "runtime_state",
74            Self::Trajectory => "trajectory",
75            Self::Failure => "failure",
76        }
77    }
78}
79
80/// Memory category aligned with the four memory families (episodic ↔ `Episode` nodes).
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum MemoryCategory {
84    Persona,
85    Semantic,
86    Episodic,
87    Procedural,
88    /// Agent-scoped runtime session counters / cache hints (persisted by `ainl-runtime`).
89    RuntimeState,
90    /// Recorded tool/adapter trajectory for learning and replay.
91    Trajectory,
92    /// Operator-visible failure substrate (loop guard, runtime gates, …).
93    Failure,
94}
95
96impl MemoryCategory {
97    pub fn from_node_type(node_type: &AinlNodeType) -> Self {
98        match node_type {
99            AinlNodeType::Episode { .. } => MemoryCategory::Episodic,
100            AinlNodeType::Semantic { .. } => MemoryCategory::Semantic,
101            AinlNodeType::Procedural { .. } => MemoryCategory::Procedural,
102            AinlNodeType::Persona { .. } => MemoryCategory::Persona,
103            AinlNodeType::RuntimeState { .. } => MemoryCategory::RuntimeState,
104            AinlNodeType::Trajectory { .. } => MemoryCategory::Trajectory,
105            AinlNodeType::Failure { .. } => MemoryCategory::Failure,
106        }
107    }
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
111#[serde(rename_all = "snake_case")]
112pub enum PersonaLayer {
113    #[default]
114    Base,
115    Delta,
116    Injection,
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case")]
121pub enum PersonaSource {
122    SystemDefault,
123    #[default]
124    UserConfigured,
125    Evolved,
126    Feedback,
127    Injection,
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(rename_all = "snake_case")]
132pub enum Sentiment {
133    Positive,
134    Neutral,
135    Negative,
136    Mixed,
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
140#[serde(rename_all = "snake_case")]
141pub enum ProcedureType {
142    #[default]
143    ToolSequence,
144    ResponsePattern,
145    WorkflowStep,
146    BehavioralRule,
147}
148
149/// One strength adjustment on a persona trait (evolution / provenance).
150#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
151pub struct StrengthEvent {
152    pub delta: f32,
153    pub reason: String,
154    pub episode_id: String,
155    pub timestamp: u64,
156}
157
158fn default_importance_score() -> f32 {
159    0.5
160}
161
162fn default_semantic_confidence() -> f32 {
163    0.7
164}
165
166fn default_decay_eligible() -> bool {
167    true
168}
169
170fn default_success_rate() -> f32 {
171    0.5
172}
173
174fn default_procedural_prompt_eligible() -> bool {
175    true
176}
177
178fn default_strength_floor() -> f32 {
179    0.0
180}
181
182/// Canonical persona payload (flattened under `AinlNodeType::Persona` in JSON).
183#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
184pub struct PersonaNode {
185    pub trait_name: String,
186    pub strength: f32,
187    #[serde(default)]
188    pub learned_from: Vec<Uuid>,
189    #[serde(default)]
190    pub layer: PersonaLayer,
191    #[serde(default)]
192    pub source: PersonaSource,
193    #[serde(default = "default_strength_floor")]
194    pub strength_floor: f32,
195    #[serde(default)]
196    pub locked: bool,
197    #[serde(default)]
198    pub relevance_score: f32,
199    #[serde(default)]
200    pub provenance_episode_ids: Vec<String>,
201    #[serde(default)]
202    pub evolution_log: Vec<StrengthEvent>,
203    /// Optional axis-evolution bundle (`ainl-persona`); omitted in JSON → empty map.
204    #[serde(default)]
205    pub axis_scores: HashMap<String, f32>,
206    #[serde(default)]
207    pub evolution_cycle: u32,
208    /// ISO-8601 timestamp of last persona evolution pass.
209    #[serde(default)]
210    pub last_evolved: String,
211    /// Redundant copy of owning agent id (mirrors `AinlMemoryNode.agent_id` for payload consumers).
212    #[serde(default)]
213    pub agent_id: String,
214    /// Soft labels: axes above the high-spectrum threshold, not discrete classes.
215    #[serde(default)]
216    pub dominant_axes: Vec<String>,
217}
218
219/// Semantic / factual memory payload.
220#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
221pub struct SemanticNode {
222    pub fact: String,
223    #[serde(default = "default_semantic_confidence")]
224    pub confidence: f32,
225    pub source_turn_id: Uuid,
226    #[serde(default)]
227    pub topic_cluster: Option<String>,
228    #[serde(default)]
229    pub source_episode_id: String,
230    #[serde(default)]
231    pub contradiction_ids: Vec<String>,
232    #[serde(default)]
233    pub last_referenced_at: u64,
234    /// How many times this node has been retrieved from the store.
235    /// Managed by the recall path only — never written by extractors.
236    #[serde(default)]
237    pub reference_count: u32,
238    #[serde(default = "default_decay_eligible")]
239    pub decay_eligible: bool,
240    /// Optional tag hints for analytics / persona (`ainl-persona`); omitted → empty.
241    #[serde(default)]
242    pub tags: Vec<String>,
243    /// How many times this exact fact has recurred across separate extraction events.
244    /// Written by `graph_extractor` when the same fact is observed again.
245    ///
246    /// Do **not** use `reference_count` as a substitute: that field tracks retrieval frequency,
247    /// not extraction recurrence. They measure different things. `graph_extractor` (Prompt 2)
248    /// must write `recurrence_count` directly; persona / domain extractors gate on this field only.
249    #[serde(default)]
250    pub recurrence_count: u32,
251    /// `reference_count` snapshot from the last graph-extractor pass (JSON key `_last_ref_snapshot`).
252    #[serde(rename = "_last_ref_snapshot", default)]
253    pub last_ref_snapshot: u32,
254}
255
256/// Episodic memory payload (one turn / moment).
257#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
258pub struct EpisodicNode {
259    pub turn_id: Uuid,
260    pub timestamp: i64,
261    #[serde(default)]
262    pub tool_calls: Vec<String>,
263    #[serde(default)]
264    pub delegation_to: Option<String>,
265    #[serde(skip_serializing_if = "Option::is_none")]
266    pub trace_event: Option<serde_json::Value>,
267    #[serde(default)]
268    pub turn_index: u32,
269    #[serde(default)]
270    pub user_message_tokens: u32,
271    #[serde(default)]
272    pub assistant_response_tokens: u32,
273    /// Preferred list of tools for analytics; mirrors `tool_calls` when not set explicitly.
274    #[serde(default)]
275    pub tools_invoked: Vec<String>,
276    /// Persona signal names emitted this turn (`Vec`, never `Option`). Omitted JSON → `[]`.
277    /// Serialized even when empty (no `skip_serializing_if`). Backfill: `read_node` → patch → `write_node`.
278    #[serde(default)]
279    pub persona_signals_emitted: Vec<String>,
280    #[serde(default)]
281    pub sentiment: Option<Sentiment>,
282    #[serde(default)]
283    pub flagged: bool,
284    #[serde(default)]
285    pub conversation_id: String,
286    #[serde(default)]
287    pub follows_episode_id: Option<String>,
288    /// Optional raw user message for offline extractors (`ainl-graph-extractor`); omitted unless set.
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub user_message: Option<String>,
291    /// Optional assistant reply text for offline extractors; omitted unless set.
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub assistant_response: Option<String>,
294    /// Deterministic semantic category tags for this episode (e.g. from `ainl-semantic-tagger` / tool sequence).
295    #[serde(default, skip_serializing_if = "Vec::is_empty")]
296    pub tags: Vec<String>,
297    /// Coarse cognitive gate from the LLM completion that produced this episode: "pass" / "warn" / "fail".
298    /// `None` when the provider did not return logprobs (Anthropic, Ollama, etc.).
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub vitals_gate: Option<String>,
301    /// Fine-grained cognitive phase + confidence, e.g. `"reasoning:0.69"`.
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub vitals_phase: Option<String>,
304    /// Scalar trust score in [0, 1]. Higher = more confident / lower entropy.
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub vitals_trust: Option<f32>,
307}
308
309impl EpisodicNode {
310    /// Effective tool list: `tools_invoked` if non-empty, else `tool_calls`.
311    pub fn effective_tools(&self) -> &[String] {
312        if !self.tools_invoked.is_empty() {
313            &self.tools_invoked
314        } else {
315            &self.tool_calls
316        }
317    }
318}
319
320/// Procedural memory payload.
321#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
322pub struct ProceduralNode {
323    pub pattern_name: String,
324    #[serde(default)]
325    pub compiled_graph: Vec<u8>,
326    #[serde(default, skip_serializing_if = "Vec::is_empty")]
327    pub tool_sequence: Vec<String>,
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    pub confidence: Option<f32>,
330    #[serde(default)]
331    pub procedure_type: ProcedureType,
332    #[serde(default)]
333    pub trigger_conditions: Vec<String>,
334    #[serde(default)]
335    pub success_count: u32,
336    #[serde(default)]
337    pub failure_count: u32,
338    #[serde(default = "default_success_rate")]
339    pub success_rate: f32,
340    #[serde(default)]
341    pub last_invoked_at: u64,
342    #[serde(default)]
343    pub reinforcement_episode_ids: Vec<String>,
344    #[serde(default)]
345    pub suppression_episode_ids: Vec<String>,
346    /// Graph-patch / refinement generation (`ainl-persona`); omitted JSON → 0 (skip persona extract until bumped).
347    #[serde(default)]
348    pub patch_version: u32,
349    /// Optional fitness score in \[0,1\]; when absent, consumers may fall back to `success_rate`.
350    #[serde(default)]
351    pub fitness: Option<f32>,
352    /// Declared read dependencies for the procedure (metadata-only hints).
353    #[serde(default)]
354    pub declared_reads: Vec<String>,
355    /// When true, excluded from [`crate::GraphQuery::active_patches`] and skipped by patch dispatch.
356    #[serde(default)]
357    pub retired: bool,
358    /// IR label for graph-patch identity (empty → runtimes may fall back to [`Self::pattern_name`]).
359    #[serde(default)]
360    pub label: String,
361    /// Optional orchestration / turn correlation id (same namespace as episodic `trace_event.trace_id`).
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub trace_id: Option<String>,
364    /// How many times this normalized `tool_sequence` was reinforced (extractor / host merge).
365    #[serde(default)]
366    pub pattern_observation_count: u32,
367    /// When true, the pattern may appear in graph-memory “SuggestedProcedure”-style output.
368    /// Omitted in legacy JSON → `true` (behaves like older rows). New extractor candidates start
369    /// `false` until [`crate::pattern_promotion::should_promote`].
370    #[serde(default = "default_procedural_prompt_eligible")]
371    pub prompt_eligible: bool,
372}
373
374impl ProceduralNode {
375    pub fn recompute_success_rate(&mut self) {
376        let total = self.success_count.saturating_add(self.failure_count);
377        self.success_rate = if total == 0 {
378            0.5
379        } else {
380            self.success_count as f32 / total as f32
381        };
382    }
383}
384
385/// Persisted runtime state for an agent session.
386/// Written at end of each turn; read on `AinlRuntime::new` (ainl-runtime) to restore state.
387#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
388pub struct RuntimeStateNode {
389    pub agent_id: String,
390    #[serde(default)]
391    pub turn_count: u64,
392    #[serde(default, alias = "last_extraction_turn")]
393    pub last_extraction_at_turn: u64,
394    /// Serialized persona contribution (JSON string value) — avoids re-deriving from graph on cold start.
395    #[serde(default, alias = "last_persona_prompt")]
396    pub persona_snapshot_json: Option<String>,
397    #[serde(default, deserialize_with = "deserialize_updated_at")]
398    pub updated_at: i64,
399}
400
401/// Execution trajectory: tool/adapter steps for learning and replay (schema from `ainl_contracts`).
402#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
403pub struct TrajectoryNode {
404    /// Episode this trajectory augments.
405    pub episode_id: Uuid,
406    /// Unix seconds when the trajectory was recorded.
407    pub recorded_at: i64,
408    #[serde(default)]
409    pub session_id: String,
410    #[serde(default)]
411    pub project_id: Option<String>,
412    #[serde(default)]
413    pub ainl_source_hash: Option<String>,
414    pub outcome: TrajectoryOutcome,
415    #[serde(default)]
416    pub steps: Vec<TrajectoryStep>,
417    #[serde(default)]
418    pub duration_ms: u64,
419    /// Optional end-of-episode frame snapshot (JSON) — e.g. vitals + compression summary.
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    pub frame_vars: Option<serde_json::Value>,
422    /// Optional host-learner “fitness” or improvement signal (e.g. vitals trust, Δ reward).
423    #[serde(default, skip_serializing_if = "Option::is_none")]
424    pub fitness_delta: Option<f32>,
425}
426
427/// Typed failure payload (persisted as `node_type = "failure"`).
428#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
429pub struct FailureNode {
430    /// Unix seconds when the failure was recorded.
431    pub recorded_at: i64,
432    /// Origin label, e.g. `loop_guard:block` or `loop_guard:circuit_break`.
433    pub source: String,
434    #[serde(default)]
435    pub tool_name: Option<String>,
436    /// MCP server namespace when the failure came from a namespaced tool (e.g. `ainl` for `mcp_ainl_*`).
437    #[serde(default, skip_serializing_if = "Option::is_none")]
438    pub source_namespace: Option<String>,
439    /// Logical tool identifier for analytics (often the full host tool name, e.g. `mcp_ainl_ainl_run`).
440    #[serde(default, skip_serializing_if = "Option::is_none")]
441    pub source_tool: Option<String>,
442    pub message: String,
443    #[serde(default)]
444    pub session_id: Option<String>,
445}
446
447/// Core AINL node types - the vocabulary of agent memory.
448#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
449#[serde(tag = "type", rename_all = "snake_case")]
450pub enum AinlNodeType {
451    /// Episodic memory: what happened during an agent turn
452    Episode {
453        #[serde(flatten)]
454        episodic: EpisodicNode,
455    },
456
457    /// Semantic memory: facts learned, with confidence
458    Semantic {
459        #[serde(flatten)]
460        semantic: SemanticNode,
461    },
462
463    /// Procedural memory: reusable compiled workflow patterns
464    Procedural {
465        #[serde(flatten)]
466        procedural: ProceduralNode,
467    },
468
469    /// Persona memory: traits learned over time
470    Persona {
471        #[serde(flatten)]
472        persona: PersonaNode,
473    },
474
475    /// Runtime session state (turn counters, extraction cadence, persona cache snapshot).
476    RuntimeState { runtime_state: RuntimeStateNode },
477
478    /// Step-level execution trace linked to an episode (self-learning substrate).
479    Trajectory { trajectory: TrajectoryNode },
480
481    /// Failure / guard outcome stored for recall (Phase 2 failure learning).
482    Failure { failure: FailureNode },
483}
484
485/// A node in the AINL memory graph
486#[derive(Serialize, Debug, Clone, PartialEq)]
487pub struct AinlMemoryNode {
488    pub id: Uuid,
489    pub memory_category: MemoryCategory,
490    pub importance_score: f32,
491    pub agent_id: String,
492    /// Optional host workspace / repo key (when `AINL_MEMORY_PROJECT_SCOPE` is enabled).
493    #[serde(default, skip_serializing_if = "Option::is_none")]
494    pub project_id: Option<String>,
495    pub node_type: AinlNodeType,
496    pub edges: Vec<AinlEdge>,
497    /// Freeform extension payload for host-specific metadata that doesn't fit a typed variant.
498    /// Stored alongside the node payload; ignored by the core runtime.
499    #[serde(default, skip_serializing_if = "Option::is_none")]
500    pub plugin_data: Option<serde_json::Value>,
501}
502
503#[derive(Deserialize)]
504struct AinlMemoryNodeWire {
505    id: Uuid,
506    #[serde(default)]
507    memory_category: Option<MemoryCategory>,
508    #[serde(default)]
509    importance_score: Option<f32>,
510    #[serde(default)]
511    agent_id: Option<String>,
512    #[serde(default)]
513    project_id: Option<String>,
514    node_type: AinlNodeType,
515    #[serde(default)]
516    edges: Vec<AinlEdge>,
517    #[serde(default)]
518    plugin_data: Option<serde_json::Value>,
519}
520
521impl From<AinlMemoryNodeWire> for AinlMemoryNode {
522    fn from(w: AinlMemoryNodeWire) -> Self {
523        let memory_category = w
524            .memory_category
525            .unwrap_or_else(|| MemoryCategory::from_node_type(&w.node_type));
526        let importance_score = w.importance_score.unwrap_or_else(default_importance_score);
527        Self {
528            id: w.id,
529            memory_category,
530            importance_score,
531            agent_id: w.agent_id.unwrap_or_default(),
532            project_id: w
533                .project_id
534                .as_ref()
535                .map(|s| s.trim().to_string())
536                .filter(|s| !s.is_empty()),
537            node_type: w.node_type,
538            edges: w.edges,
539            plugin_data: w.plugin_data,
540        }
541    }
542}
543
544impl<'de> Deserialize<'de> for AinlMemoryNode {
545    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
546    where
547        D: serde::Deserializer<'de>,
548    {
549        let w = AinlMemoryNodeWire::deserialize(deserializer)?;
550        Ok(Self::from(w))
551    }
552}
553
554/// Typed edge connecting memory nodes
555#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
556pub struct AinlEdge {
557    /// Target node ID
558    pub target_id: Uuid,
559
560    /// Edge label (e.g., "delegated_to", "learned_from", "caused_by")
561    pub label: String,
562}
563
564impl AinlMemoryNode {
565    fn base(
566        memory_category: MemoryCategory,
567        importance_score: f32,
568        agent_id: String,
569        node_type: AinlNodeType,
570    ) -> Self {
571        Self {
572            id: Uuid::new_v4(),
573            memory_category,
574            importance_score,
575            agent_id,
576            project_id: None,
577            node_type,
578            edges: Vec::new(),
579            plugin_data: None,
580        }
581    }
582
583    /// Create a new episode node
584    pub fn new_episode(
585        turn_id: Uuid,
586        timestamp: i64,
587        tool_calls: Vec<String>,
588        delegation_to: Option<String>,
589        trace_event: Option<serde_json::Value>,
590    ) -> Self {
591        let tools_invoked = tool_calls.clone();
592        let episodic = EpisodicNode {
593            turn_id,
594            timestamp,
595            tool_calls,
596            delegation_to,
597            trace_event,
598            turn_index: 0,
599            user_message_tokens: 0,
600            assistant_response_tokens: 0,
601            tools_invoked,
602            persona_signals_emitted: Vec::new(),
603            sentiment: None,
604            flagged: false,
605            conversation_id: String::new(),
606            follows_episode_id: None,
607            user_message: None,
608            assistant_response: None,
609            tags: Vec::new(),
610            vitals_gate: None,
611            vitals_phase: None,
612            vitals_trust: None,
613        };
614        Self::base(
615            MemoryCategory::Episodic,
616            default_importance_score(),
617            String::new(),
618            AinlNodeType::Episode { episodic },
619        )
620    }
621
622    /// Create a new semantic fact node
623    pub fn new_fact(fact: String, confidence: f32, source_turn_id: Uuid) -> Self {
624        let semantic = SemanticNode {
625            fact,
626            confidence,
627            source_turn_id,
628            topic_cluster: None,
629            source_episode_id: String::new(),
630            contradiction_ids: Vec::new(),
631            last_referenced_at: 0,
632            reference_count: 0,
633            decay_eligible: true,
634            tags: Vec::new(),
635            recurrence_count: 0,
636            last_ref_snapshot: 0,
637        };
638        Self::base(
639            MemoryCategory::Semantic,
640            default_importance_score(),
641            String::new(),
642            AinlNodeType::Semantic { semantic },
643        )
644    }
645
646    /// Create a new procedural pattern node
647    pub fn new_pattern(pattern_name: String, compiled_graph: Vec<u8>) -> Self {
648        let mut procedural = ProceduralNode {
649            pattern_name,
650            compiled_graph,
651            tool_sequence: Vec::new(),
652            confidence: None,
653            procedure_type: ProcedureType::default(),
654            trigger_conditions: Vec::new(),
655            success_count: 0,
656            failure_count: 0,
657            success_rate: default_success_rate(),
658            last_invoked_at: 0,
659            reinforcement_episode_ids: Vec::new(),
660            suppression_episode_ids: Vec::new(),
661            patch_version: 1,
662            fitness: None,
663            declared_reads: Vec::new(),
664            retired: false,
665            label: String::new(),
666            trace_id: None,
667            pattern_observation_count: 0,
668            prompt_eligible: true,
669        };
670        procedural.recompute_success_rate();
671        Self::base(
672            MemoryCategory::Procedural,
673            default_importance_score(),
674            String::new(),
675            AinlNodeType::Procedural { procedural },
676        )
677    }
678
679    /// Procedural node from a detected tool workflow (no compiled IR).
680    pub fn new_procedural_tools(
681        pattern_name: String,
682        tool_sequence: Vec<String>,
683        confidence: f32,
684    ) -> Self {
685        use crate::pattern_promotion;
686        let c = confidence.clamp(0.0, 1.0);
687        let ema0 = pattern_promotion::ema_fitness_update(None, c);
688        let mut procedural = ProceduralNode {
689            pattern_name,
690            compiled_graph: Vec::new(),
691            tool_sequence,
692            confidence: Some(c),
693            procedure_type: ProcedureType::ToolSequence,
694            trigger_conditions: Vec::new(),
695            success_count: 0,
696            failure_count: 0,
697            success_rate: default_success_rate(),
698            last_invoked_at: 0,
699            reinforcement_episode_ids: Vec::new(),
700            suppression_episode_ids: Vec::new(),
701            patch_version: 1,
702            fitness: Some(ema0),
703            declared_reads: Vec::new(),
704            retired: false,
705            label: String::new(),
706            trace_id: None,
707            pattern_observation_count: 1,
708            prompt_eligible: false,
709        };
710        procedural.recompute_success_rate();
711        Self::base(
712            MemoryCategory::Procedural,
713            default_importance_score(),
714            String::new(),
715            AinlNodeType::Procedural { procedural },
716        )
717    }
718
719    /// Create a new persona trait node
720    pub fn new_persona(trait_name: String, strength: f32, learned_from: Vec<Uuid>) -> Self {
721        let persona = PersonaNode {
722            trait_name,
723            strength,
724            learned_from,
725            layer: PersonaLayer::default(),
726            source: PersonaSource::default(),
727            strength_floor: default_strength_floor(),
728            locked: false,
729            relevance_score: 0.0,
730            provenance_episode_ids: Vec::new(),
731            evolution_log: Vec::new(),
732            axis_scores: HashMap::new(),
733            evolution_cycle: 0,
734            last_evolved: String::new(),
735            agent_id: String::new(),
736            dominant_axes: Vec::new(),
737        };
738        Self::base(
739            MemoryCategory::Persona,
740            default_importance_score(),
741            String::new(),
742            AinlNodeType::Persona { persona },
743        )
744    }
745
746    /// Create a trajectory node linked to an episode graph row (`episode_id` = episode node's `id`).
747    pub fn new_trajectory(trajectory: TrajectoryNode, agent_id: impl Into<String>) -> Self {
748        Self::base(
749            MemoryCategory::Trajectory,
750            default_importance_score(),
751            agent_id.into(),
752            AinlNodeType::Trajectory { trajectory },
753        )
754    }
755
756    /// Failure node from the agent loop loop-guard (`verdict_label`: `block` | `circuit_break`).
757    pub fn new_loop_guard_failure(
758        verdict_label: &str,
759        tool_name: Option<&str>,
760        message: impl Into<String>,
761        session_id: Option<&str>,
762    ) -> Self {
763        let recorded_at = chrono::Utc::now().timestamp();
764        let source = format!("loop_guard:{verdict_label}");
765        let failure = FailureNode {
766            recorded_at,
767            source,
768            tool_name: tool_name.map(str::to_string),
769            source_namespace: None,
770            source_tool: None,
771            message: message.into(),
772            session_id: session_id.map(str::to_string),
773        };
774        Self::base(
775            MemoryCategory::Failure,
776            default_importance_score(),
777            String::new(),
778            AinlNodeType::Failure { failure },
779        )
780    }
781
782    /// Failure node from a host tool execution error (OpenFang `tool_runner`, MCP dispatch, etc.).
783    ///
784    /// `source` is fixed to `tool_runner:error` for FTS recall alongside loop-guard failures.
785    pub fn new_tool_execution_failure(
786        tool_name: &str,
787        message: impl Into<String>,
788        session_id: Option<&str>,
789    ) -> Self {
790        Self::new_tool_execution_failure_with_source(
791            tool_name,
792            message,
793            session_id,
794            None::<&str>,
795            None::<&str>,
796        )
797    }
798
799    /// Like [`Self::new_tool_execution_failure`], with optional MCP-style source metadata for analytics / recall.
800    pub fn new_tool_execution_failure_with_source(
801        tool_name: &str,
802        message: impl Into<String>,
803        session_id: Option<&str>,
804        source_namespace: Option<&str>,
805        source_tool: Option<&str>,
806    ) -> Self {
807        let recorded_at = chrono::Utc::now().timestamp();
808        let source = "tool_runner:error".to_string();
809        let failure = FailureNode {
810            recorded_at,
811            source,
812            tool_name: Some(tool_name.to_string()),
813            source_namespace: source_namespace.map(str::to_string),
814            source_tool: source_tool.map(str::to_string),
815            message: message.into(),
816            session_id: session_id.map(str::to_string),
817        };
818        Self::base(
819            MemoryCategory::Failure,
820            default_importance_score(),
821            String::new(),
822            AinlNodeType::Failure { failure },
823        )
824    }
825
826    /// Failure node for tool calls rejected in the agent loop **before** `execute_tool` (hooks,
827    /// required-parameter validation, etc.). `kind` becomes `agent_loop:{kind}` in [`FailureNode::source`]
828    /// (e.g. `hook_blocked`, `param_validation`) for FTS recall alongside `tool_runner:error`.
829    pub fn new_agent_loop_precheck_failure(
830        kind: &str,
831        tool_name: &str,
832        message: impl Into<String>,
833        session_id: Option<&str>,
834    ) -> Self {
835        let recorded_at = chrono::Utc::now().timestamp();
836        let source = format!("agent_loop:{kind}");
837        let failure = FailureNode {
838            recorded_at,
839            source,
840            tool_name: Some(tool_name.to_string()),
841            source_namespace: None,
842            source_tool: None,
843            message: message.into(),
844            session_id: session_id.map(str::to_string),
845        };
846        Self::base(
847            MemoryCategory::Failure,
848            default_importance_score(),
849            String::new(),
850            AinlNodeType::Failure { failure },
851        )
852    }
853
854    /// Failure node when `ainl-runtime` rejects a turn because the loaded graph fails validation
855    /// (dangling edges, etc.). Fixed `source` for FTS recall with other failure origins.
856    pub fn new_ainl_runtime_graph_validation_failure(
857        message: impl Into<String>,
858        session_id: Option<&str>,
859    ) -> Self {
860        let recorded_at = chrono::Utc::now().timestamp();
861        let source = "ainl_runtime:graph_validation".to_string();
862        let failure = FailureNode {
863            recorded_at,
864            source,
865            tool_name: None,
866            source_namespace: None,
867            source_tool: None,
868            message: message.into(),
869            session_id: session_id.map(str::to_string),
870        };
871        Self::base(
872            MemoryCategory::Failure,
873            default_importance_score(),
874            String::new(),
875            AinlNodeType::Failure { failure },
876        )
877    }
878
879    pub fn episodic(&self) -> Option<&EpisodicNode> {
880        match &self.node_type {
881            AinlNodeType::Episode { episodic } => Some(episodic),
882            _ => None,
883        }
884    }
885
886    pub fn semantic(&self) -> Option<&SemanticNode> {
887        match &self.node_type {
888            AinlNodeType::Semantic { semantic } => Some(semantic),
889            _ => None,
890        }
891    }
892
893    pub fn procedural(&self) -> Option<&ProceduralNode> {
894        match &self.node_type {
895            AinlNodeType::Procedural { procedural } => Some(procedural),
896            _ => None,
897        }
898    }
899
900    pub fn persona(&self) -> Option<&PersonaNode> {
901        match &self.node_type {
902            AinlNodeType::Persona { persona } => Some(persona),
903            _ => None,
904        }
905    }
906
907    pub fn trajectory(&self) -> Option<&TrajectoryNode> {
908        match &self.node_type {
909            AinlNodeType::Trajectory { trajectory } => Some(trajectory),
910            _ => None,
911        }
912    }
913
914    pub fn failure(&self) -> Option<&FailureNode> {
915        match &self.node_type {
916            AinlNodeType::Failure { failure } => Some(failure),
917            _ => None,
918        }
919    }
920
921    /// Add an edge to another node
922    pub fn add_edge(&mut self, target_id: Uuid, label: impl Into<String>) {
923        self.edges.push(AinlEdge {
924            target_id,
925            label: label.into(),
926        });
927    }
928}
929
930#[cfg(test)]
931mod trajectory_tests {
932    use super::*;
933    use uuid::Uuid;
934
935    #[test]
936    fn trajectory_node_serde_roundtrip() {
937        let traj = TrajectoryNode {
938            episode_id: Uuid::nil(),
939            recorded_at: 1700000000,
940            session_id: "sess".into(),
941            project_id: Some("proj".into()),
942            ainl_source_hash: Some("abc".into()),
943            outcome: TrajectoryOutcome::Success,
944            steps: vec![TrajectoryStep {
945                step_id: "1".into(),
946                timestamp_ms: 1,
947                adapter: "http".into(),
948                operation: "GET".into(),
949                inputs_preview: None,
950                outputs_preview: None,
951                duration_ms: 2,
952                success: true,
953                error: None,
954                vitals: None,
955                freshness_at_step: None,
956                frame_vars: None,
957                tool_telemetry: None,
958            }],
959            duration_ms: 10,
960            frame_vars: None,
961            fitness_delta: None,
962        };
963        let node = AinlMemoryNode {
964            id: Uuid::nil(),
965            memory_category: MemoryCategory::Trajectory,
966            importance_score: 0.5,
967            agent_id: "agent".into(),
968            project_id: None,
969            node_type: AinlNodeType::Trajectory { trajectory: traj },
970            edges: Vec::new(),
971            plugin_data: None,
972        };
973        let json = serde_json::to_string(&node).expect("serialize");
974        let back: AinlMemoryNode = serde_json::from_str(&json).expect("deserialize");
975        assert!(matches!(back.node_type, AinlNodeType::Trajectory { .. }));
976        assert_eq!(back.trajectory().map(|t| t.episode_id), Some(Uuid::nil()));
977    }
978}