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 declarations = if query.level_of_detail == super::VisualLevelOfDetail::Moment {
278 super::visual_projection::declared_equivalences(&read.context.bundle)
279 } else {
280 Vec::new()
281 };
282 let temporal = temporal_result(temporal_query, read)?;
283 let mut projection = build_visual_projection(&query, temporal, catalogue)?;
284 super::visual_projection::include_owned_declarations(&mut projection, declarations);
285 Ok(projection)
286 }
287
288 pub async fn relate(
293 &self,
294 query: RelateMemoryQuery,
295 ) -> Result<GetContextResult, ApplicationError> {
296 let render_options = memory_render_options(
297 query.token_budget,
298 query.max_tier,
299 KmpMode::ReasonPreserving,
300 EndpointHint::Neighborhood,
301 );
302 let dimensions = query.dimensions.resolve_current_about(&query.about);
303 let result = self
304 .memory_context(
305 &query.about,
306 "relater",
307 query.depth,
308 &dimensions,
309 &render_options,
310 )
311 .await?;
312 apply_dimension_selection(result, &dimensions, &render_options)
313 }
314
315 pub async fn trace(
316 &self,
317 query: TraceMemoryQuery,
318 ) -> Result<GetContextPathResult, ApplicationError> {
319 self.validate_read_members(
320 &query.about,
321 &[("from", query.from.as_str()), ("to", query.to.as_str())],
322 )
323 .await?;
324 self.query_application
325 .get_context_path(GetContextPathQuery {
326 root_node_id: query.from,
327 target_node_id: query.to,
328 role: query.role,
329 subtree_depth: Some(0),
330 render_options: ContextRenderOptions {
331 focus_node_id: None,
332 token_budget: (query.token_budget > 0).then_some(query.token_budget),
333 max_tier: Some(ResolutionTier::L2EvidencePack),
334 rehydration_mode: KmpMode::ReasonPreserving,
335 endpoint_hint: EndpointHint::FocusedPath,
336 },
337 })
338 .await
339 }
340
341 pub async fn inspect(
342 &self,
343 query: InspectMemoryQuery,
344 ) -> Result<InspectMemoryResult, ApplicationError> {
345 self.validate_read_members(&query.about, &[("ref", query.ref_id.as_str())])
346 .await?;
347 let include_incoming = query.include_incoming;
348 let include_outgoing = query.include_outgoing;
349 let include_details = query.include_details;
350 let detail = self
351 .query_application
352 .get_node_detail(GetNodeDetailQuery {
353 node_id: query.ref_id.clone(),
354 })
355 .await?;
356
357 let links = self
361 .query_application
362 .get_node_relationships(GetNodeRelationshipsQuery {
363 node_id: query.ref_id.clone(),
364 })
365 .await?;
366 let mut evidence = Vec::new();
367 let supporting_refs = links
368 .incoming
369 .iter()
370 .filter(|relationship| relationship.relationship_type == "supports")
371 .map(|relationship| relationship.source_node_id.clone())
372 .collect::<BTreeSet<_>>();
373 for evidence_ref in supporting_refs {
374 let evidence_detail = match self
375 .query_application
376 .get_node_detail(GetNodeDetailQuery {
377 node_id: evidence_ref,
378 })
379 .await
380 {
381 Ok(detail) => detail,
382 Err(ApplicationError::NotFound(_)) => continue,
385 Err(error) => return Err(error),
386 };
387 if !is_memory_evidence_kind(&evidence_detail.node.node_kind) {
388 continue;
389 }
390 evidence.push(InspectedEvidence {
391 supports: projected_evidence_supports(&evidence_detail, &query.ref_id),
392 detail: evidence_detail,
393 });
394 }
395 let raw_coordinates = if query.include_raw {
396 inspect_raw_coordinates(&query.ref_id, Some(&links))?
397 } else {
398 Vec::new()
399 };
400
401 Ok(InspectMemoryResult {
402 detail,
403 incoming: if include_incoming {
404 links.incoming.clone()
405 } else {
406 Vec::new()
407 },
408 outgoing: if include_outgoing {
409 links.outgoing.clone()
410 } else {
411 Vec::new()
412 },
413 evidence,
414 raw_coordinates,
415 include_details,
416 include_raw: query.include_raw,
417 })
418 }
419
420 async fn existing_memory_refs(
421 &self,
422 about: &str,
423 ) -> Result<ExistingMemoryRefs, ApplicationError> {
424 match self
425 .query_application
426 .get_context(GetContextQuery {
427 root_node_id: about.to_string(),
428 role: "memory".to_string(),
429 depth: MEMORY_EXISTING_REFS_LOOKUP_DEPTH,
434 requested_scopes: Vec::new(),
435 render_options: ContextRenderOptions::default(),
436 })
437 .await
438 {
439 Ok(result) => Ok(existing_refs_from_bundle(&result.bundle)),
440 Err(ApplicationError::NotFound(_)) => Ok(ExistingMemoryRefs::default()),
441 Err(error) => Err(error),
442 }
443 }
444
445 async fn validate_read_members(
446 &self,
447 about: &str,
448 members: &[(&str, &str)],
449 ) -> Result<(), ApplicationError> {
450 let mut graph_members = Vec::new();
451 for (path, member_ref) in members {
452 validate_ref_token(path, member_ref).map_err(ApplicationError::Validation)?;
453 if validate_supplied_member_ref(about, path, member_ref).is_err() {
454 graph_members.push((*path, *member_ref));
455 }
456 }
457 if graph_members.is_empty() {
458 return Ok(());
459 }
460
461 let visible = match self
462 .query_application
463 .get_context(GetContextQuery {
464 root_node_id: about.to_string(),
465 role: "memory-boundary".to_string(),
466 depth: MAX_NATIVE_GRAPH_TRAVERSAL_DEPTH,
467 requested_scopes: Vec::new(),
468 render_options: ContextRenderOptions::default(),
469 })
470 .await
471 {
472 Ok(result) => bundle_node_ids(&result.bundle),
473 Err(ApplicationError::NotFound(_)) => BTreeSet::new(),
474 Err(error) => return Err(error),
475 };
476 for (path, member_ref) in graph_members {
477 if !visible.contains(member_ref) {
478 return Err(ApplicationError::Validation(format!(
479 "`{path}` `{member_ref}` does not belong to about `{about}`"
480 )));
481 }
482 }
483 Ok(())
484 }
485
486 async fn memory_context(
487 &self,
488 about: &str,
489 role: &str,
490 depth: u32,
491 dimensions: &DimensionSelection,
492 render_options: &ContextRenderOptions,
493 ) -> Result<GetContextResult, ApplicationError> {
494 let roots = self.memory_context_roots(about, dimensions).await?;
495 let requested_scopes = requested_dimension_scopes(about, dimensions, &roots);
496 let mut results = Vec::new();
497 for root in &roots {
498 results.push(
499 self.query_application
500 .get_context(GetContextQuery {
501 root_node_id: root.clone(),
502 role: role.to_string(),
503 depth,
504 requested_scopes: requested_scopes.clone(),
505 render_options: render_options.clone(),
506 })
507 .await?,
508 );
509 }
510
511 merge_context_results(results, render_options)
512 }
513
514 async fn memory_context_roots(
515 &self,
516 current_about: &str,
517 selection: &DimensionSelection,
518 ) -> Result<Vec<String>, ApplicationError> {
519 if selection.scope_mode() != DimensionScopeMode::AllAbouts {
520 return context_roots(current_about, selection);
521 }
522
523 let roots = if should_filter_all_abouts_by_dimensions(selection) {
524 let dimension_ids = index_dimension_ids(selection);
529 self.query_application
530 .list_memory_abouts_by_dimensions(&dimension_ids)
531 .await?
532 } else {
533 self.query_application.list_memory_abouts().await?
534 };
535
536 let roots = prioritize_current_about(normalize_about_roots(roots), current_about);
537 if roots.is_empty() {
538 return Err(ApplicationError::NotFound(
539 "no memory abouts found for ALL_ABOUTS scope".to_string(),
540 ));
541 }
542 Ok(roots)
543 }
544}
545
546struct TemporalRead {
549 context: GetContextResult,
550 dimensions: DimensionSelection,
551}
552
553fn temporal_result(
555 query: TemporalMemoryQuery,
556 read: TemporalRead,
557) -> Result<TemporalMemoryResult, ApplicationError> {
558 let TemporalRead {
559 context,
560 dimensions,
561 } = read;
562 let quality = context.rendered.quality.clone();
563 let source_bundle = filter_bundle_by_memory_dimensions(&context.bundle, &dimensions)?;
564
565 let request = TemporalTraversalRequest::new(query.direction, query.cursor)
566 .with_axis(query.axis)
567 .with_dimensions(dimensions.clone())
568 .with_requested_dimensions(query.dimensions.clone())
569 .with_window(query.window);
570 let request = if let Some(limit_entries) = query.limit_entries {
571 request.with_limit_entries(limit_entries)?
572 } else {
573 request
574 };
575
576 let traversal = TemporalMemoryTraversal::traverse(&context.bundle, &request)?;
580
581 Ok(TemporalMemoryResult {
582 traversal,
583 source_bundle,
584 include: query.include,
585 quality,
586 })
587}
588
589fn bundle_node_ids(bundle: &KmpBundle) -> BTreeSet<String> {
590 std::iter::once(bundle.root_node().node_id())
591 .chain(bundle.neighbor_nodes().iter().map(BundleNode::node_id))
592 .map(ToString::to_string)
593 .collect()
594}
595
596fn projected_evidence_supports(
597 evidence: &crate::queries::GetNodeDetailResult,
598 inspected_ref: &str,
599) -> Vec<String> {
600 evidence
601 .node
602 .properties
603 .get("payload_supports")
604 .and_then(|value| serde_json::from_str::<Vec<String>>(value).ok())
605 .filter(|supports| !supports.is_empty())
606 .unwrap_or_else(|| vec![inspected_ref.to_string()])
607}
608
609fn should_filter_all_abouts_by_dimensions(selection: &DimensionSelection) -> bool {
610 selection.scope_mode() == DimensionScopeMode::AllAbouts
611 && ((selection.mode() == DimensionSelectionMode::Only
612 && !selection.dimensions().is_empty())
613 || !selection.scope_ids().is_empty()
614 || selection.selectors().iter().any(LabelSelector::is_positive))
615}
616
617fn index_dimension_ids(selection: &DimensionSelection) -> Vec<String> {
618 let mut dimension_ids = Vec::new();
619 if selection.mode() == DimensionSelectionMode::Only {
620 dimension_ids.extend(selection.dimensions().iter().cloned());
621 }
622 dimension_ids.extend(selection.scope_ids().iter().cloned());
623 dimension_ids.extend(selection.positive_selector_ids());
624 dimension_ids
625}
626
627fn inspect_raw_coordinates(
628 ref_id: &str,
629 links: Option<&crate::queries::GetNodeRelationshipsResult>,
630) -> Result<Vec<TemporalCoordinate>, ApplicationError> {
631 let Some(links) = links else {
632 return Ok(Vec::new());
633 };
634
635 let mut coordinates = Vec::new();
636 for relationship in links.incoming.iter().chain(links.outgoing.iter()) {
637 if relationship.relationship_type != "contains_entry"
638 || relationship.target_node_id != ref_id
639 {
640 continue;
641 }
642 if let Some(coordinate) =
643 TemporalCoordinate::from_relation_explanation(&relationship.explanation)?
644 {
645 coordinates.push(coordinate);
646 }
647 }
648
649 Ok(coordinates)
650}
651
652fn memory_render_options(
653 token_budget: u32,
654 max_tier: Option<ResolutionTier>,
655 rehydration_mode: KmpMode,
656 endpoint_hint: EndpointHint,
657) -> ContextRenderOptions {
658 ContextRenderOptions {
659 focus_node_id: None,
660 token_budget: (token_budget > 0).then_some(token_budget),
661 max_tier,
662 rehydration_mode,
663 endpoint_hint,
664 }
665}
666
667fn requested_dimension_scopes(
668 current_about: &str,
669 selection: &DimensionSelection,
670 context_roots: &[String],
671) -> Vec<String> {
672 if !selection.scope_ids().is_empty() {
673 return requested_explicit_dimension_scopes(current_about, selection, context_roots);
674 }
675
676 match selection.mode() {
677 DimensionSelectionMode::Only => match selection.scope_mode() {
678 DimensionScopeMode::CurrentAbout => selection
679 .dimensions()
680 .iter()
681 .filter_map(|dimension| namespaced_dimension_id(current_about, dimension))
682 .collect(),
683 DimensionScopeMode::Abouts => selection
684 .abouts()
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 DimensionScopeMode::AllAbouts => context_roots
695 .iter()
696 .flat_map(|about| {
697 selection
698 .dimensions()
699 .iter()
700 .filter_map(|dimension| namespaced_dimension_id(about, dimension))
701 .collect::<Vec<_>>()
702 })
703 .collect(),
704 },
705 DimensionSelectionMode::Except | DimensionSelectionMode::All => Vec::new(),
706 }
707}
708
709fn requested_explicit_dimension_scopes(
710 current_about: &str,
711 selection: &DimensionSelection,
712 context_roots: &[String],
713) -> Vec<String> {
714 let abouts = match selection.scope_mode() {
715 DimensionScopeMode::CurrentAbout => vec![current_about.to_string()],
716 DimensionScopeMode::Abouts => selection.abouts().iter().cloned().collect(),
717 DimensionScopeMode::AllAbouts => context_roots.to_vec(),
718 };
719
720 abouts
721 .iter()
722 .flat_map(|about| {
723 selection
724 .scope_ids()
725 .iter()
726 .filter_map(|scope_id| resolve_dimension_scope_id(about, scope_id))
727 .collect::<Vec<_>>()
728 })
729 .collect::<BTreeSet<_>>()
730 .into_iter()
731 .collect()
732}
733
734fn context_roots(
735 current_about: &str,
736 selection: &DimensionSelection,
737) -> Result<Vec<String>, ApplicationError> {
738 match selection.scope_mode() {
739 DimensionScopeMode::Abouts if !selection.abouts().is_empty() => {
740 Ok(selection.abouts().iter().cloned().collect())
741 }
742 DimensionScopeMode::CurrentAbout => Ok(vec![current_about.to_string()]),
743 DimensionScopeMode::Abouts => Err(ApplicationError::Validation(
744 "dimension scope ABOUTS requires at least one about".to_string(),
745 )),
746 DimensionScopeMode::AllAbouts => Err(ApplicationError::Validation(
747 "dimension scope ALL_ABOUTS must be resolved through the memory about index"
748 .to_string(),
749 )),
750 }
751}
752
753fn apply_dimension_selection(
754 mut result: GetContextResult,
755 dimensions: &DimensionSelection,
756 render_options: &ContextRenderOptions,
757) -> Result<GetContextResult, ApplicationError> {
758 result.bundle = filter_bundle_by_memory_dimensions(&result.bundle, dimensions)?;
759 result.rendered = render_graph_bundle_with_options(&result.bundle, render_options);
760 Ok(result)
761}
762
763fn normalize_about_roots(values: Vec<String>) -> Vec<String> {
764 values
765 .into_iter()
766 .map(|value| value.trim().to_string())
767 .filter(|value| !value.is_empty())
768 .collect::<BTreeSet<_>>()
769 .into_iter()
770 .collect()
771}
772
773fn prioritize_current_about(mut roots: Vec<String>, current_about: &str) -> Vec<String> {
774 let current_about = current_about.trim();
775 if current_about.is_empty() {
776 return roots;
777 }
778 if let Some(position) = roots.iter().position(|root| root == current_about) {
779 let root = roots.remove(position);
780 roots.insert(0, root);
781 }
782 roots
783}
784
785fn filter_bundle_by_memory_dimensions(
786 bundle: &KmpBundle,
787 dimensions: &DimensionSelection,
788) -> Result<KmpBundle, ApplicationError> {
789 let mut included_node_ids = BTreeSet::from([bundle.root_node().node_id().to_string()]);
790 let mut selected_entry_ids = BTreeSet::new();
791 let node_kinds = bundle_node_kinds(bundle);
792 let labels = labels_by_entry(bundle);
793
794 for relationship in bundle
795 .relationships()
796 .iter()
797 .filter(|relationship| relationship.relationship_type() == "contains_entry")
798 {
799 if contains_entry_selected(relationship, dimensions, &labels) {
800 included_node_ids.insert(relationship.source_node_id().to_string());
801 included_node_ids.insert(relationship.target_node_id().to_string());
802 selected_entry_ids.insert(relationship.target_node_id().to_string());
803 }
804 }
805
806 for relationship in bundle.relationships().iter().filter(|relationship| {
807 relationship.relationship_type() == "supports"
808 && selected_entry_ids.contains(relationship.target_node_id())
809 && node_kinds
810 .get(relationship.source_node_id())
811 .is_some_and(|kind| is_memory_evidence_kind(kind))
812 }) {
813 included_node_ids.insert(relationship.source_node_id().to_string());
814 }
815
816 let neighbor_nodes = bundle
817 .neighbor_nodes()
818 .iter()
819 .filter(|node| included_node_ids.contains(node.node_id()))
820 .cloned()
821 .collect::<Vec<_>>();
822 let relationships = bundle
823 .relationships()
824 .iter()
825 .filter(|relationship| {
826 if relationship.relationship_type() == "contains_entry" {
827 return contains_entry_selected(relationship, dimensions, &labels);
828 }
829 included_node_ids.contains(relationship.source_node_id())
830 && included_node_ids.contains(relationship.target_node_id())
831 })
832 .cloned()
833 .collect::<Vec<_>>();
834 let node_details = bundle
835 .node_details()
836 .iter()
837 .filter(|detail| included_node_ids.contains(detail.node_id()))
838 .cloned()
839 .collect::<Vec<_>>();
840
841 KmpBundle::new(
842 bundle.root_node_id().clone(),
843 bundle.role().clone(),
844 bundle.root_node().clone(),
845 neighbor_nodes,
846 relationships,
847 node_details,
848 bundle.metadata().clone(),
849 )
850 .map_err(Into::into)
851}
852
853fn contains_entry_selected(
857 relationship: &BundleRelationship,
858 dimensions: &DimensionSelection,
859 labels: &BTreeMap<String, EntryLabels>,
860) -> bool {
861 let explanation = relationship.explanation();
862 let coordinate_passes = dimensions.includes_coordinate(
863 explanation.dimension().unwrap_or_default(),
864 explanation.scope_id().unwrap_or_default(),
865 );
866 coordinate_passes
867 && (!dimensions.has_selectors()
868 || dimensions.admits(
869 labels
870 .get(relationship.target_node_id())
871 .unwrap_or(&EntryLabels::default()),
872 ))
873}
874
875fn bundle_node_kinds(bundle: &KmpBundle) -> BTreeMap<&str, &str> {
876 let mut node_kinds =
877 BTreeMap::from([(bundle.root_node().node_id(), bundle.root_node().node_kind())]);
878 for node in bundle.neighbor_nodes() {
879 node_kinds.insert(node.node_id(), node.node_kind());
880 }
881 node_kinds
882}
883
884fn is_memory_evidence_kind(kind: &str) -> bool {
885 matches!(kind, "memory_evidence" | "evidence")
886}
887
888fn existing_refs_from_bundle(bundle: &KmpBundle) -> ExistingMemoryRefs {
889 let mut refs = BTreeSet::from([bundle.root_node().node_id().to_string()]);
890 let mut dimensions = BTreeSet::new();
891 let mut max_sequences = BTreeMap::new();
892
893 let mut labels = BTreeSet::new();
894 for node in bundle.neighbor_nodes() {
895 refs.insert(node.node_id().to_string());
896 if node.node_kind() == "memory_dimension" {
897 dimensions.insert(node.node_id().to_string());
898 if let Some(kind) = node.properties().get("dimension_kind") {
899 let value = MemoryDimensionIdentity::parse(node.node_id())
900 .map(|identity| identity.dimension_id().to_string())
901 .unwrap_or_else(|| node.node_id().to_string());
902 labels.insert((kind.clone(), value));
903 }
904 }
905 }
906
907 for relationship in bundle
908 .relationships()
909 .iter()
910 .filter(|relationship| relationship.relationship_type() == "contains_entry")
911 {
912 dimensions.insert(relationship.source_node_id().to_string());
913 let explanation = relationship.explanation();
914 if let (Some(dimension), Some(scope_id), Some(sequence)) = (
915 explanation.dimension(),
916 explanation.scope_id(),
917 explanation.sequence(),
918 ) {
919 max_sequences
920 .entry((dimension.to_string(), scope_id.to_string()))
921 .and_modify(|current: &mut u32| *current = (*current).max(sequence))
922 .or_insert(sequence);
923 }
924 }
925
926 ExistingMemoryRefs {
927 refs,
928 dimensions,
929 labels,
930 max_sequences,
931 foreign: BTreeSet::new(),
932 }
933}
934
935fn merge_context_results(
936 mut results: Vec<GetContextResult>,
937 render_options: &ContextRenderOptions,
938) -> Result<GetContextResult, ApplicationError> {
939 let mut result = results.remove(0);
940 if results.is_empty() {
941 return Ok(result);
942 }
943
944 let mut node_ids = BTreeSet::from([result.bundle.root_node().node_id().to_string()]);
945 let mut neighbor_nodes = result.bundle.neighbor_nodes().to_vec();
946 for node in &neighbor_nodes {
947 node_ids.insert(node.node_id().to_string());
948 }
949
950 let mut relationships = result.bundle.relationships().to_vec();
951 let mut relationship_ids = relationships
952 .iter()
953 .map(relationship_key)
954 .collect::<BTreeSet<_>>();
955 let mut node_details = result.bundle.node_details().to_vec();
956 let mut detail_ids = node_details
957 .iter()
958 .map(|detail| detail.node_id().to_string())
959 .collect::<BTreeSet<_>>();
960
961 for other in results {
962 push_node(&mut neighbor_nodes, &mut node_ids, other.bundle.root_node());
963 for node in other.bundle.neighbor_nodes() {
964 push_node(&mut neighbor_nodes, &mut node_ids, node);
965 }
966 for relationship in other.bundle.relationships() {
967 if relationship_ids.insert(relationship_key(relationship)) {
968 relationships.push(relationship.clone());
969 }
970 }
971 for detail in other.bundle.node_details() {
972 if detail_ids.insert(detail.node_id().to_string()) {
973 node_details.push(detail.clone());
974 }
975 }
976 }
977
978 result.bundle = KmpBundle::new(
979 result.bundle.root_node_id().clone(),
980 result.bundle.role().clone(),
981 result.bundle.root_node().clone(),
982 neighbor_nodes,
983 relationships,
984 node_details,
985 result.bundle.metadata().clone(),
986 )
987 .map_err(ApplicationError::Domain)?;
988 result.rendered = render_graph_bundle_with_options(&result.bundle, render_options);
989 Ok(result)
990}
991
992fn push_node(
993 neighbor_nodes: &mut Vec<BundleNode>,
994 node_ids: &mut BTreeSet<String>,
995 node: &BundleNode,
996) {
997 if node_ids.insert(node.node_id().to_string()) {
998 neighbor_nodes.push(node.clone());
999 }
1000}
1001
1002fn relationship_key(relationship: &BundleRelationship) -> (String, String, String) {
1003 (
1004 relationship.source_node_id().to_string(),
1005 relationship.target_node_id().to_string(),
1006 relationship.relationship_type().to_string(),
1007 )
1008}
1009
1010fn namespaced_dimension_id(about: &str, dimension: &str) -> Option<String> {
1011 MemoryDimensionIdentity::new(about, dimension)
1012 .ok()
1013 .map(|identity| identity.node_id())
1014}
1015
1016fn resolve_dimension_scope_id(about: &str, scope_id: &str) -> Option<String> {
1017 let scope_id = scope_id.trim();
1018 if scope_id.is_empty() {
1019 return None;
1020 }
1021 MemoryDimensionIdentity::resolve(about, scope_id).map(|identity| identity.node_id())
1022}
1023
1024#[cfg(test)]
1025mod tests {
1026 use std::collections::BTreeMap;
1027
1028 use kmp_domain::{BundleMetadata, CaseId, RelationExplanation, RelationSemanticClass, Role};
1029
1030 use super::*;
1031
1032 #[test]
1033 fn all_abouts_scope_requires_about_index_resolution() {
1034 let selection = DimensionSelection::all().with_all_about_scope();
1035 let error = context_roots("question:current", &selection)
1036 .expect_err("ALL_ABOUTS must not fall back to current about directly");
1037
1038 assert!(matches!(
1039 error,
1040 ApplicationError::Validation(message)
1041 if message.contains("resolved through the memory about index")
1042 ));
1043 }
1044
1045 #[test]
1046 fn requested_scopes_expands_all_abouts_from_indexed_roots() {
1047 let selection = DimensionSelection::only(["timeline"]).with_all_about_scope();
1048 let scopes = requested_dimension_scopes(
1049 "question:current",
1050 &selection,
1051 &["question:a".to_string(), "question:b".to_string()],
1052 );
1053
1054 assert_eq!(
1055 scopes,
1056 vec![
1057 "about:question:a:dimension:timeline".to_string(),
1058 "about:question:b:dimension:timeline".to_string()
1059 ]
1060 );
1061 }
1062
1063 #[test]
1064 fn requested_scopes_expand_explicit_scope_ids_against_selected_abouts() {
1065 let selection = DimensionSelection::only(["conversation"])
1066 .with_about_scope(["question:a", "question:b"])
1067 .with_scope_ids([
1068 "conversation:alpha",
1069 "about:question:b:dimension:conversation:beta",
1070 ]);
1071 let scopes = requested_dimension_scopes("question:current", &selection, &[]);
1072
1073 assert_eq!(
1074 scopes,
1075 vec![
1076 "about:question:a:dimension:conversation:alpha".to_string(),
1077 "about:question:b:dimension:conversation:alpha".to_string(),
1078 "about:question:b:dimension:conversation:beta".to_string()
1079 ]
1080 );
1081 }
1082
1083 #[test]
1084 fn bundle_filter_narrows_same_dimension_kind_by_exact_scope_id() {
1085 let bundle = scoped_conversation_bundle();
1086 let selection = DimensionSelection::only(["conversation"])
1087 .resolve_current_about("question:a")
1088 .with_scope_ids(["conversation:alpha"]);
1089
1090 let filtered =
1091 filter_bundle_by_memory_dimensions(&bundle, &selection).expect("bundle should filter");
1092 let node_ids = filtered
1093 .neighbor_nodes()
1094 .iter()
1095 .map(|node| node.node_id())
1096 .collect::<Vec<_>>();
1097 let relationships = filtered
1098 .relationships()
1099 .iter()
1100 .map(|relationship| {
1101 (
1102 relationship.source_node_id(),
1103 relationship.target_node_id(),
1104 relationship.relationship_type(),
1105 )
1106 })
1107 .collect::<Vec<_>>();
1108
1109 assert!(node_ids.contains(&"about:question:a:dimension:conversation:alpha"));
1110 assert!(node_ids.contains(&"claim:alpha"));
1111 assert!(!node_ids.contains(&"about:question:a:dimension:conversation:beta"));
1112 assert!(!node_ids.contains(&"claim:beta"));
1113 assert_eq!(
1114 relationships,
1115 vec![(
1116 "about:question:a:dimension:conversation:alpha",
1117 "claim:alpha",
1118 "contains_entry"
1119 )]
1120 );
1121 }
1122
1123 #[test]
1124 fn bundle_filter_only_pulls_support_sources_when_source_is_memory_evidence() {
1125 let bundle = scoped_conversation_bundle_with_supports();
1126 let selection = DimensionSelection::only(["conversation"])
1127 .resolve_current_about("question:a")
1128 .with_scope_ids(["conversation:alpha"]);
1129
1130 let filtered =
1131 filter_bundle_by_memory_dimensions(&bundle, &selection).expect("bundle should filter");
1132 let node_ids = filtered
1133 .neighbor_nodes()
1134 .iter()
1135 .map(|node| node.node_id())
1136 .collect::<Vec<_>>();
1137 let relationships = filtered
1138 .relationships()
1139 .iter()
1140 .map(|relationship| {
1141 (
1142 relationship.source_node_id(),
1143 relationship.target_node_id(),
1144 relationship.relationship_type(),
1145 )
1146 })
1147 .collect::<Vec<_>>();
1148
1149 assert!(node_ids.contains(&"claim:alpha"));
1150 assert!(node_ids.contains(&"evidence:alpha"));
1151 assert!(!node_ids.contains(&"claim:beta"));
1152 assert_eq!(
1153 relationships,
1154 vec![
1155 (
1156 "about:question:a:dimension:conversation:alpha",
1157 "claim:alpha",
1158 "contains_entry"
1159 ),
1160 ("evidence:alpha", "claim:alpha", "supports")
1161 ]
1162 );
1163 }
1164
1165 #[test]
1166 fn normalize_about_roots_trims_sorts_and_deduplicates() {
1167 assert_eq!(
1168 normalize_about_roots(vec![
1169 " question:b ".to_string(),
1170 String::new(),
1171 "question:a".to_string(),
1172 "question:b".to_string(),
1173 ]),
1174 vec!["question:a".to_string(), "question:b".to_string()]
1175 );
1176 }
1177
1178 #[test]
1181 fn a_sweep_is_narrowed_by_kinds_or_by_exact_scopes() {
1182 let by_kind = DimensionSelection::only(["incident"]).with_all_about_scope();
1183 assert!(should_filter_all_abouts_by_dimensions(&by_kind));
1184 let by_scope = DimensionSelection::all()
1185 .with_all_about_scope()
1186 .with_scope_ids(["incident:north-outage"]);
1187 assert!(should_filter_all_abouts_by_dimensions(&by_scope));
1188 let whole_sweep = DimensionSelection::all().with_all_about_scope();
1189 assert!(!should_filter_all_abouts_by_dimensions(&whole_sweep));
1190 let inside_one_about = DimensionSelection::only(["incident"]);
1191 assert!(!should_filter_all_abouts_by_dimensions(&inside_one_about));
1192 }
1193
1194 #[test]
1195 fn prioritize_current_about_keeps_all_roots_but_moves_current_first() {
1196 assert_eq!(
1197 prioritize_current_about(
1198 vec![
1199 "question:a".to_string(),
1200 "question:current".to_string(),
1201 "question:z".to_string(),
1202 ],
1203 "question:current",
1204 ),
1205 vec![
1206 "question:current".to_string(),
1207 "question:a".to_string(),
1208 "question:z".to_string()
1209 ]
1210 );
1211 }
1212
1213 #[test]
1214 fn bundle_filter_reads_selectors_over_the_entry_not_the_coordinate() {
1215 use kmp_domain::LabelSelectorOperator;
1216
1217 let bundle = scoped_conversation_bundle();
1218 let keeps = |selection: DimensionSelection| {
1219 filter_bundle_by_memory_dimensions(&bundle, &selection)
1220 .expect("bundle should filter")
1221 .relationships()
1222 .iter()
1223 .filter(|relationship| relationship.relationship_type() == "contains_entry")
1224 .map(|relationship| relationship.target_node_id().to_string())
1225 .collect::<Vec<_>>()
1226 };
1227 let selector = |key: &str, operator: LabelSelectorOperator, values: &[&str]| {
1228 LabelSelector::new(key, operator, values.iter().copied()).expect("selector")
1229 };
1230
1231 assert_eq!(
1232 keeps(DimensionSelection::all().with_selectors([selector(
1233 "conversation",
1234 LabelSelectorOperator::In,
1235 &["conversation:alpha"]
1236 )])),
1237 vec!["claim:alpha"]
1238 );
1239 assert_eq!(
1240 keeps(DimensionSelection::all().with_selectors([selector(
1241 "conversation",
1242 LabelSelectorOperator::NotIn,
1243 &["conversation:alpha"]
1244 )])),
1245 vec!["claim:beta"]
1246 );
1247 assert!(
1248 keeps(DimensionSelection::all().with_selectors([selector(
1249 "conversation",
1250 LabelSelectorOperator::NotExists,
1251 &[]
1252 )]))
1253 .is_empty()
1254 );
1255 assert_eq!(
1256 keeps(DimensionSelection::all().with_selectors([selector(
1257 "conversation",
1258 LabelSelectorOperator::Exists,
1259 &[]
1260 )])),
1261 vec!["claim:alpha", "claim:beta"]
1262 );
1263 }
1264
1265 #[test]
1266 fn all_abouts_index_reads_positive_selectors_and_never_the_except_list() {
1267 use kmp_domain::LabelSelectorOperator;
1268
1269 let by_selector = DimensionSelection::all()
1270 .with_all_about_scope()
1271 .with_selectors([
1272 LabelSelector::new(
1273 "incident",
1274 LabelSelectorOperator::Exists,
1275 Vec::<String>::new(),
1276 )
1277 .expect("selector"),
1278 LabelSelector::new("env", LabelSelectorOperator::In, ["prod"]).expect("selector"),
1279 ]);
1280 assert!(should_filter_all_abouts_by_dimensions(&by_selector));
1281 assert_eq!(index_dimension_ids(&by_selector), vec!["incident", "prod"]);
1282
1283 let negative_only = DimensionSelection::except(["task"])
1284 .with_all_about_scope()
1285 .with_selectors([LabelSelector::new(
1286 "customer",
1287 LabelSelectorOperator::NotIn,
1288 ["acme"],
1289 )
1290 .expect("selector")]);
1291 assert!(!should_filter_all_abouts_by_dimensions(&negative_only));
1292 assert!(index_dimension_ids(&negative_only).is_empty());
1293 }
1294
1295 fn scoped_conversation_bundle() -> KmpBundle {
1296 KmpBundle::new(
1297 CaseId::new("question:a").expect("case id should be valid"),
1298 Role::new("temporal-reader").expect("role should be valid"),
1299 BundleNode::new(
1300 "question:a",
1301 "question",
1302 "Question A",
1303 "Test question",
1304 "ACTIVE",
1305 Vec::new(),
1306 BTreeMap::new(),
1307 ),
1308 vec![
1309 memory_dimension_node("about:question:a:dimension:conversation:alpha"),
1310 memory_dimension_node("about:question:a:dimension:conversation:beta"),
1311 claim_node("claim:alpha"),
1312 claim_node("claim:beta"),
1313 ],
1314 vec![
1315 contains_entry(
1316 "about:question:a:dimension:conversation:alpha",
1317 "claim:alpha",
1318 1,
1319 ),
1320 contains_entry(
1321 "about:question:a:dimension:conversation:beta",
1322 "claim:beta",
1323 2,
1324 ),
1325 cross_scope_constraint("claim:beta", "claim:alpha"),
1326 ],
1327 Vec::new(),
1328 BundleMetadata::initial("test"),
1329 )
1330 .expect("test bundle should be valid")
1331 }
1332
1333 fn scoped_conversation_bundle_with_supports() -> KmpBundle {
1334 KmpBundle::new(
1335 CaseId::new("question:a").expect("case id should be valid"),
1336 Role::new("temporal-reader").expect("role should be valid"),
1337 BundleNode::new(
1338 "question:a",
1339 "question",
1340 "Question A",
1341 "Test question",
1342 "ACTIVE",
1343 Vec::new(),
1344 BTreeMap::new(),
1345 ),
1346 vec![
1347 memory_dimension_node("about:question:a:dimension:conversation:alpha"),
1348 memory_dimension_node("about:question:a:dimension:conversation:beta"),
1349 claim_node("claim:alpha"),
1350 claim_node("claim:beta"),
1351 evidence_node("evidence:alpha"),
1352 ],
1353 vec![
1354 contains_entry(
1355 "about:question:a:dimension:conversation:alpha",
1356 "claim:alpha",
1357 1,
1358 ),
1359 contains_entry(
1360 "about:question:a:dimension:conversation:beta",
1361 "claim:beta",
1362 2,
1363 ),
1364 supports("claim:beta", "claim:alpha"),
1365 supports("evidence:alpha", "claim:alpha"),
1366 ],
1367 Vec::new(),
1368 BundleMetadata::initial("test"),
1369 )
1370 .expect("test bundle should be valid")
1371 }
1372
1373 fn memory_dimension_node(node_id: &str) -> BundleNode {
1374 BundleNode::new(
1375 node_id,
1376 "memory_dimension",
1377 node_id,
1378 "Conversation scope",
1379 "ACTIVE",
1380 Vec::new(),
1381 BTreeMap::new(),
1382 )
1383 }
1384
1385 fn claim_node(node_id: &str) -> BundleNode {
1386 BundleNode::new(
1387 node_id,
1388 "claim",
1389 node_id,
1390 "Claim",
1391 "ACTIVE",
1392 Vec::new(),
1393 BTreeMap::new(),
1394 )
1395 }
1396
1397 fn evidence_node(node_id: &str) -> BundleNode {
1398 BundleNode::new(
1399 node_id,
1400 "memory_evidence",
1401 node_id,
1402 "Evidence",
1403 "ACTIVE",
1404 Vec::new(),
1405 BTreeMap::new(),
1406 )
1407 }
1408
1409 fn contains_entry(scope_id: &str, target_node_id: &str, sequence: u32) -> BundleRelationship {
1410 BundleRelationship::new(
1411 scope_id,
1412 target_node_id,
1413 "contains_entry",
1414 RelationExplanation::new(RelationSemanticClass::Structural)
1415 .with_dimension("conversation")
1416 .with_scope_id(scope_id)
1417 .with_sequence(sequence),
1418 )
1419 }
1420
1421 fn cross_scope_constraint(source_node_id: &str, target_node_id: &str) -> BundleRelationship {
1422 BundleRelationship::new(
1423 source_node_id,
1424 target_node_id,
1425 "contextual_constraint",
1426 RelationExplanation::new(RelationSemanticClass::Constraint)
1427 .with_rationale("Off-scope relation must not leak through exact scope filtering.")
1428 .with_confidence("medium"),
1429 )
1430 }
1431
1432 fn supports(source_node_id: &str, target_node_id: &str) -> BundleRelationship {
1433 BundleRelationship::new(
1434 source_node_id,
1435 target_node_id,
1436 "supports",
1437 RelationExplanation::new(RelationSemanticClass::Evidential)
1438 .with_rationale("Support relation for scoped filtering.")
1439 .with_confidence("medium"),
1440 )
1441 }
1442}