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