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}