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