Skip to main content

zeph_agent_context/
memory_backend.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Adapters that bridge `zeph-memory` concrete types to `zeph-common` traits consumed
5//! by `zeph-context`.
6//!
7//! This module is the only place in the workspace where both `zeph-memory` and
8//! `zeph-context` interface types are visible simultaneously — by design. `zeph-core`
9//! builds adapters here at Layer 4 so that `zeph-context` (Layer 1) never imports
10//! `zeph-memory` (Layer 1).
11
12use std::pin::Pin;
13
14use zeph_common::memory::{
15    AsyncMemoryRouter, ContextMemoryBackend, GraphRecallParams, MemCorrection, MemDocumentChunk,
16    MemGraphFact, MemGraphNeighbor, MemPersonaFact, MemReasoningStrategy, MemRecalledMessage,
17    MemSessionSummary, MemSummary, MemTrajectoryEntry, MemTreeNode, RecallView,
18};
19use zeph_memory::semantic::SemanticMemory;
20use zeph_memory::{ConversationId, RecallView as MemRecallView, RecalledFact};
21
22fn box_err<E: std::error::Error + Send + Sync + 'static>(
23    e: E,
24) -> Box<dyn std::error::Error + Send + Sync> {
25    Box::new(e)
26}
27
28fn map_persona_fact(r: zeph_memory::PersonaFactRow) -> MemPersonaFact {
29    MemPersonaFact {
30        category: r.category,
31        content: r.content,
32    }
33}
34
35fn map_trajectory_entry(r: zeph_memory::TrajectoryEntryRow) -> MemTrajectoryEntry {
36    MemTrajectoryEntry {
37        intent: r.intent,
38        outcome: r.outcome,
39        confidence: r.confidence,
40    }
41}
42
43fn map_tree_node(r: zeph_memory::MemoryTreeRow) -> MemTreeNode {
44    MemTreeNode { content: r.content }
45}
46
47fn map_summary(r: zeph_memory::semantic::Summary) -> MemSummary {
48    MemSummary {
49        first_message_id: r.first_message_id.map(|m| m.0),
50        last_message_id: r.last_message_id.map(|m| m.0),
51        content: r.content,
52    }
53}
54
55fn map_reasoning_strategy(s: zeph_memory::ReasoningStrategy) -> MemReasoningStrategy {
56    MemReasoningStrategy {
57        id: s.id,
58        outcome: s.outcome.as_str().to_owned(),
59        summary: s.summary,
60    }
61}
62
63fn map_correction(c: zeph_memory::UserCorrectionRow) -> MemCorrection {
64    MemCorrection {
65        correction_text: c.correction_text,
66    }
67}
68
69fn map_recalled_message(r: zeph_memory::RecalledMessage) -> MemRecalledMessage {
70    use zeph_llm::provider::Role;
71    let role = match r.message.role {
72        Role::User => "user",
73        Role::Assistant => "assistant",
74        Role::System => "system",
75    }
76    .to_owned();
77    MemRecalledMessage {
78        role,
79        content: r.message.content,
80        score: r.score,
81    }
82}
83
84fn map_graph_fact(rf: RecalledFact) -> MemGraphFact {
85    MemGraphFact {
86        fact: rf.fact.fact,
87        confidence: rf.fact.confidence,
88        activation_score: rf.activation_score,
89        neighbors: rf
90            .neighbors
91            .into_iter()
92            .map(|n| MemGraphNeighbor {
93                fact: n.fact,
94                confidence: n.confidence,
95            })
96            .collect(),
97        provenance_snippet: rf.provenance_snippet,
98    }
99}
100
101fn map_session_summary(r: zeph_memory::semantic::SessionSummaryResult) -> MemSessionSummary {
102    MemSessionSummary {
103        summary_text: r.summary_text,
104        score: r.score,
105    }
106}
107
108/// Adapter that implements [`ContextMemoryBackend`] by delegating to [`SemanticMemory`].
109pub struct SemanticMemoryBackend {
110    inner: std::sync::Arc<SemanticMemory>,
111}
112
113impl SemanticMemoryBackend {
114    /// Wrap an `Arc<SemanticMemory>` in the backend adapter.
115    #[must_use]
116    pub fn new(inner: std::sync::Arc<SemanticMemory>) -> Self {
117        Self { inner }
118    }
119}
120
121type BoxFut<'a, T> = Pin<
122    Box<
123        dyn std::future::Future<Output = Result<T, Box<dyn std::error::Error + Send + Sync>>>
124            + Send
125            + 'a,
126    >,
127>;
128
129impl ContextMemoryBackend for SemanticMemoryBackend {
130    fn load_persona_facts(&self, min_confidence: f64) -> BoxFut<'_, Vec<MemPersonaFact>> {
131        Box::pin(async move {
132            let rows = self
133                .inner
134                .sqlite()
135                .load_persona_facts(min_confidence)
136                .await
137                .map_err(box_err)?;
138            Ok(rows.into_iter().map(map_persona_fact).collect())
139        })
140    }
141
142    fn load_trajectory_entries<'a>(
143        &'a self,
144        tier: Option<&'a str>,
145        top_k: usize,
146    ) -> BoxFut<'a, Vec<MemTrajectoryEntry>> {
147        Box::pin(async move {
148            let rows = self
149                .inner
150                .sqlite()
151                .load_trajectory_entries(tier, top_k)
152                .await
153                .map_err(box_err)?;
154            Ok(rows.into_iter().map(map_trajectory_entry).collect())
155        })
156    }
157
158    fn load_tree_nodes(&self, level: u32, top_k: usize) -> BoxFut<'_, Vec<MemTreeNode>> {
159        Box::pin(async move {
160            let rows = self
161                .inner
162                .sqlite()
163                .load_tree_level(level.into(), top_k)
164                .await
165                .map_err(box_err)?;
166            Ok(rows.into_iter().map(map_tree_node).collect())
167        })
168    }
169
170    fn load_summaries(&self, conversation_id: i64) -> BoxFut<'_, Vec<MemSummary>> {
171        Box::pin(async move {
172            let cid = ConversationId(conversation_id);
173            let rows = self.inner.load_summaries(cid).await.map_err(box_err)?;
174            Ok(rows.into_iter().map(map_summary).collect())
175        })
176    }
177
178    fn retrieve_reasoning_strategies<'a>(
179        &'a self,
180        query: &'a str,
181        top_k: usize,
182    ) -> BoxFut<'a, Vec<MemReasoningStrategy>> {
183        Box::pin(async move {
184            let strategies = self
185                .inner
186                .retrieve_reasoning_strategies(query, top_k)
187                .await
188                .map_err(box_err)?;
189            Ok(strategies.into_iter().map(map_reasoning_strategy).collect())
190        })
191    }
192
193    fn mark_reasoning_used<'a>(&'a self, ids: &'a [String]) -> BoxFut<'a, ()> {
194        Box::pin(async move {
195            if let Some(ref reasoning) = self.inner.reasoning {
196                reasoning.mark_used(ids).await.map_err(box_err)?;
197            }
198            Ok(())
199        })
200    }
201
202    fn retrieve_corrections<'a>(
203        &'a self,
204        query: &'a str,
205        limit: usize,
206        min_score: f32,
207    ) -> BoxFut<'a, Vec<MemCorrection>> {
208        Box::pin(async move {
209            let corrections = self
210                .inner
211                .retrieve_similar_corrections(query, limit, min_score)
212                .await
213                .map_err(box_err)?;
214            Ok(corrections.into_iter().map(map_correction).collect())
215        })
216    }
217
218    fn recall<'a>(
219        &'a self,
220        query: &'a str,
221        limit: usize,
222        router: Option<&'a dyn AsyncMemoryRouter>,
223    ) -> BoxFut<'a, Vec<MemRecalledMessage>> {
224        Box::pin(async move {
225            let recalled = if let Some(r) = router {
226                self.inner
227                    .recall_routed_async(query, limit, None, r, None)
228                    .await
229                    .map_err(box_err)?
230            } else {
231                self.inner
232                    .recall(query, limit, None)
233                    .await
234                    .map_err(box_err)?
235            };
236            Ok(recalled.into_iter().map(map_recalled_message).collect())
237        })
238    }
239
240    fn recall_graph_facts<'a>(
241        &'a self,
242        query: &'a str,
243        params: GraphRecallParams<'a>,
244    ) -> BoxFut<'a, Vec<MemGraphFact>> {
245        Box::pin(async move {
246            let mem_view = match params.view {
247                RecallView::ZoomIn => MemRecallView::ZoomIn,
248                RecallView::ZoomOut => MemRecallView::ZoomOut,
249                _ => MemRecallView::Head,
250            };
251            let mem_edge_types: Vec<zeph_memory::EdgeType> = params
252                .edge_types
253                .iter()
254                .map(|e| {
255                    use zeph_common::memory::EdgeType as CE;
256                    use zeph_memory::EdgeType as ME;
257                    match e {
258                        CE::Temporal => ME::Temporal,
259                        CE::Causal => ME::Causal,
260                        CE::Entity => ME::Entity,
261                        _ => ME::Semantic,
262                    }
263                })
264                .collect();
265            let sa_params = params.spreading_activation.map(|p| {
266                zeph_memory::graph::SpreadingActivationParams {
267                    decay_lambda: p.decay_lambda,
268                    max_hops: p.max_hops,
269                    activation_threshold: p.activation_threshold,
270                    inhibition_threshold: p.inhibition_threshold,
271                    max_activated_nodes: p.max_activated_nodes,
272                    temporal_decay_rate: p.temporal_decay_rate,
273                    seed_structural_weight: p.seed_structural_weight,
274                    seed_community_cap: p.seed_community_cap,
275                }
276            });
277            let recalled = self
278                .inner
279                .recall_graph_view(
280                    query,
281                    params.limit,
282                    mem_view,
283                    params.zoom_out_neighbor_cap,
284                    params.max_hops,
285                    params.temporal_decay_rate,
286                    &mem_edge_types,
287                    sa_params,
288                )
289                .await
290                .map_err(box_err)?;
291            Ok(recalled.into_iter().map(map_graph_fact).collect())
292        })
293    }
294
295    fn search_session_summaries<'a>(
296        &'a self,
297        query: &'a str,
298        limit: usize,
299        current_conversation_id: Option<i64>,
300    ) -> BoxFut<'a, Vec<MemSessionSummary>> {
301        Box::pin(async move {
302            let cid = current_conversation_id.map(ConversationId);
303            let results = self
304                .inner
305                .search_session_summaries(query, limit, cid)
306                .await
307                .map_err(box_err)?;
308            Ok(results.into_iter().map(map_session_summary).collect())
309        })
310    }
311
312    fn search_document_collection<'a>(
313        &'a self,
314        collection: &'a str,
315        query: &'a str,
316        top_k: usize,
317    ) -> BoxFut<'a, Vec<MemDocumentChunk>> {
318        Box::pin(async move {
319            let points = self
320                .inner
321                .search_document_collection(collection, query, top_k)
322                .await
323                .map_err(box_err)?;
324            Ok(points
325                .into_iter()
326                .map(|p| {
327                    let text = p
328                        .payload
329                        .get("text")
330                        .and_then(|v| v.as_str())
331                        .unwrap_or_default()
332                        .to_owned();
333                    MemDocumentChunk { text }
334                })
335                .collect())
336        })
337    }
338}
339
340/// Adapter implementing [`zeph_context::summarization::MessageTokenCounter`] for
341/// [`zeph_memory::TokenCounter`].
342pub struct TokenCounterAdapter(std::sync::Arc<zeph_memory::TokenCounter>);
343
344impl TokenCounterAdapter {
345    /// Wrap an `Arc<TokenCounter>` in the adapter.
346    #[must_use]
347    pub fn new(inner: std::sync::Arc<zeph_memory::TokenCounter>) -> Self {
348        Self(inner)
349    }
350}
351
352impl zeph_context::summarization::MessageTokenCounter for TokenCounterAdapter {
353    fn count_message_tokens(&self, msg: &zeph_llm::provider::Message) -> usize {
354        self.0.count_message_tokens(msg)
355    }
356}
357
358/// Build a memory router from the context manager's routing configuration.
359///
360/// Moved from `ContextManager::build_router()` to `zeph-agent-context` (Layer 4)
361/// so that `zeph-context` (Layer 1) no longer needs to import concrete router types
362/// from `zeph-memory` (Layer 1).
363///
364/// Returns a `Box<dyn AsyncMemoryRouter>` compatible with `ContextAssemblyInput::router`.
365#[must_use]
366pub fn build_memory_router(
367    manager: &zeph_context::manager::ContextManager,
368) -> Box<dyn zeph_common::memory::AsyncMemoryRouter + Send + Sync> {
369    use zeph_config::StoreRoutingStrategy;
370
371    if !manager.routing.enabled {
372        return Box::new(zeph_memory::HeuristicRouter);
373    }
374    let fallback = manager.routing.fallback_route;
375    match manager.routing.strategy {
376        StoreRoutingStrategy::Llm => {
377            let Some(provider) = manager.store_routing_provider.clone() else {
378                tracing::warn!(
379                    "store_routing: strategy=llm but no provider resolved; \
380                     falling back to heuristic"
381                );
382                return Box::new(zeph_memory::HeuristicRouter);
383            };
384            Box::new(zeph_memory::LlmRouter::new(provider, fallback))
385        }
386        StoreRoutingStrategy::Hybrid => {
387            let Some(provider) = manager.store_routing_provider.clone() else {
388                tracing::warn!(
389                    "store_routing: strategy=hybrid but no provider resolved; \
390                     falling back to heuristic"
391                );
392                return Box::new(zeph_memory::HeuristicRouter);
393            };
394            Box::new(zeph_memory::HybridRouter::new(
395                provider,
396                fallback,
397                manager.routing.confidence_threshold,
398            ))
399        }
400        _ => Box::new(zeph_memory::HeuristicRouter),
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use zeph_llm::provider::{Message, Role};
407    use zeph_memory::graph::types::{EdgeType, GraphFact};
408    use zeph_memory::semantic::{SessionSummaryResult, Summary};
409    use zeph_memory::types::{ConversationId, MessageId};
410    use zeph_memory::{
411        MemoryTreeRow, Outcome, PersonaFactRow, ReasoningStrategy, RecalledFact, RecalledMessage,
412        TrajectoryEntryRow, UserCorrectionRow,
413    };
414
415    use super::*;
416
417    fn make_persona_row() -> PersonaFactRow {
418        PersonaFactRow {
419            id: 1,
420            category: "preference".to_owned(),
421            content: "prefers short answers".to_owned(),
422            confidence: 0.9,
423            evidence_count: 3,
424            source_conversation_id: None,
425            supersedes_id: None,
426            created_at: "2026-01-01".to_owned(),
427            updated_at: "2026-01-02".to_owned(),
428        }
429    }
430
431    fn make_trajectory_row() -> TrajectoryEntryRow {
432        TrajectoryEntryRow {
433            id: 1,
434            conversation_id: Some(42),
435            turn_index: 5,
436            kind: "procedural".to_owned(),
437            intent: "read a file".to_owned(),
438            outcome: "file read successfully".to_owned(),
439            tools_used: "read_file".to_owned(),
440            confidence: 0.85,
441            created_at: "2026-01-01".to_owned(),
442            updated_at: "2026-01-01".to_owned(),
443        }
444    }
445
446    fn make_tree_row() -> MemoryTreeRow {
447        MemoryTreeRow {
448            id: 1,
449            level: 0,
450            parent_id: None,
451            content: "node content here".to_owned(),
452            source_ids: "1,2,3".to_owned(),
453            token_count: 10,
454            consolidated_at: None,
455            created_at: "2026-01-01".to_owned(),
456        }
457    }
458
459    fn make_summary() -> Summary {
460        Summary {
461            id: 1,
462            conversation_id: ConversationId(10),
463            content: "summary of the conversation".to_owned(),
464            first_message_id: Some(MessageId(5)),
465            last_message_id: Some(MessageId(20)),
466            token_estimate: 100,
467        }
468    }
469
470    fn make_reasoning_strategy() -> ReasoningStrategy {
471        ReasoningStrategy {
472            id: "strat-uuid-1".to_owned(),
473            summary: "break the problem into parts".to_owned(),
474            outcome: Outcome::Success,
475            task_hint: "code refactoring task".to_owned(),
476            created_at: 1_700_000_000,
477            last_used_at: 1_700_000_100,
478            use_count: 3,
479            embedded_at: Some(1_700_000_050),
480        }
481    }
482
483    fn make_correction_row() -> UserCorrectionRow {
484        UserCorrectionRow {
485            id: 1,
486            session_id: Some(7),
487            original_output: "wrong output".to_owned(),
488            correction_text: "use bullet points".to_owned(),
489            skill_name: Some("formatting".to_owned()),
490            correction_kind: "explicit_rejection".to_owned(),
491            created_at: "2026-01-01".to_owned(),
492        }
493    }
494
495    fn make_recalled_message(role: Role) -> RecalledMessage {
496        RecalledMessage {
497            message: Message {
498                role,
499                content: "hello world".to_owned(),
500                ..Default::default()
501            },
502            score: 0.75,
503        }
504    }
505
506    fn make_graph_fact() -> GraphFact {
507        GraphFact {
508            entity_name: "Rust".to_owned(),
509            relation: "uses".to_owned(),
510            target_name: "LLVM".to_owned(),
511            fact: "Rust uses LLVM".to_owned(),
512            entity_match_score: 0.9,
513            hop_distance: 0,
514            confidence: 0.95,
515            valid_from: None,
516            edge_type: EdgeType::Semantic,
517            retrieval_count: 1,
518            edge_id: Some(10),
519        }
520    }
521
522    fn make_recalled_fact() -> RecalledFact {
523        RecalledFact::from_graph_fact(make_graph_fact())
524    }
525
526    fn make_session_summary() -> SessionSummaryResult {
527        SessionSummaryResult {
528            summary_text: "yesterday's session about Rust".to_owned(),
529            score: 0.88,
530            conversation_id: ConversationId(99),
531        }
532    }
533
534    // ── map_persona_fact ──────────────────────────────────────────────────────
535
536    #[test]
537    fn persona_fact_maps_fields() {
538        let row = make_persona_row();
539        let dto = map_persona_fact(row);
540        assert_eq!(dto.category, "preference");
541        assert_eq!(dto.content, "prefers short answers");
542    }
543
544    // ── map_trajectory_entry ──────────────────────────────────────────────────
545
546    #[test]
547    fn trajectory_entry_maps_fields() {
548        let row = make_trajectory_row();
549        let dto = map_trajectory_entry(row);
550        assert_eq!(dto.intent, "read a file");
551        assert_eq!(dto.outcome, "file read successfully");
552        assert!((dto.confidence - 0.85).abs() < f64::EPSILON);
553    }
554
555    // ── map_tree_node ─────────────────────────────────────────────────────────
556
557    #[test]
558    fn tree_node_maps_content() {
559        let row = make_tree_row();
560        let dto = map_tree_node(row);
561        assert_eq!(dto.content, "node content here");
562    }
563
564    // ── map_summary ───────────────────────────────────────────────────────────
565
566    #[test]
567    fn summary_maps_all_fields() {
568        let s = make_summary();
569        let dto = map_summary(s);
570        assert_eq!(dto.first_message_id, Some(5));
571        assert_eq!(dto.last_message_id, Some(20));
572        assert_eq!(dto.content, "summary of the conversation");
573    }
574
575    #[test]
576    fn summary_none_message_ids_stay_none() {
577        let s = Summary {
578            id: 2,
579            conversation_id: ConversationId(1),
580            content: "shutdown summary".to_owned(),
581            first_message_id: None,
582            last_message_id: None,
583            token_estimate: 50,
584        };
585        let dto = map_summary(s);
586        assert!(dto.first_message_id.is_none());
587        assert!(dto.last_message_id.is_none());
588    }
589
590    // ── map_reasoning_strategy ────────────────────────────────────────────────
591
592    #[test]
593    fn reasoning_strategy_maps_success_outcome() {
594        let s = make_reasoning_strategy();
595        let dto = map_reasoning_strategy(s);
596        assert_eq!(dto.id, "strat-uuid-1");
597        assert_eq!(dto.outcome, "success");
598        assert_eq!(dto.summary, "break the problem into parts");
599    }
600
601    #[test]
602    fn reasoning_strategy_maps_failure_outcome() {
603        let mut s = make_reasoning_strategy();
604        s.outcome = Outcome::Failure;
605        let dto = map_reasoning_strategy(s);
606        assert_eq!(dto.outcome, "failure");
607    }
608
609    // ── map_correction ────────────────────────────────────────────────────────
610
611    #[test]
612    fn correction_maps_text() {
613        let row = make_correction_row();
614        let dto = map_correction(row);
615        assert_eq!(dto.correction_text, "use bullet points");
616    }
617
618    // ── map_recalled_message ──────────────────────────────────────────────────
619
620    #[test]
621    fn recalled_message_maps_user_role() {
622        let rm = make_recalled_message(Role::User);
623        let dto = map_recalled_message(rm);
624        assert_eq!(dto.role, "user");
625        assert_eq!(dto.content, "hello world");
626        assert!((dto.score - 0.75).abs() < f32::EPSILON);
627    }
628
629    #[test]
630    fn recalled_message_maps_assistant_role() {
631        let rm = make_recalled_message(Role::Assistant);
632        let dto = map_recalled_message(rm);
633        assert_eq!(dto.role, "assistant");
634        assert!((dto.score - 0.75).abs() < f32::EPSILON);
635    }
636
637    #[test]
638    fn recalled_message_maps_system_role() {
639        let rm = make_recalled_message(Role::System);
640        let dto = map_recalled_message(rm);
641        assert_eq!(dto.role, "system");
642        assert!((dto.score - 0.75).abs() < f32::EPSILON);
643    }
644
645    // ── map_graph_fact ────────────────────────────────────────────────────────
646
647    #[test]
648    fn graph_fact_maps_basic_fields() {
649        let rf = make_recalled_fact();
650        let dto = map_graph_fact(rf);
651        assert_eq!(dto.fact, "Rust uses LLVM");
652        assert!((dto.confidence - 0.95).abs() < f32::EPSILON);
653        assert!(dto.activation_score.is_none());
654        assert!(dto.neighbors.is_empty());
655        assert!(dto.provenance_snippet.is_none());
656    }
657
658    #[test]
659    fn graph_fact_maps_activation_score() {
660        let mut rf = make_recalled_fact();
661        rf.activation_score = Some(0.82);
662        let dto = map_graph_fact(rf);
663        assert!(
664            dto.activation_score
665                .is_some_and(|s| (s - 0.82_f32).abs() < f32::EPSILON)
666        );
667    }
668
669    #[test]
670    fn graph_fact_maps_neighbors() {
671        let mut rf = make_recalled_fact();
672        rf.neighbors.push(GraphFact {
673            entity_name: "LLVM".to_owned(),
674            relation: "supports".to_owned(),
675            target_name: "WebAssembly".to_owned(),
676            fact: "LLVM supports WebAssembly".to_owned(),
677            entity_match_score: 0.5,
678            hop_distance: 1,
679            confidence: 0.8,
680            valid_from: None,
681            edge_type: EdgeType::Semantic,
682            retrieval_count: 0,
683            edge_id: None,
684        });
685        let dto = map_graph_fact(rf);
686        assert_eq!(dto.neighbors.len(), 1);
687        assert_eq!(dto.neighbors[0].fact, "LLVM supports WebAssembly");
688        assert!((dto.neighbors[0].confidence - 0.8).abs() < f32::EPSILON);
689    }
690
691    #[test]
692    fn graph_fact_maps_provenance_snippet() {
693        let mut rf = make_recalled_fact();
694        rf.provenance_snippet = Some("Rust compiler snippet".to_owned());
695        let dto = map_graph_fact(rf);
696        assert_eq!(
697            dto.provenance_snippet.as_deref(),
698            Some("Rust compiler snippet")
699        );
700    }
701
702    // ── map_session_summary ───────────────────────────────────────────────────
703
704    #[test]
705    fn session_summary_maps_fields() {
706        let r = make_session_summary();
707        let dto = map_session_summary(r);
708        assert_eq!(dto.summary_text, "yesterday's session about Rust");
709        assert!((dto.score - 0.88).abs() < f32::EPSILON);
710    }
711
712    #[test]
713    fn session_summary_score_zero() {
714        let r = SessionSummaryResult {
715            summary_text: "empty session".to_owned(),
716            score: 0.0,
717            conversation_id: ConversationId(1),
718        };
719        let dto = map_session_summary(r);
720        assert!(dto.score.abs() < f32::EPSILON);
721    }
722
723    #[test]
724    fn session_summary_score_one() {
725        let r = SessionSummaryResult {
726            summary_text: "perfect match".to_owned(),
727            score: 1.0,
728            conversation_id: ConversationId(1),
729        };
730        let dto = map_session_summary(r);
731        assert!((dto.score - 1.0_f32).abs() < f32::EPSILON);
732    }
733}