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