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