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