1use std::collections::{BTreeMap, BTreeSet};
2
3use kmp_domain::{MemoryDimensionIdentity, MemoryRelationType, RelationSemanticClass, SourceKind};
4
5use crate::ApplicationError;
6use crate::commands::{UpdateContextChange, UpdateContextCommand};
7use crate::memory::{
8 MemoryAcceptedCounts, MemoryCoordinateData, MemoryData, MemoryDimensionData,
9 MemoryIngestCommand, MemoryIngestOutcome,
10};
11
12#[derive(Debug, Clone, Default, PartialEq, Eq)]
13pub struct ExistingMemoryRefs {
14 pub refs: BTreeSet<String>,
15 pub dimensions: BTreeSet<String>,
16}
17
18pub fn translate_memory_ingest(
19 command: &MemoryIngestCommand,
20 existing: &ExistingMemoryRefs,
21) -> Result<(UpdateContextCommand, MemoryIngestOutcome), ApplicationError> {
22 validate_command(command)?;
23 let memory = namespaced_memory(&command.about, &command.memory, existing)?;
24
25 let changes = memory_changes(&memory)?;
26 let outcome = MemoryIngestOutcome {
27 about: command.about.clone(),
28 memory_id: memory_id_from_idempotency_key(&command.idempotency_key),
29 accepted: MemoryAcceptedCounts {
30 entries: command.memory.entries.len(),
31 relations: command.memory.relations.len(),
32 evidence: command.memory.evidence.len(),
33 },
34 read_after_write_ready: false,
35 warnings: Vec::new(),
36 };
37
38 Ok((
39 UpdateContextCommand {
40 root_node_id: command.about.clone(),
41 role: "memory".to_string(),
42 work_item_id: command.idempotency_key.clone(),
43 changes,
44 expected_revision: None,
45 expected_content_hash: None,
46 idempotency_key: Some(command.idempotency_key.clone()),
47 logical_digest: Some(logical_digest(command)),
48 requested_by: command
49 .provenance
50 .as_ref()
51 .map(|provenance| provenance.source_agent.clone()),
52 },
53 outcome,
54 ))
55}
56
57fn validate_command(command: &MemoryIngestCommand) -> Result<(), ApplicationError> {
58 require_non_empty(&command.about, "about")?;
59 require_non_empty(&command.idempotency_key, "idempotency_key")?;
60 if let Some(provenance) = command.provenance.as_ref() {
61 SourceKind::parse(&provenance.source_kind).map_err(|error| {
62 ApplicationError::Validation(format!(
63 "memory provenance source_kind is invalid: {error}"
64 ))
65 })?;
66 require_non_empty(&provenance.source_agent, "provenance.source_agent")?;
67 require_non_empty(&provenance.observed_at, "provenance.observed_at")?;
68 }
69
70 Ok(())
71}
72
73fn namespaced_memory(
74 about: &str,
75 memory: &MemoryData,
76 existing: &ExistingMemoryRefs,
77) -> Result<MemoryData, ApplicationError> {
78 if memory.dimensions.is_empty() && existing.dimensions.is_empty() {
79 return Err(ApplicationError::Validation(
80 "memory.dimensions must not be empty when no existing memory dimensions are available"
81 .to_string(),
82 ));
83 }
84 if memory.entries.is_empty() {
85 return Err(ApplicationError::Validation(
86 "memory.entries must not be empty".to_string(),
87 ));
88 }
89
90 let mut known_refs = existing.refs.clone();
91 known_refs.extend(existing.dimensions.iter().cloned());
92 known_refs.insert(about.to_string());
100 let mut dimension_ids = existing.dimensions.clone();
101 let mut dimension_aliases = existing_dimension_aliases(about, existing);
102 let mut declared_dimension_kinds = BTreeMap::new();
103 let mut declared_dimension_refs = BTreeSet::new();
104 let mut dimensions = Vec::new();
105 for dimension in &memory.dimensions {
106 require_non_empty(&dimension.id, "memory.dimensions[].id")?;
107 require_non_empty(&dimension.kind, "memory.dimensions[].kind")?;
108 let dimension_identity = dimension_identity(about, &dimension.id)?;
109 let dimension_ref = dimension_identity.node_id();
110 declared_dimension_kinds.insert(dimension_ref.clone(), dimension.kind.clone());
111 insert_unique(
112 &mut declared_dimension_refs,
113 &dimension_ref,
114 "memory dimension",
115 )?;
116 if existing.dimensions.contains(&dimension_ref) {
117 dimension_aliases
118 .entry(dimension.id.clone())
119 .or_insert_with(|| dimension_ref.clone());
120 known_refs.insert(dimension_ref);
121 continue;
122 }
123 insert_unique(&mut dimension_ids, &dimension_ref, "memory dimension")?;
124 if dimension_aliases
125 .insert(dimension.id.clone(), dimension_ref.clone())
126 .is_some()
127 {
128 return Err(ApplicationError::Validation(format!(
129 "duplicate memory dimension `{}`",
130 dimension.id
131 )));
132 }
133 known_refs.insert(dimension_ref.clone());
134
135 let mut metadata = dimension.metadata.clone();
136 metadata
137 .entry("memory_about".to_string())
138 .or_insert_with(|| about.to_string());
139 metadata
140 .entry("memory_dimension_id".to_string())
141 .or_insert_with(|| dimension.id.clone());
142 dimensions.push(MemoryDimensionData {
143 id: dimension_ref,
144 kind: dimension.kind.clone(),
145 title: dimension.title.clone(),
146 metadata,
147 });
148 }
149
150 let mut entry_ids = BTreeSet::new();
151 let mut entries = Vec::new();
152 for entry in &memory.entries {
153 require_non_empty(&entry.id, "memory.entries[].id")?;
154 require_non_empty(&entry.kind, "memory.entries[].kind")?;
155 require_non_empty(&entry.text, "memory.entries[].text")?;
156 if entry.coordinates.is_empty() {
157 return Err(ApplicationError::Validation(format!(
158 "memory entry `{}` must include at least one coordinate",
159 entry.id
160 )));
161 }
162 insert_unique(&mut entry_ids, &entry.id, "memory entry")?;
163 known_refs.insert(entry.id.clone());
164
165 let mut coordinates = Vec::new();
166 for coordinate in &entry.coordinates {
167 coordinates.push(normalize_coordinate(
168 coordinate,
169 "memory.entries[].coordinates[]",
170 "memory entry",
171 &dimension_aliases,
172 &dimension_ids,
173 &declared_dimension_kinds,
174 )?);
175 }
176 let mut entry = entry.clone();
177 entry.coordinates = coordinates;
178 entries.push(entry);
179 }
180
181 let mut relations = Vec::new();
182 for relation in &memory.relations {
183 require_non_empty(&relation.source_ref, "memory.relations[].source_ref")?;
184 require_non_empty(&relation.target_ref, "memory.relations[].target_ref")?;
185 require_non_empty(&relation.rel, "memory.relations[].rel")?;
186 let relation_type = MemoryRelationType::new(&relation.rel).map_err(|error| {
187 ApplicationError::Validation(format!("memory relation type is invalid: {error}"))
188 })?;
189 let semantic_class =
190 RelationSemanticClass::parse(&relation.semantic_class).map_err(|error| {
191 ApplicationError::Validation(format!("memory relation class is invalid: {error}"))
192 })?;
193 let source_ref = normalize_ref(&relation.source_ref, &dimension_aliases);
194 let target_ref = normalize_ref(&relation.target_ref, &dimension_aliases);
195 if !known_refs.contains(&source_ref) || !known_refs.contains(&target_ref) {
196 return Err(ApplicationError::Validation(format!(
197 "memory relation `{}` -> `{}` references unknown refs",
198 relation.source_ref, relation.target_ref
199 )));
200 }
201 if semantic_class != RelationSemanticClass::Structural {
202 if relation
203 .confidence
204 .as_deref()
205 .unwrap_or("")
206 .trim()
207 .is_empty()
208 {
209 return Err(ApplicationError::Validation(
210 "non-structural memory relations require confidence".to_string(),
211 ));
212 }
213 if relation.why.as_deref().unwrap_or("").trim().is_empty()
214 && relation.evidence.as_deref().unwrap_or("").trim().is_empty()
215 {
216 return Err(ApplicationError::Validation(
217 "non-structural memory relations require why or evidence".to_string(),
218 ));
219 }
220 }
221 validate_positive_optional(relation.sequence, "memory.relations[].sequence")?;
222 let coordinate = relation
223 .coordinate
224 .as_ref()
225 .map(|coordinate| {
226 normalize_coordinate(
227 coordinate,
228 "memory.relations[].coordinate",
229 "memory relation",
230 &dimension_aliases,
231 &dimension_ids,
232 &declared_dimension_kinds,
233 )
234 })
235 .transpose()?;
236 let mut relation = relation.clone();
237 relation.source_ref = source_ref;
238 relation.target_ref = target_ref;
239 relation.rel = relation_type.as_str().to_string();
240 relation.coordinate = coordinate;
241 relations.push(relation);
242 }
243
244 let mut evidence_ids = BTreeSet::new();
245 let mut evidence_items = Vec::new();
246 for evidence in &memory.evidence {
247 require_non_empty(&evidence.id, "memory.evidence[].id")?;
248 require_non_empty(&evidence.text, "memory.evidence[].text")?;
249 insert_unique(&mut evidence_ids, &evidence.id, "memory evidence")?;
250 known_refs.insert(evidence.id.clone());
251 let mut supports = Vec::new();
252 for supported in &evidence.supports {
253 require_non_empty(supported, "memory.evidence[].supports[]")?;
254 let supported_ref = normalize_ref(supported, &dimension_aliases);
255 if !known_refs.contains(&supported_ref) {
256 return Err(ApplicationError::Validation(format!(
257 "memory evidence `{}` supports unknown ref `{supported}`",
258 evidence.id
259 )));
260 }
261 supports.push(supported_ref);
262 }
263 let mut evidence = evidence.clone();
264 evidence.supports = supports;
265 evidence_items.push(evidence);
266 }
267
268 Ok(MemoryData {
269 dimensions,
270 entries,
271 relations,
272 evidence: evidence_items,
273 })
274}
275
276fn existing_dimension_aliases(
277 about: &str,
278 existing: &ExistingMemoryRefs,
279) -> BTreeMap<String, String> {
280 existing
281 .dimensions
282 .iter()
283 .filter_map(|dimension_ref| {
284 let identity = MemoryDimensionIdentity::parse(dimension_ref)?;
285 (identity.about() == about)
286 .then(|| (identity.dimension_id().to_string(), dimension_ref.clone()))
287 })
288 .collect()
289}
290
291fn dimension_identity(
292 about: &str,
293 dimension_id: &str,
294) -> Result<MemoryDimensionIdentity, ApplicationError> {
295 MemoryDimensionIdentity::new(about, dimension_id)
296 .map_err(|error| ApplicationError::Validation(error.to_string()))
297}
298
299fn normalize_ref(value: &str, dimension_aliases: &BTreeMap<String, String>) -> String {
300 dimension_aliases
301 .get(value)
302 .cloned()
303 .unwrap_or_else(|| value.to_string())
304}
305
306fn normalize_coordinate(
307 coordinate: &MemoryCoordinateData,
308 field: &str,
309 label: &str,
310 dimension_aliases: &BTreeMap<String, String>,
311 dimension_ids: &BTreeSet<String>,
312 declared_dimension_kinds: &BTreeMap<String, String>,
313) -> Result<MemoryCoordinateData, ApplicationError> {
314 require_non_empty(&coordinate.dimension, &format!("{field}.dimension"))?;
315 require_non_empty(&coordinate.scope_id, &format!("{field}.scope_id"))?;
316 let scope_id = normalize_ref(&coordinate.scope_id, dimension_aliases);
317 if !dimension_ids.contains(&scope_id) {
318 return Err(ApplicationError::Validation(format!(
319 "{label} coordinate references unknown dimension scope `{}`",
320 coordinate.scope_id
321 )));
322 }
323 if let Some(expected_kind) = declared_dimension_kinds.get(&scope_id)
324 && coordinate.dimension != *expected_kind
325 {
326 return Err(ApplicationError::Validation(format!(
327 "{label} coordinate dimension `{}` does not match declared kind `{expected_kind}` for scope `{}`",
328 coordinate.dimension, coordinate.scope_id
329 )));
330 }
331 validate_positive_optional(coordinate.sequence, &format!("{field}.sequence"))?;
332 validate_positive_optional(coordinate.rank, &format!("{field}.rank"))?;
333
334 let mut coordinate = coordinate.clone();
335 coordinate.scope_id = scope_id;
336 Ok(coordinate)
337}
338
339fn memory_changes(memory: &MemoryData) -> Result<Vec<UpdateContextChange>, ApplicationError> {
340 let mut changes = Vec::new();
341 for dimension in &memory.dimensions {
342 changes.push(change(
343 "memory_dimension",
344 &dimension.id,
345 serde_json::to_string(dimension),
346 "KMP memory dimension ingest",
347 vec![dimension.id.clone()],
348 )?);
349 }
350 for entry in &memory.entries {
351 let scopes = entry
352 .coordinates
353 .iter()
354 .map(|coordinate| coordinate.scope_id.clone())
355 .collect();
356 changes.push(change(
357 "memory_entry",
358 &entry.id,
359 serde_json::to_string(entry),
360 "KMP memory entry ingest",
361 scopes,
362 )?);
363 }
364 for relation in &memory.relations {
365 changes.push(change(
366 "memory_relation",
367 &format!(
368 "relation:{}:{}:{}",
369 relation.source_ref, relation.rel, relation.target_ref
370 ),
371 serde_json::to_string(relation),
372 relation
373 .why
374 .as_deref()
375 .filter(|value| !value.trim().is_empty())
376 .unwrap_or("KMP memory relation ingest"),
377 vec![relation.source_ref.clone(), relation.target_ref.clone()],
378 )?);
379 }
380 for evidence in &memory.evidence {
381 changes.push(change(
382 "memory_evidence",
383 &evidence.id,
384 serde_json::to_string(evidence),
385 evidence
386 .source
387 .as_deref()
388 .filter(|value| !value.trim().is_empty())
389 .unwrap_or("KMP memory evidence ingest"),
390 evidence.supports.clone(),
391 )?);
392 }
393
394 Ok(changes)
395}
396
397fn change(
398 entity_kind: &str,
399 entity_id: &str,
400 payload: Result<String, serde_json::Error>,
401 reason: &str,
402 scopes: Vec<String>,
403) -> Result<UpdateContextChange, ApplicationError> {
404 Ok(UpdateContextChange {
405 operation: "UPSERT".to_string(),
406 entity_kind: entity_kind.to_string(),
407 entity_id: entity_id.to_string(),
408 payload_json: payload.map_err(|error| {
409 ApplicationError::Validation(format!("memory payload could not serialize: {error}"))
410 })?,
411 reason: reason.to_string(),
412 scopes,
413 })
414}
415
416fn require_non_empty(value: &str, field: &str) -> Result<(), ApplicationError> {
417 if value.trim().is_empty() {
418 Err(ApplicationError::Validation(format!(
419 "{field} cannot be empty"
420 )))
421 } else {
422 Ok(())
423 }
424}
425
426fn insert_unique(
427 values: &mut BTreeSet<String>,
428 value: &str,
429 label: &str,
430) -> Result<(), ApplicationError> {
431 if !values.insert(value.to_string()) {
432 Err(ApplicationError::Validation(format!(
433 "duplicate {label} `{value}`"
434 )))
435 } else {
436 Ok(())
437 }
438}
439
440fn validate_positive_optional(value: Option<u32>, field: &str) -> Result<(), ApplicationError> {
441 if value == Some(0) {
442 Err(ApplicationError::Validation(format!(
443 "{field} must be greater than zero when set"
444 )))
445 } else {
446 Ok(())
447 }
448}
449
450fn logical_digest(command: &MemoryIngestCommand) -> String {
457 use sha2::{Digest, Sha256};
458 let mut hasher = Sha256::new();
459 hasher.update(command.about.as_bytes());
460 hasher.update([0]);
461 let memory = serde_json::to_vec(&command.memory)
462 .expect("memory data serializes: it holds only strings, maps and integers");
463 hasher.update(&memory);
464 hasher.update([0]);
465 if let Some(provenance) = &command.provenance {
466 let provenance =
467 serde_json::to_vec(provenance).expect("provenance serializes: it holds only strings");
468 hasher.update(&provenance);
469 }
470 format!("{:x}", hasher.finalize())
471}
472
473fn memory_id_from_idempotency_key(idempotency_key: &str) -> String {
474 idempotency_key
475 .strip_prefix("ingest:")
476 .map(|suffix| format!("memory:{suffix}"))
477 .unwrap_or_else(|| format!("memory:{idempotency_key}"))
478}
479
480#[cfg(test)]
481mod tests {
482 use std::collections::BTreeSet;
483
484 use crate::ApplicationError;
485 use crate::memory::{
486 ExistingMemoryRefs, MemoryCoordinateData, MemoryData, MemoryDimensionData, MemoryEntryData,
487 MemoryEvidenceData, MemoryIngestCommand, MemoryRelationData,
488 };
489
490 use super::translate_memory_ingest;
491
492 #[test]
493 fn translate_memory_ingest_creates_internal_memory_update_command() {
494 let command = sample_command();
495
496 let (update, outcome) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
497 .expect("valid memory should translate");
498
499 assert_eq!(update.root_node_id, "question:830ce83f");
500 assert_eq!(update.role, "memory");
501 assert_eq!(update.idempotency_key.as_deref(), Some("ingest:app-test"));
502 assert_eq!(outcome.memory_id, "memory:app-test");
503 assert_eq!(outcome.accepted.entries, 1);
504 assert_eq!(outcome.accepted.relations, 1);
505 assert_eq!(outcome.accepted.evidence, 1);
506 assert_eq!(
507 update
508 .changes
509 .iter()
510 .map(|change| change.entity_kind.as_str())
511 .collect::<Vec<_>>(),
512 vec![
513 "memory_dimension",
514 "memory_entry",
515 "memory_relation",
516 "memory_evidence"
517 ]
518 );
519 assert_eq!(
520 update.changes[0].entity_id,
521 "about:question:830ce83f:dimension:conversation:rachel-2026-04-12"
522 );
523 assert_eq!(
524 update.changes[1].scopes,
525 ["about:question:830ce83f:dimension:conversation:rachel-2026-04-12"]
526 );
527 assert_eq!(
528 update.changes[2].entity_id,
529 "relation:about:question:830ce83f:dimension:conversation:rachel-2026-04-12:contains_entry:claim:rachel-denver"
530 );
531 let entry_payload: serde_json::Value =
532 serde_json::from_str(&update.changes[1].payload_json).expect("entry payload json");
533 assert_eq!(
534 entry_payload["coordinates"][0]["scope_id"],
535 "about:question:830ce83f:dimension:conversation:rachel-2026-04-12"
536 );
537 }
538
539 #[test]
540 fn translate_memory_ingest_fails_fast_for_unknown_coordinate_dimension() {
541 let mut command = sample_command();
542 command.memory.entries[0].coordinates[0].scope_id = "conversation:missing".to_string();
543
544 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
545 .expect_err("unknown scope should fail");
546
547 assert_validation_contains(error, "unknown dimension scope");
548 }
549
550 #[test]
551 fn translate_memory_ingest_rejects_coordinate_kind_mismatch() {
552 let mut command = sample_command();
553 command.memory.entries[0].coordinates[0].dimension = "ceremony".to_string();
554
555 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
556 .expect_err("coordinate kind mismatch should fail");
557
558 assert_validation_contains(error, "does not match declared kind `conversation`");
559 }
560
561 #[test]
562 fn translate_memory_ingest_rejects_relation_coordinate_kind_mismatch() {
563 let mut command = sample_command();
564 let mut coordinate = command.memory.entries[0].coordinates[0].clone();
565 coordinate.dimension = "ceremony".to_string();
566 command.memory.relations[0].coordinate = Some(coordinate);
567
568 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
569 .expect_err("relation coordinate kind mismatch should fail");
570
571 assert_validation_contains(error, "does not match declared kind `conversation`");
572 }
573
574 #[test]
575 fn translate_memory_ingest_fails_fast_for_unknown_relation_endpoint() {
576 let mut command = sample_command();
577 command.memory.relations[0].target_ref = "claim:missing".to_string();
578
579 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
580 .expect_err("unknown ref should fail");
581
582 assert_validation_contains(error, "references unknown refs");
583 }
584
585 #[test]
594 fn translate_memory_ingest_accepts_a_relation_to_the_abouts_own_anchor() {
595 let mut command = sample_command();
596 command.memory.relations[0].target_ref = command.about.clone();
597
598 let (update, _) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
599 .expect("an entry may relate to the about it belongs to");
600
601 assert!(
602 update
603 .changes
604 .iter()
605 .any(|change| change.entity_id.ends_with(&command.about)),
606 "the relation to the anchor must survive translation, got {:?}",
607 update
608 .changes
609 .iter()
610 .map(|change| change.entity_id.as_str())
611 .collect::<Vec<_>>()
612 );
613 }
614
615 #[test]
616 fn translate_memory_ingest_canonicalizes_known_relation_types() {
617 let mut command = sample_command();
618 command.memory.relations[0].rel = " CONTAINS-ENTRY ".to_string();
619
620 let (update, _) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
621 .expect("known relation aliases should canonicalize");
622
623 assert_eq!(
624 update.changes[2].entity_id,
625 "relation:about:question:830ce83f:dimension:conversation:rachel-2026-04-12:contains_entry:claim:rachel-denver"
626 );
627 }
628
629 #[test]
630 fn translate_memory_ingest_requires_non_structural_relation_proof() {
631 let mut command = sample_command();
632 command.memory.relations[0].semantic_class = "causal".to_string();
633 command.memory.relations[0].why = None;
634 command.memory.relations[0].evidence = None;
635 command.memory.relations[0].confidence = None;
636
637 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
638 .expect_err("missing proof should fail");
639
640 assert_validation_contains(error, "require confidence");
641 }
642
643 #[test]
644 fn translate_memory_ingest_accepts_existing_materialized_refs() {
645 let mut command = sample_command();
646 command.memory.dimensions.clear();
647 command.memory.entries[0].coordinates[0].scope_id = "conversation:existing".to_string();
648 command.memory.relations[0].source_ref = "conversation:existing".to_string();
649 command.memory.relations[0].target_ref = "claim:existing".to_string();
650 command.memory.evidence[0].supports = vec!["claim:existing".to_string()];
651 let existing = ExistingMemoryRefs {
652 refs: [
653 "conversation:existing".to_string(),
654 "claim:existing".to_string(),
655 ]
656 .into_iter()
657 .collect(),
658 dimensions: ["conversation:existing".to_string()].into_iter().collect(),
659 };
660
661 let (update, outcome) =
662 translate_memory_ingest(&command, &existing).expect("existing refs should validate");
663
664 assert_eq!(outcome.accepted.entries, 1);
665 assert_eq!(update.changes.len(), 3);
666 }
667
668 #[test]
669 fn translate_memory_ingest_treats_existing_namespaced_dimension_as_idempotent() {
670 let command = sample_command();
671 let dimension_ref =
672 "about:question:830ce83f:dimension:conversation:rachel-2026-04-12".to_string();
673 let existing = ExistingMemoryRefs {
674 refs: [dimension_ref.clone()].into_iter().collect(),
675 dimensions: [dimension_ref.clone()].into_iter().collect(),
676 };
677
678 let (update, outcome) = translate_memory_ingest(&command, &existing)
679 .expect("existing dimension declaration should be idempotent");
680
681 assert_eq!(outcome.accepted.entries, 1);
682 assert_eq!(
683 update
684 .changes
685 .iter()
686 .map(|change| change.entity_kind.as_str())
687 .collect::<Vec<_>>(),
688 vec!["memory_entry", "memory_relation", "memory_evidence"]
689 );
690 assert_eq!(
691 update.changes[0].scopes,
692 std::slice::from_ref(&dimension_ref)
693 );
694 assert_eq!(
695 update.changes[1].entity_id,
696 "relation:about:question:830ce83f:dimension:conversation:rachel-2026-04-12:contains_entry:claim:rachel-denver"
697 );
698 }
699
700 #[test]
701 fn translate_memory_ingest_keeps_existing_dimensions_as_known_relation_refs() {
702 let mut command = sample_command();
703 command.memory.dimensions.clear();
704 let dimension_ref =
705 "about:question:830ce83f:dimension:conversation:rachel-2026-04-12".to_string();
706 command.memory.relations[0].source_ref = dimension_ref.clone();
707 let existing = ExistingMemoryRefs {
708 refs: BTreeSet::new(),
709 dimensions: [dimension_ref].into_iter().collect(),
710 };
711
712 translate_memory_ingest(&command, &existing)
713 .expect("existing dimensions should also be valid relation refs");
714 }
715
716 #[test]
717 fn translate_memory_ingest_rejects_zero_coordinates_when_set() {
718 let mut command = sample_command();
719 command.memory.entries[0].coordinates[0].sequence = Some(0);
720
721 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
722 .expect_err("zero coordinate sequence should fail");
723
724 assert_validation_contains(error, "sequence must be greater than zero");
725 }
726
727 fn sample_command() -> MemoryIngestCommand {
728 MemoryIngestCommand {
729 about: "question:830ce83f".to_string(),
730 memory: MemoryData {
731 dimensions: vec![MemoryDimensionData {
732 id: "conversation:rachel-2026-04-12".to_string(),
733 kind: "conversation".to_string(),
734 title: Some("Rachel relocation discussion".to_string()),
735 metadata: Default::default(),
736 }],
737 entries: vec![MemoryEntryData {
738 id: "claim:rachel-denver".to_string(),
739 kind: "claim".to_string(),
740 text: "Rachel said she was moving to Denver.".to_string(),
741 coordinates: vec![MemoryCoordinateData {
742 dimension: "conversation".to_string(),
743 scope_id: "conversation:rachel-2026-04-12".to_string(),
744 occurred_at: Some("2026-04-12T15:00:00Z".to_string()),
745 observed_at: None,
746 ingested_at: None,
747 valid_from: None,
748 valid_until: None,
749 sequence: Some(1),
750 rank: None,
751 metadata: Default::default(),
752 }],
753 metadata: Default::default(),
754 }],
755 relations: vec![MemoryRelationData {
756 source_ref: "conversation:rachel-2026-04-12".to_string(),
757 target_ref: "claim:rachel-denver".to_string(),
758 rel: "contains_entry".to_string(),
759 semantic_class: "structural".to_string(),
760 why: None,
761 evidence: None,
762 confidence: None,
763 sequence: Some(1),
764 motivation: None,
765 method: None,
766 decision_id: None,
767 caused_by_node_id: None,
768 coordinate: None,
769 }],
770 evidence: vec![MemoryEvidenceData {
771 id: "evidence:rachel-denver".to_string(),
772 supports: vec!["claim:rachel-denver".to_string()],
773 text: "Conversation transcript line 1".to_string(),
774 source: Some("transcript:1".to_string()),
775 time: Some("2026-04-12T15:00:00Z".to_string()),
776 metadata: Default::default(),
777 }],
778 },
779 provenance: None,
780 idempotency_key: "ingest:app-test".to_string(),
781 dry_run: false,
782 }
783 }
784
785 fn assert_validation_contains(error: ApplicationError, expected: &str) {
786 match error {
787 ApplicationError::Validation(message) => assert!(
788 message.contains(expected),
789 "expected `{message}` to contain `{expected}`"
790 ),
791 other => panic!("expected validation error, got {other:?}"),
792 }
793 }
794}