1use std::collections::{BTreeMap, BTreeSet};
2use std::sync::Arc;
3
4use kmp_domain::{
5 BundleNode, BundleRelationship, ContextEventStore, DimensionScopeMode, DimensionSelection,
6 DimensionSelectionMode, GraphNeighborhoodReader, KmpBundle, KmpMode, MemoryAboutIndexReader,
7 MemoryDimensionIdentity, MemoryRelationType, NodeDetailReader, NodeRelationshipReader,
8 ProjectionWriter, ResolutionTier, SnapshotStore, TemporalCoordinate, TemporalMemoryTraversal,
9 TemporalTraversalRequest,
10};
11
12use crate::ApplicationError;
13use crate::commands::CommandApplicationService;
14use crate::memory::{
15 AskMemoryQuery, ExistingMemoryRefs, InspectMemoryQuery, InspectMemoryResult, InspectedEvidence,
16 MemoryIngestCommand, MemoryIngestOutcome, RelateMemoryQuery, TemporalMemoryQuery,
17 TemporalMemoryResult, TraceMemoryQuery, VisualProjectionQuery, VisualProjectionResult,
18 WakeMemoryQuery, build_visual_projection, crosses_abouts, translate_memory_ingest,
19 validate_ref_token, validate_supplied_member_ref,
20};
21use crate::queries::{
22 ContextRenderOptions, EndpointHint, GetContextPathQuery, GetContextPathResult, GetContextQuery,
23 GetContextResult, GetNodeDetailQuery, GetNodeRelationshipsQuery,
24 MAX_NATIVE_GRAPH_TRAVERSAL_DEPTH, QueryApplicationService, render_graph_bundle_with_options,
25};
26
27const MEMORY_EXISTING_REFS_LOOKUP_DEPTH: u32 = 1;
28
29pub struct KernelMemoryApplicationService<G, D, S, E, W> {
30 query_application: Arc<QueryApplicationService<G, D, S>>,
31 command_application: Arc<CommandApplicationService<E, W>>,
32}
33
34impl<G, D, S, E, W> KernelMemoryApplicationService<G, D, S, E, W> {
35 pub fn new(
36 query_application: Arc<QueryApplicationService<G, D, S>>,
37 command_application: Arc<CommandApplicationService<E, W>>,
38 ) -> Self {
39 Self {
40 query_application,
41 command_application,
42 }
43 }
44}
45
46impl<G, D, S, E, W> KernelMemoryApplicationService<G, D, S, E, W>
47where
48 G: GraphNeighborhoodReader + MemoryAboutIndexReader + NodeRelationshipReader + Send + Sync,
49 D: NodeDetailReader + Send + Sync,
50 S: SnapshotStore + Send + Sync,
51 E: ContextEventStore + Send + Sync,
52 W: ProjectionWriter + Send + Sync,
53{
54 pub async fn ingest(
55 &self,
56 command: MemoryIngestCommand,
57 ) -> Result<MemoryIngestOutcome, ApplicationError> {
58 let mut existing = self.existing_memory_refs(&command.about).await?;
59 for relation in &command.memory.relations {
63 let Ok(relation_type) = MemoryRelationType::new(&relation.rel) else {
64 continue;
65 };
66 let target_ref = relation.target_ref.trim().to_string();
67 if !crosses_abouts(&command.about, relation, &relation_type, &target_ref) {
68 continue;
69 }
70 match self
71 .query_application
72 .get_node_detail(GetNodeDetailQuery {
73 node_id: target_ref.clone(),
74 })
75 .await
76 {
77 Ok(_) => {
78 existing.foreign.insert(target_ref);
79 }
80 Err(ApplicationError::NotFound(_)) => {}
81 Err(error) => return Err(error),
82 }
83 }
84 let (update_context, mut outcome) = translate_memory_ingest(&command, &existing)?;
85 if command.dry_run {
86 outcome
87 .warnings
88 .push("dry_run=true; validated memory without writing to the kernel".to_string());
89 return Ok(outcome);
90 }
91
92 let accepted = self
93 .command_application
94 .update_context(update_context)
95 .await?;
96 outcome.read_after_write_ready = true;
97 outcome.warnings.extend(accepted.warnings);
98 Ok(outcome)
99 }
100
101 pub async fn list_abouts(&self) -> Result<Vec<String>, ApplicationError> {
104 self.query_application.list_memory_abouts().await
105 }
106
107 pub async fn wake(&self, query: WakeMemoryQuery) -> Result<GetContextResult, ApplicationError> {
108 let render_options = memory_render_options(
109 query.token_budget,
110 query.max_tier,
111 KmpMode::ResumeFocused,
112 EndpointHint::Neighborhood,
113 );
114 let dimensions = query.dimensions.resolve_current_about(&query.about);
115 let result = self
116 .memory_context(
117 &query.about,
118 &query.role,
119 query.depth,
120 &dimensions,
121 &render_options,
122 )
123 .await?;
124 apply_dimension_selection(result, &dimensions, &render_options)
125 }
126
127 pub async fn ask(&self, query: AskMemoryQuery) -> Result<GetContextResult, ApplicationError> {
128 let render_options = memory_render_options(
129 query.token_budget,
130 query.max_tier,
131 KmpMode::ReasonPreserving,
132 EndpointHint::Neighborhood,
133 );
134 let dimensions = query.dimensions.resolve_current_about(&query.about);
135 let result = self
136 .memory_context(
137 &query.about,
138 "answerer",
139 query.depth,
140 &dimensions,
141 &render_options,
142 )
143 .await?;
144 apply_dimension_selection(result, &dimensions, &render_options)
145 }
146
147 pub async fn temporal(
148 &self,
149 query: TemporalMemoryQuery,
150 ) -> Result<TemporalMemoryResult, ApplicationError> {
151 let render_options = memory_render_options(
152 query.token_budget,
153 query.max_tier,
154 KmpMode::ReasonPreserving,
155 EndpointHint::Neighborhood,
156 );
157 let dimensions = query.dimensions.resolve_current_about(&query.about);
158 let context = self
159 .memory_context(
160 &query.about,
161 "temporal-reader",
162 query.depth,
163 &dimensions,
164 &render_options,
165 )
166 .await?;
167 let quality = context.rendered.quality.clone();
168 let source_bundle = filter_bundle_by_memory_dimensions(&context.bundle, &dimensions)?;
169
170 let request = TemporalTraversalRequest::new(query.direction, query.cursor)
171 .with_axis(query.axis)
172 .with_dimensions(dimensions.clone())
173 .with_requested_dimensions(query.dimensions.clone())
174 .with_window(query.window);
175 let request = if let Some(limit_entries) = query.limit_entries {
176 request.with_limit_entries(limit_entries)?
177 } else {
178 request
179 };
180
181 let traversal = TemporalMemoryTraversal::traverse(&source_bundle, &request)?;
182
183 Ok(TemporalMemoryResult {
184 traversal,
185 source_bundle,
186 include: query.include,
187 quality,
188 })
189 }
190
191 pub async fn visual_projection(
198 &self,
199 query: VisualProjectionQuery,
200 ) -> Result<VisualProjectionResult, ApplicationError> {
201 let temporal_query = query.temporal_query()?;
202 let temporal = self.temporal(temporal_query).await?;
203 build_visual_projection(&query, temporal)
204 }
205
206 pub async fn relate(
211 &self,
212 query: RelateMemoryQuery,
213 ) -> Result<GetContextResult, ApplicationError> {
214 let render_options = memory_render_options(
215 query.token_budget,
216 query.max_tier,
217 KmpMode::ReasonPreserving,
218 EndpointHint::Neighborhood,
219 );
220 let dimensions = query.dimensions.resolve_current_about(&query.about);
221 let result = self
222 .memory_context(
223 &query.about,
224 "relater",
225 query.depth,
226 &dimensions,
227 &render_options,
228 )
229 .await?;
230 apply_dimension_selection(result, &dimensions, &render_options)
231 }
232
233 pub async fn trace(
234 &self,
235 query: TraceMemoryQuery,
236 ) -> Result<GetContextPathResult, ApplicationError> {
237 self.validate_read_members(
238 &query.about,
239 &[("from", query.from.as_str()), ("to", query.to.as_str())],
240 )
241 .await?;
242 self.query_application
243 .get_context_path(GetContextPathQuery {
244 root_node_id: query.from,
245 target_node_id: query.to,
246 role: query.role,
247 subtree_depth: Some(0),
248 render_options: ContextRenderOptions {
249 focus_node_id: None,
250 token_budget: (query.token_budget > 0).then_some(query.token_budget),
251 max_tier: Some(ResolutionTier::L2EvidencePack),
252 rehydration_mode: KmpMode::ReasonPreserving,
253 endpoint_hint: EndpointHint::FocusedPath,
254 },
255 })
256 .await
257 }
258
259 pub async fn inspect(
260 &self,
261 query: InspectMemoryQuery,
262 ) -> Result<InspectMemoryResult, ApplicationError> {
263 self.validate_read_members(&query.about, &[("ref", query.ref_id.as_str())])
264 .await?;
265 let include_incoming = query.include_incoming;
266 let include_outgoing = query.include_outgoing;
267 let include_details = query.include_details;
268 let detail = self
269 .query_application
270 .get_node_detail(GetNodeDetailQuery {
271 node_id: query.ref_id.clone(),
272 })
273 .await?;
274
275 let links = self
279 .query_application
280 .get_node_relationships(GetNodeRelationshipsQuery {
281 node_id: query.ref_id.clone(),
282 })
283 .await?;
284 let mut evidence = Vec::new();
285 let supporting_refs = links
286 .incoming
287 .iter()
288 .filter(|relationship| relationship.relationship_type == "supports")
289 .map(|relationship| relationship.source_node_id.clone())
290 .collect::<BTreeSet<_>>();
291 for evidence_ref in supporting_refs {
292 let evidence_detail = match self
293 .query_application
294 .get_node_detail(GetNodeDetailQuery {
295 node_id: evidence_ref,
296 })
297 .await
298 {
299 Ok(detail) => detail,
300 Err(ApplicationError::NotFound(_)) => continue,
303 Err(error) => return Err(error),
304 };
305 if !is_memory_evidence_kind(&evidence_detail.node.node_kind) {
306 continue;
307 }
308 evidence.push(InspectedEvidence {
309 supports: projected_evidence_supports(&evidence_detail, &query.ref_id),
310 detail: evidence_detail,
311 });
312 }
313 let raw_coordinates = if query.include_raw {
314 inspect_raw_coordinates(&query.ref_id, Some(&links))?
315 } else {
316 Vec::new()
317 };
318
319 Ok(InspectMemoryResult {
320 detail,
321 incoming: if include_incoming {
322 links.incoming.clone()
323 } else {
324 Vec::new()
325 },
326 outgoing: if include_outgoing {
327 links.outgoing.clone()
328 } else {
329 Vec::new()
330 },
331 evidence,
332 raw_coordinates,
333 include_details,
334 include_raw: query.include_raw,
335 })
336 }
337
338 async fn existing_memory_refs(
339 &self,
340 about: &str,
341 ) -> Result<ExistingMemoryRefs, ApplicationError> {
342 match self
343 .query_application
344 .get_context(GetContextQuery {
345 root_node_id: about.to_string(),
346 role: "memory".to_string(),
347 depth: MEMORY_EXISTING_REFS_LOOKUP_DEPTH,
352 requested_scopes: Vec::new(),
353 render_options: ContextRenderOptions::default(),
354 })
355 .await
356 {
357 Ok(result) => Ok(existing_refs_from_bundle(&result.bundle)),
358 Err(ApplicationError::NotFound(_)) => Ok(ExistingMemoryRefs::default()),
359 Err(error) => Err(error),
360 }
361 }
362
363 async fn validate_read_members(
364 &self,
365 about: &str,
366 members: &[(&str, &str)],
367 ) -> Result<(), ApplicationError> {
368 let mut graph_members = Vec::new();
369 for (path, member_ref) in members {
370 validate_ref_token(path, member_ref).map_err(ApplicationError::Validation)?;
371 if validate_supplied_member_ref(about, path, member_ref).is_err() {
372 graph_members.push((*path, *member_ref));
373 }
374 }
375 if graph_members.is_empty() {
376 return Ok(());
377 }
378
379 let visible = match self
380 .query_application
381 .get_context(GetContextQuery {
382 root_node_id: about.to_string(),
383 role: "memory-boundary".to_string(),
384 depth: MAX_NATIVE_GRAPH_TRAVERSAL_DEPTH,
385 requested_scopes: Vec::new(),
386 render_options: ContextRenderOptions::default(),
387 })
388 .await
389 {
390 Ok(result) => bundle_node_ids(&result.bundle),
391 Err(ApplicationError::NotFound(_)) => BTreeSet::new(),
392 Err(error) => return Err(error),
393 };
394 for (path, member_ref) in graph_members {
395 if !visible.contains(member_ref) {
396 return Err(ApplicationError::Validation(format!(
397 "`{path}` `{member_ref}` does not belong to about `{about}`"
398 )));
399 }
400 }
401 Ok(())
402 }
403
404 async fn memory_context(
405 &self,
406 about: &str,
407 role: &str,
408 depth: u32,
409 dimensions: &DimensionSelection,
410 render_options: &ContextRenderOptions,
411 ) -> Result<GetContextResult, ApplicationError> {
412 let roots = self.memory_context_roots(about, dimensions).await?;
413 let requested_scopes = requested_dimension_scopes(about, dimensions, &roots);
414 let mut results = Vec::new();
415 for root in &roots {
416 results.push(
417 self.query_application
418 .get_context(GetContextQuery {
419 root_node_id: root.clone(),
420 role: role.to_string(),
421 depth,
422 requested_scopes: requested_scopes.clone(),
423 render_options: render_options.clone(),
424 })
425 .await?,
426 );
427 }
428
429 merge_context_results(results, render_options)
430 }
431
432 async fn memory_context_roots(
433 &self,
434 current_about: &str,
435 selection: &DimensionSelection,
436 ) -> Result<Vec<String>, ApplicationError> {
437 if selection.scope_mode() != DimensionScopeMode::AllAbouts {
438 return context_roots(current_about, selection);
439 }
440
441 let roots = if should_filter_all_abouts_by_dimensions(selection) {
442 let dimension_ids = selection
445 .dimensions()
446 .iter()
447 .chain(selection.scope_ids().iter())
448 .cloned()
449 .collect::<Vec<_>>();
450 self.query_application
451 .list_memory_abouts_by_dimensions(&dimension_ids)
452 .await?
453 } else {
454 self.query_application.list_memory_abouts().await?
455 };
456
457 let roots = prioritize_current_about(normalize_about_roots(roots), current_about);
458 if roots.is_empty() {
459 return Err(ApplicationError::NotFound(
460 "no memory abouts found for ALL_ABOUTS scope".to_string(),
461 ));
462 }
463 Ok(roots)
464 }
465}
466
467fn bundle_node_ids(bundle: &KmpBundle) -> BTreeSet<String> {
468 std::iter::once(bundle.root_node().node_id())
469 .chain(bundle.neighbor_nodes().iter().map(BundleNode::node_id))
470 .map(ToString::to_string)
471 .collect()
472}
473
474fn projected_evidence_supports(
475 evidence: &crate::queries::GetNodeDetailResult,
476 inspected_ref: &str,
477) -> Vec<String> {
478 evidence
479 .node
480 .properties
481 .get("payload_supports")
482 .and_then(|value| serde_json::from_str::<Vec<String>>(value).ok())
483 .filter(|supports| !supports.is_empty())
484 .unwrap_or_else(|| vec![inspected_ref.to_string()])
485}
486
487fn should_filter_all_abouts_by_dimensions(selection: &DimensionSelection) -> bool {
488 selection.scope_mode() == DimensionScopeMode::AllAbouts
489 && ((selection.mode() == DimensionSelectionMode::Only
490 && !selection.dimensions().is_empty())
491 || !selection.scope_ids().is_empty())
492}
493
494fn inspect_raw_coordinates(
495 ref_id: &str,
496 links: Option<&crate::queries::GetNodeRelationshipsResult>,
497) -> Result<Vec<TemporalCoordinate>, ApplicationError> {
498 let Some(links) = links else {
499 return Ok(Vec::new());
500 };
501
502 let mut coordinates = Vec::new();
503 for relationship in links.incoming.iter().chain(links.outgoing.iter()) {
504 if relationship.relationship_type != "contains_entry"
505 || relationship.target_node_id != ref_id
506 {
507 continue;
508 }
509 if let Some(coordinate) =
510 TemporalCoordinate::from_relation_explanation(&relationship.explanation)?
511 {
512 coordinates.push(coordinate);
513 }
514 }
515
516 Ok(coordinates)
517}
518
519fn memory_render_options(
520 token_budget: u32,
521 max_tier: Option<ResolutionTier>,
522 rehydration_mode: KmpMode,
523 endpoint_hint: EndpointHint,
524) -> ContextRenderOptions {
525 ContextRenderOptions {
526 focus_node_id: None,
527 token_budget: (token_budget > 0).then_some(token_budget),
528 max_tier,
529 rehydration_mode,
530 endpoint_hint,
531 }
532}
533
534fn requested_dimension_scopes(
535 current_about: &str,
536 selection: &DimensionSelection,
537 context_roots: &[String],
538) -> Vec<String> {
539 if !selection.scope_ids().is_empty() {
540 return requested_explicit_dimension_scopes(current_about, selection, context_roots);
541 }
542
543 match selection.mode() {
544 DimensionSelectionMode::Only => match selection.scope_mode() {
545 DimensionScopeMode::CurrentAbout => selection
546 .dimensions()
547 .iter()
548 .filter_map(|dimension| namespaced_dimension_id(current_about, dimension))
549 .collect(),
550 DimensionScopeMode::Abouts => selection
551 .abouts()
552 .iter()
553 .flat_map(|about| {
554 selection
555 .dimensions()
556 .iter()
557 .filter_map(|dimension| namespaced_dimension_id(about, dimension))
558 .collect::<Vec<_>>()
559 })
560 .collect(),
561 DimensionScopeMode::AllAbouts => context_roots
562 .iter()
563 .flat_map(|about| {
564 selection
565 .dimensions()
566 .iter()
567 .filter_map(|dimension| namespaced_dimension_id(about, dimension))
568 .collect::<Vec<_>>()
569 })
570 .collect(),
571 },
572 DimensionSelectionMode::Except | DimensionSelectionMode::All => Vec::new(),
573 }
574}
575
576fn requested_explicit_dimension_scopes(
577 current_about: &str,
578 selection: &DimensionSelection,
579 context_roots: &[String],
580) -> Vec<String> {
581 let abouts = match selection.scope_mode() {
582 DimensionScopeMode::CurrentAbout => vec![current_about.to_string()],
583 DimensionScopeMode::Abouts => selection.abouts().iter().cloned().collect(),
584 DimensionScopeMode::AllAbouts => context_roots.to_vec(),
585 };
586
587 abouts
588 .iter()
589 .flat_map(|about| {
590 selection
591 .scope_ids()
592 .iter()
593 .filter_map(|scope_id| resolve_dimension_scope_id(about, scope_id))
594 .collect::<Vec<_>>()
595 })
596 .collect::<BTreeSet<_>>()
597 .into_iter()
598 .collect()
599}
600
601fn context_roots(
602 current_about: &str,
603 selection: &DimensionSelection,
604) -> Result<Vec<String>, ApplicationError> {
605 match selection.scope_mode() {
606 DimensionScopeMode::Abouts if !selection.abouts().is_empty() => {
607 Ok(selection.abouts().iter().cloned().collect())
608 }
609 DimensionScopeMode::CurrentAbout => Ok(vec![current_about.to_string()]),
610 DimensionScopeMode::Abouts => Err(ApplicationError::Validation(
611 "dimension scope ABOUTS requires at least one about".to_string(),
612 )),
613 DimensionScopeMode::AllAbouts => Err(ApplicationError::Validation(
614 "dimension scope ALL_ABOUTS must be resolved through the memory about index"
615 .to_string(),
616 )),
617 }
618}
619
620fn apply_dimension_selection(
621 mut result: GetContextResult,
622 dimensions: &DimensionSelection,
623 render_options: &ContextRenderOptions,
624) -> Result<GetContextResult, ApplicationError> {
625 result.bundle = filter_bundle_by_memory_dimensions(&result.bundle, dimensions)?;
626 result.rendered = render_graph_bundle_with_options(&result.bundle, render_options);
627 Ok(result)
628}
629
630fn normalize_about_roots(values: Vec<String>) -> Vec<String> {
631 values
632 .into_iter()
633 .map(|value| value.trim().to_string())
634 .filter(|value| !value.is_empty())
635 .collect::<BTreeSet<_>>()
636 .into_iter()
637 .collect()
638}
639
640fn prioritize_current_about(mut roots: Vec<String>, current_about: &str) -> Vec<String> {
641 let current_about = current_about.trim();
642 if current_about.is_empty() {
643 return roots;
644 }
645 if let Some(position) = roots.iter().position(|root| root == current_about) {
646 let root = roots.remove(position);
647 roots.insert(0, root);
648 }
649 roots
650}
651
652fn filter_bundle_by_memory_dimensions(
653 bundle: &KmpBundle,
654 dimensions: &DimensionSelection,
655) -> Result<KmpBundle, ApplicationError> {
656 let mut included_node_ids = BTreeSet::from([bundle.root_node().node_id().to_string()]);
657 let mut selected_entry_ids = BTreeSet::new();
658 let node_kinds = bundle_node_kinds(bundle);
659
660 for relationship in bundle
661 .relationships()
662 .iter()
663 .filter(|relationship| relationship.relationship_type() == "contains_entry")
664 {
665 let explanation = relationship.explanation();
666 if dimensions.includes_coordinate(
667 explanation.dimension().unwrap_or_default(),
668 explanation.scope_id().unwrap_or_default(),
669 ) {
670 included_node_ids.insert(relationship.source_node_id().to_string());
671 included_node_ids.insert(relationship.target_node_id().to_string());
672 selected_entry_ids.insert(relationship.target_node_id().to_string());
673 }
674 }
675
676 for relationship in bundle.relationships().iter().filter(|relationship| {
677 relationship.relationship_type() == "supports"
678 && selected_entry_ids.contains(relationship.target_node_id())
679 && node_kinds
680 .get(relationship.source_node_id())
681 .is_some_and(|kind| is_memory_evidence_kind(kind))
682 }) {
683 included_node_ids.insert(relationship.source_node_id().to_string());
684 }
685
686 let neighbor_nodes = bundle
687 .neighbor_nodes()
688 .iter()
689 .filter(|node| included_node_ids.contains(node.node_id()))
690 .cloned()
691 .collect::<Vec<_>>();
692 let relationships = bundle
693 .relationships()
694 .iter()
695 .filter(|relationship| {
696 if relationship.relationship_type() == "contains_entry" {
697 let explanation = relationship.explanation();
698 return dimensions.includes_coordinate(
699 explanation.dimension().unwrap_or_default(),
700 explanation.scope_id().unwrap_or_default(),
701 );
702 }
703 included_node_ids.contains(relationship.source_node_id())
704 && included_node_ids.contains(relationship.target_node_id())
705 })
706 .cloned()
707 .collect::<Vec<_>>();
708 let node_details = bundle
709 .node_details()
710 .iter()
711 .filter(|detail| included_node_ids.contains(detail.node_id()))
712 .cloned()
713 .collect::<Vec<_>>();
714
715 KmpBundle::new(
716 bundle.root_node_id().clone(),
717 bundle.role().clone(),
718 bundle.root_node().clone(),
719 neighbor_nodes,
720 relationships,
721 node_details,
722 bundle.metadata().clone(),
723 )
724 .map_err(Into::into)
725}
726
727fn bundle_node_kinds(bundle: &KmpBundle) -> BTreeMap<&str, &str> {
728 let mut node_kinds =
729 BTreeMap::from([(bundle.root_node().node_id(), bundle.root_node().node_kind())]);
730 for node in bundle.neighbor_nodes() {
731 node_kinds.insert(node.node_id(), node.node_kind());
732 }
733 node_kinds
734}
735
736fn is_memory_evidence_kind(kind: &str) -> bool {
737 matches!(kind, "memory_evidence" | "evidence")
738}
739
740fn existing_refs_from_bundle(bundle: &KmpBundle) -> ExistingMemoryRefs {
741 let mut refs = BTreeSet::from([bundle.root_node().node_id().to_string()]);
742 let mut dimensions = BTreeSet::new();
743 let mut max_sequences = BTreeMap::new();
744
745 for node in bundle.neighbor_nodes() {
746 refs.insert(node.node_id().to_string());
747 if node.node_kind() == "memory_dimension" {
748 dimensions.insert(node.node_id().to_string());
749 }
750 }
751
752 for relationship in bundle
753 .relationships()
754 .iter()
755 .filter(|relationship| relationship.relationship_type() == "contains_entry")
756 {
757 dimensions.insert(relationship.source_node_id().to_string());
758 let explanation = relationship.explanation();
759 if let (Some(dimension), Some(scope_id), Some(sequence)) = (
760 explanation.dimension(),
761 explanation.scope_id(),
762 explanation.sequence(),
763 ) {
764 max_sequences
765 .entry((dimension.to_string(), scope_id.to_string()))
766 .and_modify(|current: &mut u32| *current = (*current).max(sequence))
767 .or_insert(sequence);
768 }
769 }
770
771 ExistingMemoryRefs {
772 refs,
773 dimensions,
774 max_sequences,
775 foreign: BTreeSet::new(),
776 }
777}
778
779fn merge_context_results(
780 mut results: Vec<GetContextResult>,
781 render_options: &ContextRenderOptions,
782) -> Result<GetContextResult, ApplicationError> {
783 let mut result = results.remove(0);
784 if results.is_empty() {
785 return Ok(result);
786 }
787
788 let mut node_ids = BTreeSet::from([result.bundle.root_node().node_id().to_string()]);
789 let mut neighbor_nodes = result.bundle.neighbor_nodes().to_vec();
790 for node in &neighbor_nodes {
791 node_ids.insert(node.node_id().to_string());
792 }
793
794 let mut relationships = result.bundle.relationships().to_vec();
795 let mut relationship_ids = relationships
796 .iter()
797 .map(relationship_key)
798 .collect::<BTreeSet<_>>();
799 let mut node_details = result.bundle.node_details().to_vec();
800 let mut detail_ids = node_details
801 .iter()
802 .map(|detail| detail.node_id().to_string())
803 .collect::<BTreeSet<_>>();
804
805 for other in results {
806 push_node(&mut neighbor_nodes, &mut node_ids, other.bundle.root_node());
807 for node in other.bundle.neighbor_nodes() {
808 push_node(&mut neighbor_nodes, &mut node_ids, node);
809 }
810 for relationship in other.bundle.relationships() {
811 if relationship_ids.insert(relationship_key(relationship)) {
812 relationships.push(relationship.clone());
813 }
814 }
815 for detail in other.bundle.node_details() {
816 if detail_ids.insert(detail.node_id().to_string()) {
817 node_details.push(detail.clone());
818 }
819 }
820 }
821
822 result.bundle = KmpBundle::new(
823 result.bundle.root_node_id().clone(),
824 result.bundle.role().clone(),
825 result.bundle.root_node().clone(),
826 neighbor_nodes,
827 relationships,
828 node_details,
829 result.bundle.metadata().clone(),
830 )
831 .map_err(ApplicationError::Domain)?;
832 result.rendered = render_graph_bundle_with_options(&result.bundle, render_options);
833 Ok(result)
834}
835
836fn push_node(
837 neighbor_nodes: &mut Vec<BundleNode>,
838 node_ids: &mut BTreeSet<String>,
839 node: &BundleNode,
840) {
841 if node_ids.insert(node.node_id().to_string()) {
842 neighbor_nodes.push(node.clone());
843 }
844}
845
846fn relationship_key(relationship: &BundleRelationship) -> (String, String, String) {
847 (
848 relationship.source_node_id().to_string(),
849 relationship.target_node_id().to_string(),
850 relationship.relationship_type().to_string(),
851 )
852}
853
854fn namespaced_dimension_id(about: &str, dimension: &str) -> Option<String> {
855 MemoryDimensionIdentity::new(about, dimension)
856 .ok()
857 .map(|identity| identity.node_id())
858}
859
860fn resolve_dimension_scope_id(about: &str, scope_id: &str) -> Option<String> {
861 let scope_id = scope_id.trim();
862 if scope_id.is_empty() {
863 return None;
864 }
865 MemoryDimensionIdentity::resolve(about, scope_id).map(|identity| identity.node_id())
866}
867
868#[cfg(test)]
869mod tests {
870 use std::collections::BTreeMap;
871
872 use kmp_domain::{BundleMetadata, CaseId, RelationExplanation, RelationSemanticClass, Role};
873
874 use super::*;
875
876 #[test]
877 fn all_abouts_scope_requires_about_index_resolution() {
878 let selection = DimensionSelection::all().with_all_about_scope();
879 let error = context_roots("question:current", &selection)
880 .expect_err("ALL_ABOUTS must not fall back to current about directly");
881
882 assert!(matches!(
883 error,
884 ApplicationError::Validation(message)
885 if message.contains("resolved through the memory about index")
886 ));
887 }
888
889 #[test]
890 fn requested_scopes_expands_all_abouts_from_indexed_roots() {
891 let selection = DimensionSelection::only(["timeline"]).with_all_about_scope();
892 let scopes = requested_dimension_scopes(
893 "question:current",
894 &selection,
895 &["question:a".to_string(), "question:b".to_string()],
896 );
897
898 assert_eq!(
899 scopes,
900 vec![
901 "about:question:a:dimension:timeline".to_string(),
902 "about:question:b:dimension:timeline".to_string()
903 ]
904 );
905 }
906
907 #[test]
908 fn requested_scopes_expand_explicit_scope_ids_against_selected_abouts() {
909 let selection = DimensionSelection::only(["conversation"])
910 .with_about_scope(["question:a", "question:b"])
911 .with_scope_ids([
912 "conversation:alpha",
913 "about:question:b:dimension:conversation:beta",
914 ]);
915 let scopes = requested_dimension_scopes("question:current", &selection, &[]);
916
917 assert_eq!(
918 scopes,
919 vec![
920 "about:question:a:dimension:conversation:alpha".to_string(),
921 "about:question:b:dimension:conversation:alpha".to_string(),
922 "about:question:b:dimension:conversation:beta".to_string()
923 ]
924 );
925 }
926
927 #[test]
928 fn bundle_filter_narrows_same_dimension_kind_by_exact_scope_id() {
929 let bundle = scoped_conversation_bundle();
930 let selection = DimensionSelection::only(["conversation"])
931 .resolve_current_about("question:a")
932 .with_scope_ids(["conversation:alpha"]);
933
934 let filtered =
935 filter_bundle_by_memory_dimensions(&bundle, &selection).expect("bundle should filter");
936 let node_ids = filtered
937 .neighbor_nodes()
938 .iter()
939 .map(|node| node.node_id())
940 .collect::<Vec<_>>();
941 let relationships = filtered
942 .relationships()
943 .iter()
944 .map(|relationship| {
945 (
946 relationship.source_node_id(),
947 relationship.target_node_id(),
948 relationship.relationship_type(),
949 )
950 })
951 .collect::<Vec<_>>();
952
953 assert!(node_ids.contains(&"about:question:a:dimension:conversation:alpha"));
954 assert!(node_ids.contains(&"claim:alpha"));
955 assert!(!node_ids.contains(&"about:question:a:dimension:conversation:beta"));
956 assert!(!node_ids.contains(&"claim:beta"));
957 assert_eq!(
958 relationships,
959 vec![(
960 "about:question:a:dimension:conversation:alpha",
961 "claim:alpha",
962 "contains_entry"
963 )]
964 );
965 }
966
967 #[test]
968 fn bundle_filter_only_pulls_support_sources_when_source_is_memory_evidence() {
969 let bundle = scoped_conversation_bundle_with_supports();
970 let selection = DimensionSelection::only(["conversation"])
971 .resolve_current_about("question:a")
972 .with_scope_ids(["conversation:alpha"]);
973
974 let filtered =
975 filter_bundle_by_memory_dimensions(&bundle, &selection).expect("bundle should filter");
976 let node_ids = filtered
977 .neighbor_nodes()
978 .iter()
979 .map(|node| node.node_id())
980 .collect::<Vec<_>>();
981 let relationships = filtered
982 .relationships()
983 .iter()
984 .map(|relationship| {
985 (
986 relationship.source_node_id(),
987 relationship.target_node_id(),
988 relationship.relationship_type(),
989 )
990 })
991 .collect::<Vec<_>>();
992
993 assert!(node_ids.contains(&"claim:alpha"));
994 assert!(node_ids.contains(&"evidence:alpha"));
995 assert!(!node_ids.contains(&"claim:beta"));
996 assert_eq!(
997 relationships,
998 vec![
999 (
1000 "about:question:a:dimension:conversation:alpha",
1001 "claim:alpha",
1002 "contains_entry"
1003 ),
1004 ("evidence:alpha", "claim:alpha", "supports")
1005 ]
1006 );
1007 }
1008
1009 #[test]
1010 fn normalize_about_roots_trims_sorts_and_deduplicates() {
1011 assert_eq!(
1012 normalize_about_roots(vec![
1013 " question:b ".to_string(),
1014 String::new(),
1015 "question:a".to_string(),
1016 "question:b".to_string(),
1017 ]),
1018 vec!["question:a".to_string(), "question:b".to_string()]
1019 );
1020 }
1021
1022 #[test]
1025 fn a_sweep_is_narrowed_by_kinds_or_by_exact_scopes() {
1026 let by_kind = DimensionSelection::only(["incident"]).with_all_about_scope();
1027 assert!(should_filter_all_abouts_by_dimensions(&by_kind));
1028 let by_scope = DimensionSelection::all()
1029 .with_all_about_scope()
1030 .with_scope_ids(["incident:north-outage"]);
1031 assert!(should_filter_all_abouts_by_dimensions(&by_scope));
1032 let whole_sweep = DimensionSelection::all().with_all_about_scope();
1033 assert!(!should_filter_all_abouts_by_dimensions(&whole_sweep));
1034 let inside_one_about = DimensionSelection::only(["incident"]);
1035 assert!(!should_filter_all_abouts_by_dimensions(&inside_one_about));
1036 }
1037
1038 #[test]
1039 fn prioritize_current_about_keeps_all_roots_but_moves_current_first() {
1040 assert_eq!(
1041 prioritize_current_about(
1042 vec![
1043 "question:a".to_string(),
1044 "question:current".to_string(),
1045 "question:z".to_string(),
1046 ],
1047 "question:current",
1048 ),
1049 vec![
1050 "question:current".to_string(),
1051 "question:a".to_string(),
1052 "question:z".to_string()
1053 ]
1054 );
1055 }
1056
1057 fn scoped_conversation_bundle() -> KmpBundle {
1058 KmpBundle::new(
1059 CaseId::new("question:a").expect("case id should be valid"),
1060 Role::new("temporal-reader").expect("role should be valid"),
1061 BundleNode::new(
1062 "question:a",
1063 "question",
1064 "Question A",
1065 "Test question",
1066 "ACTIVE",
1067 Vec::new(),
1068 BTreeMap::new(),
1069 ),
1070 vec![
1071 memory_dimension_node("about:question:a:dimension:conversation:alpha"),
1072 memory_dimension_node("about:question:a:dimension:conversation:beta"),
1073 claim_node("claim:alpha"),
1074 claim_node("claim:beta"),
1075 ],
1076 vec![
1077 contains_entry(
1078 "about:question:a:dimension:conversation:alpha",
1079 "claim:alpha",
1080 1,
1081 ),
1082 contains_entry(
1083 "about:question:a:dimension:conversation:beta",
1084 "claim:beta",
1085 2,
1086 ),
1087 cross_scope_constraint("claim:beta", "claim:alpha"),
1088 ],
1089 Vec::new(),
1090 BundleMetadata::initial("test"),
1091 )
1092 .expect("test bundle should be valid")
1093 }
1094
1095 fn scoped_conversation_bundle_with_supports() -> KmpBundle {
1096 KmpBundle::new(
1097 CaseId::new("question:a").expect("case id should be valid"),
1098 Role::new("temporal-reader").expect("role should be valid"),
1099 BundleNode::new(
1100 "question:a",
1101 "question",
1102 "Question A",
1103 "Test question",
1104 "ACTIVE",
1105 Vec::new(),
1106 BTreeMap::new(),
1107 ),
1108 vec![
1109 memory_dimension_node("about:question:a:dimension:conversation:alpha"),
1110 memory_dimension_node("about:question:a:dimension:conversation:beta"),
1111 claim_node("claim:alpha"),
1112 claim_node("claim:beta"),
1113 evidence_node("evidence:alpha"),
1114 ],
1115 vec![
1116 contains_entry(
1117 "about:question:a:dimension:conversation:alpha",
1118 "claim:alpha",
1119 1,
1120 ),
1121 contains_entry(
1122 "about:question:a:dimension:conversation:beta",
1123 "claim:beta",
1124 2,
1125 ),
1126 supports("claim:beta", "claim:alpha"),
1127 supports("evidence:alpha", "claim:alpha"),
1128 ],
1129 Vec::new(),
1130 BundleMetadata::initial("test"),
1131 )
1132 .expect("test bundle should be valid")
1133 }
1134
1135 fn memory_dimension_node(node_id: &str) -> BundleNode {
1136 BundleNode::new(
1137 node_id,
1138 "memory_dimension",
1139 node_id,
1140 "Conversation scope",
1141 "ACTIVE",
1142 Vec::new(),
1143 BTreeMap::new(),
1144 )
1145 }
1146
1147 fn claim_node(node_id: &str) -> BundleNode {
1148 BundleNode::new(
1149 node_id,
1150 "claim",
1151 node_id,
1152 "Claim",
1153 "ACTIVE",
1154 Vec::new(),
1155 BTreeMap::new(),
1156 )
1157 }
1158
1159 fn evidence_node(node_id: &str) -> BundleNode {
1160 BundleNode::new(
1161 node_id,
1162 "memory_evidence",
1163 node_id,
1164 "Evidence",
1165 "ACTIVE",
1166 Vec::new(),
1167 BTreeMap::new(),
1168 )
1169 }
1170
1171 fn contains_entry(scope_id: &str, target_node_id: &str, sequence: u32) -> BundleRelationship {
1172 BundleRelationship::new(
1173 scope_id,
1174 target_node_id,
1175 "contains_entry",
1176 RelationExplanation::new(RelationSemanticClass::Structural)
1177 .with_dimension("conversation")
1178 .with_scope_id(scope_id)
1179 .with_sequence(sequence),
1180 )
1181 }
1182
1183 fn cross_scope_constraint(source_node_id: &str, target_node_id: &str) -> BundleRelationship {
1184 BundleRelationship::new(
1185 source_node_id,
1186 target_node_id,
1187 "contextual_constraint",
1188 RelationExplanation::new(RelationSemanticClass::Constraint)
1189 .with_rationale("Off-scope relation must not leak through exact scope filtering.")
1190 .with_confidence("medium"),
1191 )
1192 }
1193
1194 fn supports(source_node_id: &str, target_node_id: &str) -> BundleRelationship {
1195 BundleRelationship::new(
1196 source_node_id,
1197 target_node_id,
1198 "supports",
1199 RelationExplanation::new(RelationSemanticClass::Evidential)
1200 .with_rationale("Support relation for scoped filtering.")
1201 .with_confidence("medium"),
1202 )
1203 }
1204}