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 if let Some(identity) = MemoryDimensionIdentity::parse(scope_id) {
805 if identity.about() == about {
806 return Some(identity.node_id());
807 }
808 return None;
809 }
810 namespaced_dimension_id(about, scope_id)
811}
812
813#[cfg(test)]
814mod tests {
815 use std::collections::BTreeMap;
816
817 use kmp_domain::{BundleMetadata, CaseId, RelationExplanation, RelationSemanticClass, Role};
818
819 use super::*;
820
821 #[test]
822 fn all_abouts_scope_requires_about_index_resolution() {
823 let selection = DimensionSelection::all().with_all_about_scope();
824 let error = context_roots("question:current", &selection)
825 .expect_err("ALL_ABOUTS must not fall back to current about directly");
826
827 assert!(matches!(
828 error,
829 ApplicationError::Validation(message)
830 if message.contains("resolved through the memory about index")
831 ));
832 }
833
834 #[test]
835 fn requested_scopes_expands_all_abouts_from_indexed_roots() {
836 let selection = DimensionSelection::only(["timeline"]).with_all_about_scope();
837 let scopes = requested_dimension_scopes(
838 "question:current",
839 &selection,
840 &["question:a".to_string(), "question:b".to_string()],
841 );
842
843 assert_eq!(
844 scopes,
845 vec![
846 "about:question:a:dimension:timeline".to_string(),
847 "about:question:b:dimension:timeline".to_string()
848 ]
849 );
850 }
851
852 #[test]
853 fn requested_scopes_expand_explicit_scope_ids_against_selected_abouts() {
854 let selection = DimensionSelection::only(["conversation"])
855 .with_about_scope(["question:a", "question:b"])
856 .with_scope_ids([
857 "conversation:alpha",
858 "about:question:b:dimension:conversation:beta",
859 ]);
860 let scopes = requested_dimension_scopes("question:current", &selection, &[]);
861
862 assert_eq!(
863 scopes,
864 vec![
865 "about:question:a:dimension:conversation:alpha".to_string(),
866 "about:question:b:dimension:conversation:alpha".to_string(),
867 "about:question:b:dimension:conversation:beta".to_string()
868 ]
869 );
870 }
871
872 #[test]
873 fn bundle_filter_narrows_same_dimension_kind_by_exact_scope_id() {
874 let bundle = scoped_conversation_bundle();
875 let selection = DimensionSelection::only(["conversation"])
876 .resolve_current_about("question:a")
877 .with_scope_ids(["conversation:alpha"]);
878
879 let filtered =
880 filter_bundle_by_memory_dimensions(&bundle, &selection).expect("bundle should filter");
881 let node_ids = filtered
882 .neighbor_nodes()
883 .iter()
884 .map(|node| node.node_id())
885 .collect::<Vec<_>>();
886 let relationships = filtered
887 .relationships()
888 .iter()
889 .map(|relationship| {
890 (
891 relationship.source_node_id(),
892 relationship.target_node_id(),
893 relationship.relationship_type(),
894 )
895 })
896 .collect::<Vec<_>>();
897
898 assert!(node_ids.contains(&"about:question:a:dimension:conversation:alpha"));
899 assert!(node_ids.contains(&"claim:alpha"));
900 assert!(!node_ids.contains(&"about:question:a:dimension:conversation:beta"));
901 assert!(!node_ids.contains(&"claim:beta"));
902 assert_eq!(
903 relationships,
904 vec![(
905 "about:question:a:dimension:conversation:alpha",
906 "claim:alpha",
907 "contains_entry"
908 )]
909 );
910 }
911
912 #[test]
913 fn bundle_filter_only_pulls_support_sources_when_source_is_memory_evidence() {
914 let bundle = scoped_conversation_bundle_with_supports();
915 let selection = DimensionSelection::only(["conversation"])
916 .resolve_current_about("question:a")
917 .with_scope_ids(["conversation:alpha"]);
918
919 let filtered =
920 filter_bundle_by_memory_dimensions(&bundle, &selection).expect("bundle should filter");
921 let node_ids = filtered
922 .neighbor_nodes()
923 .iter()
924 .map(|node| node.node_id())
925 .collect::<Vec<_>>();
926 let relationships = filtered
927 .relationships()
928 .iter()
929 .map(|relationship| {
930 (
931 relationship.source_node_id(),
932 relationship.target_node_id(),
933 relationship.relationship_type(),
934 )
935 })
936 .collect::<Vec<_>>();
937
938 assert!(node_ids.contains(&"claim:alpha"));
939 assert!(node_ids.contains(&"evidence:alpha"));
940 assert!(!node_ids.contains(&"claim:beta"));
941 assert_eq!(
942 relationships,
943 vec![
944 (
945 "about:question:a:dimension:conversation:alpha",
946 "claim:alpha",
947 "contains_entry"
948 ),
949 ("evidence:alpha", "claim:alpha", "supports")
950 ]
951 );
952 }
953
954 #[test]
955 fn normalize_about_roots_trims_sorts_and_deduplicates() {
956 assert_eq!(
957 normalize_about_roots(vec![
958 " question:b ".to_string(),
959 String::new(),
960 "question:a".to_string(),
961 "question:b".to_string(),
962 ]),
963 vec!["question:a".to_string(), "question:b".to_string()]
964 );
965 }
966
967 #[test]
968 fn prioritize_current_about_keeps_all_roots_but_moves_current_first() {
969 assert_eq!(
970 prioritize_current_about(
971 vec![
972 "question:a".to_string(),
973 "question:current".to_string(),
974 "question:z".to_string(),
975 ],
976 "question:current",
977 ),
978 vec![
979 "question:current".to_string(),
980 "question:a".to_string(),
981 "question:z".to_string()
982 ]
983 );
984 }
985
986 fn scoped_conversation_bundle() -> KmpBundle {
987 KmpBundle::new(
988 CaseId::new("question:a").expect("case id should be valid"),
989 Role::new("temporal-reader").expect("role should be valid"),
990 BundleNode::new(
991 "question:a",
992 "question",
993 "Question A",
994 "Test question",
995 "ACTIVE",
996 Vec::new(),
997 BTreeMap::new(),
998 ),
999 vec![
1000 memory_dimension_node("about:question:a:dimension:conversation:alpha"),
1001 memory_dimension_node("about:question:a:dimension:conversation:beta"),
1002 claim_node("claim:alpha"),
1003 claim_node("claim:beta"),
1004 ],
1005 vec![
1006 contains_entry(
1007 "about:question:a:dimension:conversation:alpha",
1008 "claim:alpha",
1009 1,
1010 ),
1011 contains_entry(
1012 "about:question:a:dimension:conversation:beta",
1013 "claim:beta",
1014 2,
1015 ),
1016 cross_scope_constraint("claim:beta", "claim:alpha"),
1017 ],
1018 Vec::new(),
1019 BundleMetadata::initial("test"),
1020 )
1021 .expect("test bundle should be valid")
1022 }
1023
1024 fn scoped_conversation_bundle_with_supports() -> KmpBundle {
1025 KmpBundle::new(
1026 CaseId::new("question:a").expect("case id should be valid"),
1027 Role::new("temporal-reader").expect("role should be valid"),
1028 BundleNode::new(
1029 "question:a",
1030 "question",
1031 "Question A",
1032 "Test question",
1033 "ACTIVE",
1034 Vec::new(),
1035 BTreeMap::new(),
1036 ),
1037 vec![
1038 memory_dimension_node("about:question:a:dimension:conversation:alpha"),
1039 memory_dimension_node("about:question:a:dimension:conversation:beta"),
1040 claim_node("claim:alpha"),
1041 claim_node("claim:beta"),
1042 evidence_node("evidence:alpha"),
1043 ],
1044 vec![
1045 contains_entry(
1046 "about:question:a:dimension:conversation:alpha",
1047 "claim:alpha",
1048 1,
1049 ),
1050 contains_entry(
1051 "about:question:a:dimension:conversation:beta",
1052 "claim:beta",
1053 2,
1054 ),
1055 supports("claim:beta", "claim:alpha"),
1056 supports("evidence:alpha", "claim:alpha"),
1057 ],
1058 Vec::new(),
1059 BundleMetadata::initial("test"),
1060 )
1061 .expect("test bundle should be valid")
1062 }
1063
1064 fn memory_dimension_node(node_id: &str) -> BundleNode {
1065 BundleNode::new(
1066 node_id,
1067 "memory_dimension",
1068 node_id,
1069 "Conversation scope",
1070 "ACTIVE",
1071 Vec::new(),
1072 BTreeMap::new(),
1073 )
1074 }
1075
1076 fn claim_node(node_id: &str) -> BundleNode {
1077 BundleNode::new(
1078 node_id,
1079 "claim",
1080 node_id,
1081 "Claim",
1082 "ACTIVE",
1083 Vec::new(),
1084 BTreeMap::new(),
1085 )
1086 }
1087
1088 fn evidence_node(node_id: &str) -> BundleNode {
1089 BundleNode::new(
1090 node_id,
1091 "memory_evidence",
1092 node_id,
1093 "Evidence",
1094 "ACTIVE",
1095 Vec::new(),
1096 BTreeMap::new(),
1097 )
1098 }
1099
1100 fn contains_entry(scope_id: &str, target_node_id: &str, sequence: u32) -> BundleRelationship {
1101 BundleRelationship::new(
1102 scope_id,
1103 target_node_id,
1104 "contains_entry",
1105 RelationExplanation::new(RelationSemanticClass::Structural)
1106 .with_dimension("conversation")
1107 .with_scope_id(scope_id)
1108 .with_sequence(sequence),
1109 )
1110 }
1111
1112 fn cross_scope_constraint(source_node_id: &str, target_node_id: &str) -> BundleRelationship {
1113 BundleRelationship::new(
1114 source_node_id,
1115 target_node_id,
1116 "contextual_constraint",
1117 RelationExplanation::new(RelationSemanticClass::Constraint)
1118 .with_rationale("Off-scope relation must not leak through exact scope filtering.")
1119 .with_confidence("medium"),
1120 )
1121 }
1122
1123 fn supports(source_node_id: &str, target_node_id: &str) -> BundleRelationship {
1124 BundleRelationship::new(
1125 source_node_id,
1126 target_node_id,
1127 "supports",
1128 RelationExplanation::new(RelationSemanticClass::Evidential)
1129 .with_rationale("Support relation for scoped filtering.")
1130 .with_confidence("medium"),
1131 )
1132 }
1133}