Skip to main content

zeph_memory/
types.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Core identifier and tier types used throughout `zeph-memory`.
5
6/// Memory tier classification for the AOI four-layer architecture.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
8#[serde(rename_all = "lowercase")]
9#[non_exhaustive]
10pub enum MemoryTier {
11    /// Current conversation window. Virtual tier — not stored in the DB.
12    Working,
13    /// Session-bound messages. Default tier for all persisted messages.
14    Episodic,
15    /// Cross-session distilled facts. Promoted from Episodic when a fact
16    /// appears in `promotion_min_sessions`+ distinct sessions.
17    Semantic,
18    /// Long-lived user attributes (preferences, domain knowledge, working style).
19    /// Extracted from conversation history and injected into context (#2461).
20    Persona,
21}
22
23impl MemoryTier {
24    /// Return the canonical lowercase string representation.
25    ///
26    /// # Examples
27    ///
28    /// ```
29    /// use zeph_memory::MemoryTier;
30    ///
31    /// assert_eq!(MemoryTier::Episodic.as_str(), "episodic");
32    /// assert_eq!(MemoryTier::Semantic.as_str(), "semantic");
33    /// ```
34    #[must_use]
35    pub fn as_str(self) -> &'static str {
36        match self {
37            Self::Working => "working",
38            Self::Episodic => "episodic",
39            Self::Semantic => "semantic",
40            Self::Persona => "persona",
41        }
42    }
43}
44
45impl std::fmt::Display for MemoryTier {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        f.pad(self.as_str())
48    }
49}
50
51impl std::str::FromStr for MemoryTier {
52    type Err = String;
53    fn from_str(s: &str) -> Result<Self, Self::Err> {
54        match s {
55            "working" => Ok(Self::Working),
56            "episodic" => Ok(Self::Episodic),
57            "semantic" => Ok(Self::Semantic),
58            "persona" => Ok(Self::Persona),
59            other => Err(format!("unknown memory tier: {other}")),
60        }
61    }
62}
63
64/// Strongly typed wrapper for conversation row IDs.
65///
66/// Wraps the `SQLite` `conversations.id` integer primary key to prevent accidental
67/// confusion with [`MessageId`] or [`MemSceneId`] values.
68///
69/// # Examples
70///
71/// ```
72/// use zeph_memory::ConversationId;
73///
74/// let id = ConversationId(42);
75/// assert_eq!(id.to_string(), "42");
76/// ```
77#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, sqlx::Type)]
78#[sqlx(transparent)]
79pub struct ConversationId(pub i64);
80
81impl std::fmt::Display for ConversationId {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        write!(f, "{}", self.0)
84    }
85}
86
87/// Strongly typed wrapper for message row IDs.
88///
89/// Wraps the `SQLite` `messages.id` integer primary key to prevent confusion
90/// with [`ConversationId`] or [`MemSceneId`] values.
91///
92/// # Examples
93///
94/// ```
95/// use zeph_memory::MessageId;
96///
97/// let id = MessageId(7);
98/// assert_eq!(id.to_string(), "7");
99/// ```
100#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, sqlx::Type)]
101#[sqlx(transparent)]
102pub struct MessageId(pub i64);
103
104/// Strongly typed wrapper for `mem_scene` row IDs.
105///
106/// Wraps the `SQLite` `mem_scenes.id` integer primary key. Used by the scene
107/// consolidation subsystem to identify distinct conversational scenes.
108///
109/// # Examples
110///
111/// ```
112/// use zeph_memory::MemSceneId;
113///
114/// let id = MemSceneId(3);
115/// assert_eq!(id.to_string(), "3");
116/// ```
117#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, sqlx::Type)]
118#[sqlx(transparent)]
119pub struct MemSceneId(pub i64);
120
121impl std::fmt::Display for MemSceneId {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        write!(f, "{}", self.0)
124    }
125}
126
127impl std::fmt::Display for MessageId {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        write!(f, "{}", self.0)
130    }
131}
132
133/// Strongly typed wrapper for `experience_nodes.id` row IDs.
134///
135/// Prevents accidental confusion with [`EntityId`], [`ConversationId`], or [`MessageId`]
136/// at experience-memory API boundaries.
137///
138/// # Examples
139///
140/// ```
141/// use zeph_memory::ExperienceId;
142///
143/// let id = ExperienceId(10);
144/// assert_eq!(id.to_string(), "10");
145/// ```
146#[derive(
147    Debug,
148    Clone,
149    Copy,
150    PartialEq,
151    Eq,
152    PartialOrd,
153    Ord,
154    Hash,
155    sqlx::Type,
156    serde::Serialize,
157    serde::Deserialize,
158)]
159#[sqlx(transparent)]
160pub struct ExperienceId(pub i64);
161
162impl std::fmt::Display for ExperienceId {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        write!(f, "{}", self.0)
165    }
166}
167
168/// Strongly typed wrapper for `graph_entities.id` row IDs.
169///
170/// Prevents confusion with [`ExperienceId`] or other integer IDs at graph-store
171/// API boundaries.
172///
173/// # Examples
174///
175/// ```
176/// use zeph_memory::EntityId;
177///
178/// let id = EntityId(5);
179/// assert_eq!(id.to_string(), "5");
180/// ```
181#[derive(
182    Debug,
183    Clone,
184    Copy,
185    PartialEq,
186    Eq,
187    PartialOrd,
188    Ord,
189    Hash,
190    sqlx::Type,
191    serde::Serialize,
192    serde::Deserialize,
193)]
194#[sqlx(transparent)]
195pub struct EntityId(pub i64);
196
197impl std::fmt::Display for EntityId {
198    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199        write!(f, "{}", self.0)
200    }
201}
202
203/// Discriminates which subsystem produced a [`UsageRecord`] row (issue #6549).
204///
205/// `Conversation` rows link to a persisted `messages.id` via [`UsageRecord::message_id`];
206/// the other three variants are background/orchestration calls that never produce a
207/// conversational `Message`, so `message_id` stays `None` on those rows.
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
209pub enum UsageSource {
210    /// A turn-loop LLM call tied to a persisted assistant message.
211    Conversation,
212    /// A scheduler/orchestration planner LLM call (`plan.rs`).
213    Planner,
214    /// A scheduler/orchestration aggregator LLM call (`plan.rs`).
215    Aggregator,
216    /// A verifier-ensemble member LLM call (`scheduler_loop.rs`).
217    EnsembleMember,
218}
219
220impl UsageSource {
221    /// Return the canonical string stored in the `usage_records.source` column.
222    ///
223    /// # Examples
224    ///
225    /// ```
226    /// use zeph_memory::UsageSource;
227    ///
228    /// assert_eq!(UsageSource::Conversation.as_str(), "conversation");
229    /// ```
230    #[must_use]
231    pub fn as_str(self) -> &'static str {
232        match self {
233            Self::Conversation => "conversation",
234            Self::Planner => "planner",
235            Self::Aggregator => "aggregator",
236            Self::EnsembleMember => "ensemble_member",
237        }
238    }
239}
240
241impl std::fmt::Display for UsageSource {
242    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243        f.pad(self.as_str())
244    }
245}
246
247impl std::str::FromStr for UsageSource {
248    type Err = String;
249    fn from_str(s: &str) -> Result<Self, Self::Err> {
250        match s {
251            "conversation" => Ok(Self::Conversation),
252            "planner" => Ok(Self::Planner),
253            "aggregator" => Ok(Self::Aggregator),
254            "ensemble_member" => Ok(Self::EnsembleMember),
255            other => Err(format!("unknown usage source: {other}")),
256        }
257    }
258}
259
260/// A durable per-LLM-call usage/cost/latency record (issue #6549, per-message usage tracking).
261///
262/// Written alongside every production call site that feeds `CostTracker::record_usage`
263/// (the turn loop, planner, aggregator, and ensemble-member paths) so the sum of a UTC
264/// day's rows reconciles with `CostTracker::current_spend()`. `message_id`/`conversation_id`
265/// are `None` for background/orchestration rows ([`UsageSource::Planner`],
266/// [`UsageSource::Aggregator`], [`UsageSource::EnsembleMember`]) that have no persisted
267/// conversational `Message`.
268///
269/// # Examples
270///
271/// ```
272/// use zeph_memory::{UsageRecord, UsageSource};
273///
274/// let record = UsageRecord {
275///     message_id: None,
276///     conversation_id: None,
277///     source: UsageSource::Planner,
278///     provider_name: "quality".to_string(),
279///     model_name: "claude-sonnet-5".to_string(),
280///     input_tokens: 100,
281///     output_tokens: 50,
282///     cache_read_tokens: 0,
283///     cache_write_tokens: 0,
284///     reasoning_tokens: None,
285///     cost_cents: 0.05,
286///     latency_ms: 800,
287///     ttft_ms: None,
288///     tokens_per_sec: None,
289/// };
290/// assert_eq!(record.source.as_str(), "planner");
291/// ```
292#[derive(Debug, Clone, PartialEq)]
293pub struct UsageRecord {
294    /// `Some` for conversational turn rows; `None` for background/orchestration rows.
295    pub message_id: Option<MessageId>,
296    /// `Some` whenever the conversation is known at write time; `None` for rows written
297    /// outside any conversation context (e.g. a scheduled task with no active turn).
298    pub conversation_id: Option<ConversationId>,
299    /// Which subsystem produced this row.
300    pub source: UsageSource,
301    /// The `[[llm.providers]]` entry name that served the call.
302    pub provider_name: String,
303    /// The model identifier used for the call.
304    pub model_name: String,
305    pub input_tokens: u64,
306    pub output_tokens: u64,
307    pub cache_read_tokens: u64,
308    pub cache_write_tokens: u64,
309    /// Subset of `output_tokens` (`OpenAI` o-series only). `None` when the provider does
310    /// not report reasoning tokens separately.
311    pub reasoning_tokens: Option<u64>,
312    /// Cost in cents, computed by `CostTracker::price_of` — the same pricing source of
313    /// truth used by the live daily-budget aggregate.
314    pub cost_cents: f64,
315    /// Full call latency (request send to response fully received); always populated.
316    pub latency_ms: u64,
317    /// True time-to-first-token when the call streamed (currently: the speculative-decoding
318    /// path only), or a time-to-first-byte proxy otherwise. `None` only for the in-process
319    /// Candle backend.
320    pub ttft_ms: Option<u64>,
321    /// Derived throughput: `output_tokens / ((latency_ms - ttft_ms) / 1000)`. `None`
322    /// unless both `ttft_ms` and a positive generation window are available.
323    pub tokens_per_sec: Option<f64>,
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn memory_tier_round_trip() {
332        for tier in [
333            MemoryTier::Working,
334            MemoryTier::Episodic,
335            MemoryTier::Semantic,
336            MemoryTier::Persona,
337        ] {
338            let s = tier.as_str();
339            let parsed: MemoryTier = s.parse().expect("should parse");
340            assert_eq!(parsed, tier);
341            assert_eq!(format!("{tier}"), s);
342        }
343    }
344
345    #[test]
346    fn memory_tier_unknown_string_errors() {
347        assert!("unknown".parse::<MemoryTier>().is_err());
348    }
349
350    /// Locks in the `f.pad` fix (#6066): `f.write_str` ignores width/fill/align flags.
351    /// `f.pad` must reproduce the same padding a plain `&str` would get under an
352    /// identical width specifier.
353    #[test]
354    fn memory_tier_display_respects_width() {
355        assert_eq!(
356            format!("{:<10}", MemoryTier::Working),
357            format!("{:<10}", "working")
358        );
359        assert_eq!(
360            format!("{:>10}", MemoryTier::Semantic),
361            format!("{:>10}", "semantic")
362        );
363    }
364
365    #[test]
366    fn memory_tier_serde_round_trip() {
367        let json = serde_json::to_string(&MemoryTier::Semantic).unwrap();
368        assert_eq!(json, "\"semantic\"");
369        let parsed: MemoryTier = serde_json::from_str(&json).unwrap();
370        assert_eq!(parsed, MemoryTier::Semantic);
371    }
372
373    #[test]
374    fn conversation_id_display() {
375        let id = ConversationId(42);
376        assert_eq!(format!("{id}"), "42");
377    }
378
379    #[test]
380    fn message_id_display() {
381        let id = MessageId(7);
382        assert_eq!(format!("{id}"), "7");
383    }
384
385    #[test]
386    fn conversation_id_eq() {
387        assert_eq!(ConversationId(1), ConversationId(1));
388        assert_ne!(ConversationId(1), ConversationId(2));
389    }
390
391    #[test]
392    fn message_id_copy() {
393        let id = MessageId(5);
394        let copied = id;
395        assert_eq!(id, copied);
396    }
397
398    #[test]
399    fn experience_id_display() {
400        let id = ExperienceId(10);
401        assert_eq!(format!("{id}"), "10");
402    }
403
404    #[test]
405    fn entity_id_display() {
406        let id = EntityId(5);
407        assert_eq!(format!("{id}"), "5");
408    }
409
410    #[test]
411    fn experience_id_ord() {
412        assert!(ExperienceId(1) < ExperienceId(2));
413        assert_eq!(ExperienceId(3), ExperienceId(3));
414    }
415
416    #[test]
417    fn entity_id_hash() {
418        use std::collections::HashSet;
419        let mut set = HashSet::new();
420        set.insert(EntityId(1));
421        set.insert(EntityId(2));
422        set.insert(EntityId(1));
423        assert_eq!(set.len(), 2);
424    }
425}