Skip to main content

brain_edge/dto/
memory.rs

1//! Memory verbs: encode / recall / forget.
2
3// The DTOs below mirror the HTTP contract one-for-one: the JSON field names are
4// the API, and `tools/http_manifest.py` emits every one of them — with its type
5// and serde attributes — into `contract/http-routes.json`, which the three SDK
6// clients are checked against. A doc comment on each of ~312 fields would
7// restate the field name; the ones that carry meaning beyond their name have
8// one written below.
9#![allow(missing_docs)]
10
11use brain_db_sdk::RecallAnswer;
12use brain_db_sdk::wire::types::{
13    AnswerKindWire, EncodeGraphEdge, EncodeGraphNode, EncodeResponse, EncodeStageArtifact,
14    EncodeStageGraph, EncodeStageKeywordField, EncodeStageRecord, ForgetResponse,
15    MemoryInspectResponse, MemoryListDirWire, MemoryListItem, MemoryListRequest,
16    MemoryListSortWire, MemoryListTimeAxisWire, MemoryResult, WireMemoryId,
17};
18use serde::{Deserialize, Serialize};
19
20use super::{hex_decode, hex_encode, mem_id_decimal, parse_memory_id};
21
22/// `POST /v1/memories` body.
23#[derive(Debug, Deserialize)]
24pub struct EncodeBody {
25    /// The text to remember.
26    pub text: String,
27    /// Optional session id.
28    #[serde(default)]
29    pub session: Option<u64>,
30    /// Optional event time (unix nanos).
31    #[serde(default)]
32    pub occurred_at: Option<u64>,
33}
34
35/// `POST /v1/memories` response.
36#[derive(Debug, Serialize)]
37pub struct EncodeResponseDto {
38    pub memory_id: String,
39    pub was_deduplicated: bool,
40    pub salience: f32,
41    pub kind: u8,
42    pub created_at_unix_nanos: u64,
43    pub auto_edges_added: u32,
44}
45
46impl From<EncodeResponse> for EncodeResponseDto {
47    fn from(r: EncodeResponse) -> Self {
48        Self {
49            memory_id: r.memory_id.to_string(),
50            was_deduplicated: r.was_deduplicated,
51            salience: r.salience,
52            kind: r.kind as u8,
53            created_at_unix_nanos: r.created_at_unix_nanos,
54            auto_edges_added: r.auto_edges_added,
55        }
56    }
57}
58
59/// `POST /v1/recall` body. Accepts `query` or its alias `cue`.
60#[derive(Debug, Deserialize)]
61pub struct RecallBody {
62    #[serde(alias = "cue")]
63    pub query: String,
64    #[serde(default)]
65    pub max_results: Option<u32>,
66    #[serde(default)]
67    pub subject: Option<String>,
68    /// Read scope: `"space"` (default — the caller's own space) or
69    /// `"namespace"` (every space in the caller's namespace, fanned out across
70    /// shards and merged). Case-insensitive; anything else is rejected.
71    #[serde(default)]
72    pub scope: Option<String>,
73}
74
75/// One recalled memory in the response.
76#[derive(Debug, Serialize)]
77pub struct MemoryHit {
78    pub memory_id: String,
79    pub text: String,
80    pub similarity_score: f32,
81    pub confidence: f32,
82    pub salience: f32,
83    pub kind: u8,
84    pub created_at_unix_nanos: u64,
85}
86
87impl From<MemoryResult> for MemoryHit {
88    fn from(m: MemoryResult) -> Self {
89        Self {
90            memory_id: m.memory_id.to_string(),
91            text: m.text,
92            similarity_score: m.similarity_score,
93            confidence: m.confidence,
94            salience: m.salience,
95            kind: m.kind as u8,
96            created_at_unix_nanos: m.created_at_unix_nanos,
97        }
98    }
99}
100
101/// `POST /v1/recall` response.
102#[derive(Debug, Serialize)]
103pub struct RecallResponseDto {
104    /// `single` / `many` / `none`.
105    pub answer_kind: &'static str,
106    pub memories: Vec<MemoryHit>,
107}
108
109impl From<RecallAnswer> for RecallResponseDto {
110    fn from(a: RecallAnswer) -> Self {
111        let answer_kind = match a.answer_kind {
112            AnswerKindWire::Single => "single",
113            AnswerKindWire::Many => "many",
114            AnswerKindWire::None => "none",
115        };
116        Self {
117            answer_kind,
118            memories: a.memories.into_iter().map(MemoryHit::from).collect(),
119        }
120    }
121}
122
123/// `DELETE /v1/memories` body.
124#[derive(Debug, Deserialize)]
125pub struct ForgetBody {
126    /// Decimal string of the 128-bit memory id.
127    pub memory_id: String,
128    #[serde(default)]
129    pub hard: bool,
130}
131
132impl ForgetBody {
133    /// Parse the decimal id string to a wire id.
134    pub fn parse_id(&self) -> Result<WireMemoryId, String> {
135        parse_memory_id(&self.memory_id)
136    }
137}
138
139/// `DELETE /v1/memories` response.
140#[derive(Debug, Serialize)]
141pub struct ForgetResponseDto {
142    pub memory_id: String,
143    pub was_already_forgotten: bool,
144    pub edges_removed: u32,
145}
146
147impl From<ForgetResponse> for ForgetResponseDto {
148    fn from(r: ForgetResponse) -> Self {
149        Self {
150            memory_id: r.memory_id.to_string(),
151            was_already_forgotten: r.was_already_forgotten,
152            edges_removed: r.edges_removed,
153        }
154    }
155}
156
157// ---- memory list (GET /v1/memories) --------------------------------------
158
159/// Default and max page size for `GET /v1/memories`.
160const LIST_DEFAULT_LIMIT: u32 = 50;
161const LIST_MAX_LIMIT: u32 = 100;
162
163/// `GET /v1/memories` query parameters — a non-ranked, paginated enumeration
164/// of the caller's memories. Only the server-backed `created_at` axis is
165/// exposed; ranking/filtering beyond direction + tombstone state is left to
166/// RECALL and future filters.
167#[derive(Debug, Deserialize)]
168pub struct MemoryListQuery {
169    /// Page size, clamped to `1..=100` (default 50).
170    #[serde(default)]
171    pub limit: Option<u32>,
172    /// Opaque keyset cursor (hex) from a previous page's `next_cursor`.
173    #[serde(default)]
174    pub cursor: Option<String>,
175    /// `desc` (default, newest first) or `asc`.
176    #[serde(default)]
177    pub dir: Option<String>,
178    /// Include tombstoned memories (default false).
179    #[serde(default)]
180    pub include_tombstoned: Option<bool>,
181}
182
183impl MemoryListQuery {
184    /// Build the wire request. Fails on a malformed cursor or `dir`.
185    pub fn to_request(&self) -> Result<MemoryListRequest, String> {
186        let limit = self
187            .limit
188            .unwrap_or(LIST_DEFAULT_LIMIT)
189            .clamp(1, LIST_MAX_LIMIT);
190        let dir = match self.dir.as_deref().map(str::trim) {
191            None | Some("" | "desc") => MemoryListDirWire::Desc,
192            Some("asc") => MemoryListDirWire::Asc,
193            Some(other) => return Err(format!("dir must be asc|desc, got `{other}`")),
194        };
195        let cursor = match &self.cursor {
196            Some(c) if !c.is_empty() => hex_decode(c)?,
197            _ => Vec::new(),
198        };
199        Ok(MemoryListRequest {
200            sort: MemoryListSortWire::Created,
201            dir,
202            limit,
203            cursor,
204            kinds: Vec::new(),
205            include_tombstoned: self.include_tombstoned.unwrap_or(false),
206            time_axis: MemoryListTimeAxisWire::Created,
207            from_unix_nanos: 0,
208            to_unix_nanos: 0,
209            salience_min: 0.0,
210            salience_max: 1.0,
211            text_contains: String::new(),
212            act_as: None,
213        })
214    }
215}
216
217/// One memory in a `GET /v1/memories` page. Carries the enumeration fields
218/// plus typed-graph handle counts so a UI row can show link counts without a
219/// second call.
220#[derive(Debug, Serialize)]
221pub struct MemoryListItemDto {
222    /// 128-bit memory id, decimal string.
223    pub memory_id: String,
224    pub text: String,
225    /// 0 = Episodic, 1 = Semantic, 2 = Consolidated.
226    pub kind: u8,
227    /// 0 = active, 1 = tombstoned.
228    pub state: u8,
229    pub created_at_unix_nanos: u64,
230    pub occurred_at_unix_nanos: u64,
231    pub last_accessed_at_unix_nanos: u64,
232    pub salience: f32,
233    pub access_count: u32,
234    pub statement_count: u32,
235    pub entity_count: u32,
236    pub relation_count: u32,
237}
238
239impl From<MemoryListItem> for MemoryListItemDto {
240    fn from(m: MemoryListItem) -> Self {
241        Self {
242            memory_id: mem_id_decimal(&m.memory_id),
243            text: m.text,
244            kind: m.kind,
245            state: m.state,
246            created_at_unix_nanos: m.created_at_unix_nanos,
247            occurred_at_unix_nanos: m.occurred_at_unix_nanos,
248            last_accessed_at_unix_nanos: m.last_accessed_at_unix_nanos,
249            salience: m.salience,
250            access_count: m.access_count,
251            statement_count: m.statement_count,
252            entity_count: m.entity_count,
253            relation_count: m.relation_count,
254        }
255    }
256}
257
258/// `GET /v1/memories` response: a page of items plus the resume cursor.
259/// `next_cursor` is omitted when the enumeration is exhausted.
260#[derive(Debug, Serialize)]
261pub struct MemoryListPageDto {
262    pub items: Vec<MemoryListItemDto>,
263    #[serde(skip_serializing_if = "Option::is_none")]
264    pub next_cursor: Option<String>,
265}
266
267impl MemoryListPageDto {
268    /// Assemble from the streamed frames: flatten items, carry the tail
269    /// frame's cursor (hex, or omitted when empty).
270    pub fn from_frames(frames: Vec<brain_db_sdk::wire::types::MemoryListResponseFrame>) -> Self {
271        let next = frames
272            .last()
273            .map(|f| f.next_cursor.clone())
274            .filter(|c| !c.is_empty())
275            .map(|c| hex_encode(&c));
276        let items = frames
277            .into_iter()
278            .flat_map(|f| f.items)
279            .map(MemoryListItemDto::from)
280            .collect();
281        Self {
282            items,
283            next_cursor: next,
284        }
285    }
286}
287
288// ---- memory inspect (GET /v1/memories/{id}/inspect) ----------------------
289
290/// `GET /v1/memories/{id}/inspect` response — one memory's durable
291/// write-artifact bundle in a friendly, per-stage shape: what each write
292/// stage produced (embedding vector, stored record, analyzed keyword terms,
293/// write-time HyPE questions, extracted knowledge graph) plus the memory text.
294/// `found = false` (with an empty `artifact`) when the id doesn't exist under
295/// the caller's scope.
296#[derive(Debug, Serialize)]
297pub struct MemoryInspectDto {
298    pub found: bool,
299    /// Decimal string of the 128-bit memory id (matches `/v1/memories`).
300    pub memory_id: String,
301    pub text: String,
302    pub artifact: StageArtifactDto,
303}
304
305impl From<MemoryInspectResponse> for MemoryInspectDto {
306    fn from(r: MemoryInspectResponse) -> Self {
307        Self {
308            found: r.found,
309            memory_id: mem_id_decimal(&r.memory_id),
310            text: r.text,
311            artifact: StageArtifactDto::from(r.artifact),
312        }
313    }
314}
315
316/// The per-stage output bag. Every field is optional; a stage that produced
317/// nothing leaves its field empty/null.
318#[derive(Debug, Serialize)]
319pub struct StageArtifactDto {
320    /// The embedding vector the `embed` stage produced.
321    pub vector: Vec<f32>,
322    /// The metadata row the `persist` stage committed.
323    pub record: Option<StageRecordDto>,
324    /// Hypothetical questions the write-time HyPE step generated.
325    pub hype_questions: Vec<String>,
326    /// Analyzed keyword terms per text-index field.
327    pub keyword_fields: Vec<StageKeywordFieldDto>,
328    /// The knowledge graph the extractor produced.
329    pub graph: Option<StageGraphDto>,
330}
331
332impl From<EncodeStageArtifact> for StageArtifactDto {
333    fn from(a: EncodeStageArtifact) -> Self {
334        Self {
335            vector: a.vector,
336            record: a.record.map(StageRecordDto::from),
337            hype_questions: a.hype_questions,
338            keyword_fields: a
339                .keyword_fields
340                .into_iter()
341                .map(StageKeywordFieldDto::from)
342                .collect(),
343            graph: a.graph.map(StageGraphDto::from),
344        }
345    }
346}
347
348/// The stored metadata row a `persist` stage wrote.
349#[derive(Debug, Serialize)]
350pub struct StageRecordDto {
351    pub memory_id: String,
352    pub kind: u8,
353    pub salience: f32,
354    pub created_at_unix_nanos: u64,
355    pub occurred_at_unix_nanos: u64,
356    pub vector_dim: u32,
357    pub text_len: u32,
358    pub lsn: u64,
359}
360
361impl From<EncodeStageRecord> for StageRecordDto {
362    fn from(r: EncodeStageRecord) -> Self {
363        Self {
364            memory_id: mem_id_decimal(&r.memory_id),
365            kind: r.kind,
366            salience: r.salience,
367            created_at_unix_nanos: r.created_at_unix_nanos,
368            occurred_at_unix_nanos: r.occurred_at_unix_nanos,
369            vector_dim: r.vector_dim,
370            text_len: r.text_len,
371            lsn: r.lsn,
372        }
373    }
374}
375
376/// One text-index field and the analyzed terms it will match on.
377#[derive(Debug, Serialize)]
378pub struct StageKeywordFieldDto {
379    pub field: String,
380    pub terms: Vec<String>,
381}
382
383impl From<EncodeStageKeywordField> for StageKeywordFieldDto {
384    fn from(k: EncodeStageKeywordField) -> Self {
385        Self {
386            field: k.field,
387            terms: k.terms,
388        }
389    }
390}
391
392/// The knowledge graph an ENCODE produced — nodes + directed edges.
393#[derive(Debug, Serialize)]
394pub struct StageGraphDto {
395    pub nodes: Vec<StageGraphNodeDto>,
396    pub edges: Vec<StageGraphEdgeDto>,
397}
398
399impl From<EncodeStageGraph> for StageGraphDto {
400    fn from(g: EncodeStageGraph) -> Self {
401        Self {
402            nodes: g.nodes.into_iter().map(StageGraphNodeDto::from).collect(),
403            edges: g.edges.into_iter().map(StageGraphEdgeDto::from).collect(),
404        }
405    }
406}
407
408#[derive(Debug, Serialize)]
409pub struct StageGraphNodeDto {
410    /// Hex of the 16-byte node id.
411    pub id: String,
412    pub name: String,
413    pub kind: String,
414    pub type_qname: String,
415}
416
417impl From<EncodeGraphNode> for StageGraphNodeDto {
418    fn from(n: EncodeGraphNode) -> Self {
419        Self {
420            id: hex_encode(&n.id),
421            name: n.name,
422            kind: n.kind,
423            type_qname: n.type_qname,
424        }
425    }
426}
427
428#[derive(Debug, Serialize)]
429pub struct StageGraphEdgeDto {
430    /// Hex of the 16-byte source node id.
431    pub source: String,
432    /// Hex of the 16-byte target node id.
433    pub target: String,
434    pub predicate: String,
435    pub kind: String,
436    pub confidence: f32,
437    /// When the EVENT this edge records happened, in unix nanos —
438    /// denormalised from the backing statement. Omitted for an undated
439    /// statement and for every non-statement edge kind.
440    #[serde(skip_serializing_if = "Option::is_none")]
441    pub event_at_unix_nanos: Option<u64>,
442}
443
444impl From<EncodeGraphEdge> for StageGraphEdgeDto {
445    fn from(e: EncodeGraphEdge) -> Self {
446        Self {
447            source: hex_encode(&e.source),
448            target: hex_encode(&e.target),
449            predicate: e.predicate,
450            kind: e.kind,
451            confidence: e.confidence,
452            event_at_unix_nanos: e.event_at_unix_nanos,
453        }
454    }
455}
456
457#[cfg(test)]
458mod memory_tests {
459    use super::*;
460    use brain_db_sdk::RecallAnswer;
461    use brain_db_sdk::wire::types::{
462        EncodeGraphEdge, EncodeGraphNode, EncodeResponse, EncodeStageArtifact, EncodeStageGraph,
463        EncodeStageKeywordField, EncodeStageRecord, ForgetResponse, MemoryInspectResponse,
464        MemoryKindWire, MemoryListItem, MemoryListResponseFrame, MemoryResult,
465    };
466
467    fn sample_result(tag: u8, text: &str) -> MemoryResult {
468        MemoryResult {
469            memory_id: u128::from(tag),
470            text: text.into(),
471            similarity_score: 0.9,
472            confidence: 0.8,
473            salience: 0.5,
474            kind: MemoryKindWire::Semantic,
475            space_id: [tag; 16],
476            session_id: 0,
477            created_at_unix_nanos: 1,
478            last_accessed_at_unix_nanos: 1,
479            edges: None,
480            contributing_retrievers: vec![],
481            fused_score: 0.7,
482            rerank_score: None,
483            salience_initial: 0.5,
484            access_count: 0,
485            lsn: 1,
486            flags: 0,
487            consolidated_at_unix_nanos: None,
488            occurred_at_unix_nanos: None,
489            edges_out_count: 0,
490            edges_in_count: 0,
491            graph: None,
492        }
493    }
494
495    #[test]
496    fn list_query_clamps_limit_defaults_dir_and_rejects_bad_dir() {
497        // Over-max clamps; asc/desc parse; malformed dir is an error.
498        let over = MemoryListQuery {
499            limit: Some(9999),
500            cursor: None,
501            dir: None,
502            include_tombstoned: None,
503        };
504        let req = over.to_request().unwrap();
505        assert_eq!(req.limit, LIST_MAX_LIMIT);
506        assert!(matches!(req.dir, MemoryListDirWire::Desc)); // default
507        assert_eq!(req.cursor, [] as [u8; 0]);
508        assert!(!req.include_tombstoned);
509
510        let asc = MemoryListQuery {
511            limit: None,
512            cursor: Some("2a00ff".into()),
513            dir: Some(" asc ".into()),
514            include_tombstoned: Some(true),
515        }
516        .to_request()
517        .unwrap();
518        assert_eq!(asc.limit, LIST_DEFAULT_LIMIT);
519        assert!(matches!(asc.dir, MemoryListDirWire::Asc));
520        assert_eq!(asc.cursor, vec![0x2a, 0x00, 0xff]); // hex-decoded
521        assert!(asc.include_tombstoned);
522
523        assert!(
524            MemoryListQuery {
525                limit: None,
526                cursor: None,
527                dir: Some("sideways".into()),
528                include_tombstoned: None,
529            }
530            .to_request()
531            .is_err()
532        );
533        // A malformed cursor is rejected too.
534        assert!(
535            MemoryListQuery {
536                limit: None,
537                cursor: Some("zz".into()),
538                dir: None,
539                include_tombstoned: None,
540            }
541            .to_request()
542            .is_err()
543        );
544    }
545
546    #[test]
547    fn forget_body_parses_decimal_id_and_rejects_junk() {
548        let ok = ForgetBody {
549            memory_id: "42".into(),
550            hard: true,
551        };
552        assert_eq!(ok.parse_id().unwrap(), 42u128);
553        assert!(
554            ForgetBody {
555                memory_id: "not-a-number".into(),
556                hard: false,
557            }
558            .parse_id()
559            .is_err()
560        );
561    }
562
563    #[test]
564    fn encode_response_maps_every_field_and_kind_byte() {
565        let resp = EncodeResponse {
566            memory_id: 7,
567            was_deduplicated: true,
568            salience: 0.42,
569            auto_edges_added: 3,
570            lsn: 99,
571            space_id: [0xAB; 16],
572            session_id: 5,
573            kind: MemoryKindWire::Consolidated,
574            created_at_unix_nanos: 1_700_000_000,
575            edges_out_count: 0,
576            embedding_model_fp: [0; 16],
577            pending_stages: vec![],
578            has_active_schema: true,
579            trace: None,
580        };
581        let dto = EncodeResponseDto::from(resp);
582        assert_eq!(dto.memory_id, "7"); // 128-bit id -> decimal string
583        assert!(dto.was_deduplicated);
584        assert_eq!(dto.salience.to_bits(), 0.42f32.to_bits());
585        assert_eq!(dto.kind, 2); // Consolidated
586        assert_eq!(dto.created_at_unix_nanos, 1_700_000_000);
587        assert_eq!(dto.auto_edges_added, 3);
588    }
589
590    #[test]
591    fn recall_answer_kind_maps_single_many_none() {
592        let cases = [
593            (AnswerKindWire::Single, "single"),
594            (AnswerKindWire::Many, "many"),
595            (AnswerKindWire::None, "none"),
596        ];
597        for (kind, expected) in cases {
598            let answer = RecallAnswer {
599                answer_kind: kind,
600                memories: vec![sample_result(0xAA, "hit")],
601            };
602            let dto = RecallResponseDto::from(answer);
603            assert_eq!(dto.answer_kind, expected);
604            assert_eq!(dto.memories.len(), 1);
605            assert_eq!(dto.memories[0].memory_id, "170"); // 0xAA decimal
606            assert_eq!(dto.memories[0].text, "hit");
607            assert_eq!(dto.memories[0].kind, 1); // Semantic
608        }
609    }
610
611    #[test]
612    fn forget_response_maps_fields() {
613        let dto = ForgetResponseDto::from(ForgetResponse {
614            memory_id: 42,
615            was_already_forgotten: true,
616            edges_removed: 4,
617        });
618        assert_eq!(dto.memory_id, "42");
619        assert!(dto.was_already_forgotten);
620        assert_eq!(dto.edges_removed, 4);
621    }
622
623    fn sample_list_item(tag: u8) -> MemoryListItem {
624        MemoryListItem {
625            memory_id: [tag; 16],
626            space_id: [0; 16],
627            session_id: 0,
628            text: "row".into(),
629            kind: 1,
630            state: 0,
631            created_at_unix_nanos: 10,
632            occurred_at_unix_nanos: 0,
633            last_accessed_at_unix_nanos: 11,
634            salience: 0.5,
635            access_count: 2,
636            source_request_id: [0; 16],
637            statement_count: 1,
638            entity_count: 2,
639            relation_count: 3,
640        }
641    }
642
643    #[test]
644    fn list_page_flattens_frames_and_hex_encodes_tail_cursor() {
645        // Two frames; the tail carries a resume cursor -> surfaced as hex.
646        let frames = vec![
647            MemoryListResponseFrame {
648                items: vec![sample_list_item(1)],
649                next_cursor: Vec::new(),
650                cumulative_count: 1,
651                is_final: false,
652            },
653            MemoryListResponseFrame {
654                items: vec![sample_list_item(2)],
655                next_cursor: vec![0x2a, 0xff],
656                cumulative_count: 2,
657                is_final: true,
658            },
659        ];
660        let dto = MemoryListPageDto::from_frames(frames);
661        assert_eq!(dto.items.len(), 2);
662        assert_eq!(dto.items[0].statement_count, 1);
663        assert_eq!(dto.items[1].relation_count, 3);
664        assert_eq!(dto.next_cursor.as_deref(), Some("2aff"));
665
666        // An empty tail cursor means "exhausted" -> omitted.
667        let done = MemoryListPageDto::from_frames(vec![MemoryListResponseFrame {
668            items: vec![sample_list_item(3)],
669            next_cursor: Vec::new(),
670            cumulative_count: 1,
671            is_final: true,
672        }]);
673        assert!(done.next_cursor.is_none());
674    }
675
676    #[test]
677    fn inspect_maps_stages_and_hex_encodes_graph_ids() {
678        let resp = MemoryInspectResponse {
679            found: true,
680            memory_id: [0; 16],
681            text: "hello".into(),
682            artifact: EncodeStageArtifact {
683                vector: vec![0.1, 0.2],
684                record: Some(EncodeStageRecord {
685                    memory_id: [0; 16],
686                    kind: 1,
687                    salience: 0.5,
688                    created_at_unix_nanos: 1,
689                    occurred_at_unix_nanos: 0,
690                    vector_dim: 384,
691                    text_len: 5,
692                    lsn: 7,
693                }),
694                hype_questions: vec!["who?".into()],
695                keyword_fields: vec![EncodeStageKeywordField {
696                    field: "text".into(),
697                    terms: vec!["hello".into()],
698                }],
699                graph: Some(EncodeStageGraph {
700                    nodes: vec![EncodeGraphNode {
701                        id: [0x11; 16],
702                        name: "Ada".into(),
703                        kind: "entity".into(),
704                        type_qname: "brain:Person".into(),
705                    }],
706                    edges: vec![EncodeGraphEdge {
707                        source: [0x11; 16],
708                        target: [0x22; 16],
709                        predicate: "born_in".into(),
710                        kind: "statement".into(),
711                        confidence: 0.9,
712                        event_at_unix_nanos: Some(123),
713                    }],
714                }),
715            },
716        };
717        let dto = MemoryInspectDto::from(resp);
718        assert!(dto.found);
719        assert_eq!(dto.text, "hello");
720        assert_eq!(dto.artifact.vector, vec![0.1, 0.2]);
721        assert_eq!(dto.artifact.record.as_ref().unwrap().vector_dim, 384);
722        assert_eq!(dto.artifact.hype_questions, vec!["who?".to_string()]);
723        let graph = dto.artifact.graph.unwrap();
724        assert_eq!(graph.nodes[0].id, "11".repeat(16)); // 16-byte id, lowercase hex
725        assert_eq!(graph.edges[0].source, "11".repeat(16));
726        assert_eq!(graph.edges[0].target, "22".repeat(16));
727        assert_eq!(graph.edges[0].event_at_unix_nanos, Some(123));
728    }
729}