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.receipt_ref = None;
89 outcome
90 .warnings
91 .push("dry_run=true; validated memory without writing to the kernel".to_string());
92 return Ok(outcome);
93 }
94
95 let accepted = self
96 .command_application
97 .update_context(update_context)
98 .await?;
99 outcome.read_after_write_ready = true;
100 outcome.warnings.extend(accepted.warnings);
101 Ok(outcome)
102 }
103
104 pub async fn relabel(
109 &self,
110 command: MemoryRelabelCommand,
111 ) -> Result<MemoryRelabelOutcome, ApplicationError> {
112 let existing = self.existing_memory_refs(&command.about).await?;
113 let current = self
114 .entry_coordinates(&command.about, &command.ref_id, &existing)
115 .await?;
116 if !command.idempotency_key.trim().is_empty()
120 && let Some(accepted) = self
121 .command_application
122 .accepted_outcome(&command.idempotency_key)
123 .await?
124 {
125 if accepted.logical_digest.as_deref() != Some(relabel_logical_digest(&command).as_str())
126 {
127 return Err(ApplicationError::Ports(kmp_domain::PortError::Conflict(
128 format!(
129 "idempotency key '{}' was already accepted with different content",
130 command.idempotency_key
131 ),
132 )));
133 }
134 return replayed_relabel_outcome(&command, ¤t);
135 }
136 let (update_context, mut outcome) =
137 translate_memory_relabel(&command, &existing, ¤t)?;
138 if command.dry_run {
139 outcome.warnings.push(
140 "dry_run=true; validated the relabel against the store without writing to the kernel"
141 .to_string(),
142 );
143 return Ok(outcome);
144 }
145
146 let accepted = self
147 .command_application
148 .update_context(update_context)
149 .await?;
150 outcome.read_after_write_ready = true;
151 outcome.warnings.extend(accepted.warnings);
152 Ok(outcome)
153 }
154
155 async fn entry_coordinates(
158 &self,
159 about: &str,
160 ref_id: &str,
161 existing: &ExistingMemoryRefs,
162 ) -> Result<Vec<TemporalCoordinate>, ApplicationError> {
163 validate_supplied_entry_ref(about, "ref", ref_id).map_err(ApplicationError::Validation)?;
164 if !existing.refs.contains(ref_id) {
165 return Err(ApplicationError::NotFound(format!(
166 "`{ref_id}` is not a memory of `{about}`"
167 )));
168 }
169 let links = self
170 .query_application
171 .get_node_relationships(GetNodeRelationshipsQuery {
172 node_id: ref_id.to_string(),
173 })
174 .await?;
175 inspect_raw_coordinates(ref_id, Some(&links))
176 }
177
178 pub async fn list_abouts(&self) -> Result<Vec<String>, ApplicationError> {
181 self.query_application.list_memory_abouts().await
182 }
183
184 pub async fn wake(&self, query: WakeMemoryQuery) -> Result<GetContextResult, ApplicationError> {
185 let render_options = memory_render_options(
186 query.token_budget,
187 query.max_tier,
188 KmpMode::ResumeFocused,
189 EndpointHint::Neighborhood,
190 );
191 let dimensions = query.dimensions.resolve_current_about(&query.about);
192 let result = self
193 .memory_context(
194 &query.about,
195 &query.role,
196 query.depth,
197 &dimensions,
198 &render_options,
199 )
200 .await?;
201 apply_dimension_selection(result, &dimensions, &render_options)
202 }
203
204 pub async fn ask(&self, query: AskMemoryQuery) -> Result<GetContextResult, ApplicationError> {
205 let render_options = memory_render_options(
206 query.token_budget,
207 query.max_tier,
208 KmpMode::ReasonPreserving,
209 EndpointHint::Neighborhood,
210 );
211 let dimensions = query.dimensions.resolve_current_about(&query.about);
212 let result = self
213 .memory_context(
214 &query.about,
215 "answerer",
216 query.depth,
217 &dimensions,
218 &render_options,
219 )
220 .await?;
221 apply_dimension_selection(result, &dimensions, &render_options)
222 }
223
224 pub async fn temporal(
225 &self,
226 query: TemporalMemoryQuery,
227 ) -> Result<TemporalMemoryResult, ApplicationError> {
228 let read = self.temporal_read(&query).await?;
229 temporal_result(query, read)
230 }
231
232 async fn temporal_read(
236 &self,
237 query: &TemporalMemoryQuery,
238 ) -> Result<TemporalRead, ApplicationError> {
239 let render_options = memory_render_options(
240 query.token_budget,
241 query.max_tier,
242 KmpMode::ReasonPreserving,
243 EndpointHint::Neighborhood,
244 );
245 let dimensions = query.dimensions.resolve_current_about(&query.about);
246 let context = self
247 .memory_context(
248 &query.about,
249 "temporal-reader",
250 query.depth,
251 &dimensions,
252 &render_options,
253 )
254 .await?;
255 Ok(TemporalRead {
256 context,
257 dimensions,
258 })
259 }
260
261 pub async fn visual_projection(
268 &self,
269 query: VisualProjectionQuery,
270 ) -> Result<VisualProjectionResult, ApplicationError> {
271 let temporal_query = query.temporal_query()?;
272 let read = self.temporal_read(&temporal_query).await?;
273 let catalogue = VisualLabel::catalogue(&read.context.bundle);
278 let declarations = if query.level_of_detail == super::VisualLevelOfDetail::Moment {
279 super::visual_projection::declared_equivalences(&read.context.bundle)
280 } else {
281 Vec::new()
282 };
283 let temporal = temporal_result(temporal_query, read)?;
284 let mut projection = build_visual_projection(&query, temporal, catalogue)?;
285 super::visual_projection::include_owned_declarations(&mut projection, declarations);
286 Ok(projection)
287 }
288
289 pub async fn relate(
294 &self,
295 query: RelateMemoryQuery,
296 ) -> Result<GetContextResult, ApplicationError> {
297 let render_options = memory_render_options(
298 query.token_budget,
299 query.max_tier,
300 KmpMode::ReasonPreserving,
301 EndpointHint::Neighborhood,
302 );
303 let dimensions = query.dimensions.resolve_current_about(&query.about);
304 let result = self
305 .memory_context(
306 &query.about,
307 "relater",
308 query.depth,
309 &dimensions,
310 &render_options,
311 )
312 .await?;
313 apply_dimension_selection(result, &dimensions, &render_options)
314 }
315
316 pub async fn trace(
317 &self,
318 query: TraceMemoryQuery,
319 ) -> Result<GetContextPathResult, ApplicationError> {
320 self.validate_read_members(
321 &query.about,
322 &[("from", query.from.as_str()), ("to", query.to.as_str())],
323 )
324 .await?;
325 self.query_application
326 .get_context_path(GetContextPathQuery {
327 root_node_id: query.from,
328 target_node_id: query.to,
329 role: query.role,
330 subtree_depth: Some(0),
331 render_options: ContextRenderOptions {
332 focus_node_id: None,
333 token_budget: (query.token_budget > 0).then_some(query.token_budget),
334 max_tier: Some(ResolutionTier::L2EvidencePack),
335 rehydration_mode: KmpMode::ReasonPreserving,
336 endpoint_hint: EndpointHint::FocusedPath,
337 },
338 })
339 .await
340 }
341
342 pub async fn inspect(
343 &self,
344 query: InspectMemoryQuery,
345 ) -> Result<InspectMemoryResult, ApplicationError> {
346 if query.ref_id.starts_with("receipt:") {
347 let reference = kmp_domain::MemoryReceiptRef::parse(&query.ref_id)
348 .ok_or_else(|| ApplicationError::Validation("invalid receipt ref".into()))?;
349 if reference.about() != query.about {
350 return Err(ApplicationError::Validation(
351 "receipt ref belongs to another about".into(),
352 ));
353 }
354 let accepted = self
355 .command_application
356 .accepted_outcome(reference.idempotency_key())
357 .await?
358 .ok_or_else(|| {
359 ApplicationError::NotFound(format!("receipt not found: {}", query.ref_id))
360 })?;
361 return super::receipt::inspect_receipt(query, accepted);
362 }
363 self.validate_read_members(&query.about, &[("ref", query.ref_id.as_str())])
364 .await?;
365 let include_incoming = query.include_incoming;
366 let include_outgoing = query.include_outgoing;
367 let include_details = query.include_details;
368 let detail = self
369 .query_application
370 .get_node_detail(GetNodeDetailQuery {
371 node_id: query.ref_id.clone(),
372 })
373 .await?;
374
375 let links = self
379 .query_application
380 .get_node_relationships(GetNodeRelationshipsQuery {
381 node_id: query.ref_id.clone(),
382 })
383 .await?;
384 let mut evidence = Vec::new();
385 let supporting_refs = links
386 .incoming
387 .iter()
388 .filter(|relationship| relationship.relationship_type == "supports")
389 .map(|relationship| relationship.source_node_id.clone())
390 .collect::<BTreeSet<_>>();
391 for evidence_ref in supporting_refs {
392 let evidence_detail = match self
393 .query_application
394 .get_node_detail(GetNodeDetailQuery {
395 node_id: evidence_ref,
396 })
397 .await
398 {
399 Ok(detail) => detail,
400 Err(ApplicationError::NotFound(_)) => continue,
403 Err(error) => return Err(error),
404 };
405 if !is_memory_evidence_kind(&evidence_detail.node.node_kind) {
406 continue;
407 }
408 evidence.push(InspectedEvidence {
409 supports: projected_evidence_supports(&evidence_detail, &query.ref_id),
410 detail: evidence_detail,
411 });
412 }
413 let raw_coordinates = if query.include_raw {
414 inspect_raw_coordinates(&query.ref_id, Some(&links))?
415 } else {
416 Vec::new()
417 };
418
419 Ok(InspectMemoryResult {
420 detail,
421 incoming: if include_incoming {
422 links.incoming.clone()
423 } else {
424 Vec::new()
425 },
426 outgoing: if include_outgoing {
427 links.outgoing.clone()
428 } else {
429 Vec::new()
430 },
431 evidence,
432 raw_coordinates,
433 include_details,
434 include_raw: query.include_raw,
435 })
436 }
437
438 async fn existing_memory_refs(
439 &self,
440 about: &str,
441 ) -> Result<ExistingMemoryRefs, ApplicationError> {
442 match self
443 .query_application
444 .get_context(GetContextQuery {
445 root_node_id: about.to_string(),
446 role: "memory".to_string(),
447 depth: MEMORY_EXISTING_REFS_LOOKUP_DEPTH,
452 requested_scopes: Vec::new(),
453 render_options: ContextRenderOptions::default(),
454 })
455 .await
456 {
457 Ok(result) => Ok(existing_refs_from_bundle(&result.bundle)),
458 Err(ApplicationError::NotFound(_)) => Ok(ExistingMemoryRefs::default()),
459 Err(error) => Err(error),
460 }
461 }
462
463 async fn validate_read_members(
464 &self,
465 about: &str,
466 members: &[(&str, &str)],
467 ) -> Result<(), ApplicationError> {
468 let mut graph_members = Vec::new();
469 for (path, member_ref) in members {
470 validate_ref_token(path, member_ref).map_err(ApplicationError::Validation)?;
471 if validate_supplied_member_ref(about, path, member_ref).is_err() {
472 graph_members.push((*path, *member_ref));
473 }
474 }
475 if graph_members.is_empty() {
476 return Ok(());
477 }
478
479 let visible = match self
480 .query_application
481 .get_context(GetContextQuery {
482 root_node_id: about.to_string(),
483 role: "memory-boundary".to_string(),
484 depth: MAX_NATIVE_GRAPH_TRAVERSAL_DEPTH,
485 requested_scopes: Vec::new(),
486 render_options: ContextRenderOptions::default(),
487 })
488 .await
489 {
490 Ok(result) => bundle_node_ids(&result.bundle),
491 Err(ApplicationError::NotFound(_)) => BTreeSet::new(),
492 Err(error) => return Err(error),
493 };
494 for (path, member_ref) in graph_members {
495 if !visible.contains(member_ref) {
496 return Err(ApplicationError::Validation(format!(
497 "`{path}` `{member_ref}` does not belong to about `{about}`"
498 )));
499 }
500 }
501 Ok(())
502 }
503
504 async fn memory_context(
505 &self,
506 about: &str,
507 role: &str,
508 depth: u32,
509 dimensions: &DimensionSelection,
510 render_options: &ContextRenderOptions,
511 ) -> Result<GetContextResult, ApplicationError> {
512 let roots = self.memory_context_roots(about, dimensions).await?;
513 let requested_scopes = requested_dimension_scopes(about, dimensions, &roots);
514 let mut results = Vec::new();
515 for root in &roots {
516 results.push(
517 self.query_application
518 .get_context(GetContextQuery {
519 root_node_id: root.clone(),
520 role: role.to_string(),
521 depth,
522 requested_scopes: requested_scopes.clone(),
523 render_options: render_options.clone(),
524 })
525 .await?,
526 );
527 }
528
529 merge_context_results(results, render_options)
530 }
531
532 async fn memory_context_roots(
533 &self,
534 current_about: &str,
535 selection: &DimensionSelection,
536 ) -> Result<Vec<String>, ApplicationError> {
537 if selection.scope_mode() != DimensionScopeMode::AllAbouts {
538 return context_roots(current_about, selection);
539 }
540
541 let roots = if should_filter_all_abouts_by_dimensions(selection) {
542 let dimension_ids = index_dimension_ids(selection);
547 self.query_application
548 .list_memory_abouts_by_dimensions(&dimension_ids)
549 .await?
550 } else {
551 self.query_application.list_memory_abouts().await?
552 };
553
554 let roots = prioritize_current_about(normalize_about_roots(roots), current_about);
555 if roots.is_empty() {
556 return Err(ApplicationError::NotFound(
557 "no memory abouts found for ALL_ABOUTS scope".to_string(),
558 ));
559 }
560 Ok(roots)
561 }
562}
563
564struct TemporalRead {
567 context: GetContextResult,
568 dimensions: DimensionSelection,
569}
570
571fn temporal_result(
573 query: TemporalMemoryQuery,
574 read: TemporalRead,
575) -> Result<TemporalMemoryResult, ApplicationError> {
576 let TemporalRead {
577 context,
578 dimensions,
579 } = read;
580 let quality = context.rendered.quality.clone();
581 let source_bundle = filter_bundle_by_memory_dimensions(&context.bundle, &dimensions)?;
582
583 let request = TemporalTraversalRequest::new(query.direction, query.cursor)
584 .with_axis(query.axis)
585 .with_dimensions(dimensions.clone())
586 .with_requested_dimensions(query.dimensions.clone())
587 .with_window(query.window);
588 let request = if let Some(interval) = query.interval {
589 request.with_interval(interval)
590 } else {
591 request
592 };
593 let request = if let Some(limit_entries) = query.limit_entries {
594 request.with_limit_entries(limit_entries)?
595 } else {
596 request
597 };
598
599 let traversal = TemporalMemoryTraversal::traverse(&context.bundle, &request)?;
603
604 Ok(TemporalMemoryResult {
605 traversal,
606 source_bundle,
607 include: query.include,
608 quality,
609 })
610}
611
612fn bundle_node_ids(bundle: &KmpBundle) -> BTreeSet<String> {
613 std::iter::once(bundle.root_node().node_id())
614 .chain(bundle.neighbor_nodes().iter().map(BundleNode::node_id))
615 .map(ToString::to_string)
616 .collect()
617}
618
619fn projected_evidence_supports(
620 evidence: &crate::queries::GetNodeDetailResult,
621 inspected_ref: &str,
622) -> Vec<String> {
623 evidence
624 .node
625 .properties
626 .get("payload_supports")
627 .and_then(|value| serde_json::from_str::<Vec<String>>(value).ok())
628 .filter(|supports| !supports.is_empty())
629 .unwrap_or_else(|| vec![inspected_ref.to_string()])
630}
631
632fn should_filter_all_abouts_by_dimensions(selection: &DimensionSelection) -> bool {
633 selection.scope_mode() == DimensionScopeMode::AllAbouts
634 && ((selection.mode() == DimensionSelectionMode::Only
635 && !selection.dimensions().is_empty())
636 || !selection.scope_ids().is_empty()
637 || selection.selectors().iter().any(LabelSelector::is_positive))
638}
639
640fn index_dimension_ids(selection: &DimensionSelection) -> Vec<String> {
641 let mut dimension_ids = Vec::new();
642 if selection.mode() == DimensionSelectionMode::Only {
643 dimension_ids.extend(selection.dimensions().iter().cloned());
644 }
645 dimension_ids.extend(selection.scope_ids().iter().cloned());
646 dimension_ids.extend(selection.positive_selector_ids());
647 dimension_ids
648}
649
650fn inspect_raw_coordinates(
651 ref_id: &str,
652 links: Option<&crate::queries::GetNodeRelationshipsResult>,
653) -> Result<Vec<TemporalCoordinate>, ApplicationError> {
654 let Some(links) = links else {
655 return Ok(Vec::new());
656 };
657
658 let mut coordinates = Vec::new();
659 for relationship in links.incoming.iter().chain(links.outgoing.iter()) {
660 if relationship.relationship_type != "contains_entry"
661 || relationship.target_node_id != ref_id
662 {
663 continue;
664 }
665 if let Some(coordinate) =
666 TemporalCoordinate::from_relation_explanation(&relationship.explanation)?
667 {
668 coordinates.push(coordinate);
669 }
670 }
671
672 Ok(coordinates)
673}
674
675fn memory_render_options(
676 token_budget: u32,
677 max_tier: Option<ResolutionTier>,
678 rehydration_mode: KmpMode,
679 endpoint_hint: EndpointHint,
680) -> ContextRenderOptions {
681 ContextRenderOptions {
682 focus_node_id: None,
683 token_budget: (token_budget > 0).then_some(token_budget),
684 max_tier,
685 rehydration_mode,
686 endpoint_hint,
687 }
688}
689
690fn requested_dimension_scopes(
691 _current_about: &str,
692 selection: &DimensionSelection,
693 _context_roots: &[String],
694) -> Vec<String> {
695 if !selection.scope_ids().is_empty() {
698 return selection.scope_ids().iter().cloned().collect();
699 }
700 if selection.mode() == DimensionSelectionMode::Only {
701 return selection.dimensions().iter().cloned().collect();
702 }
703 Vec::new()
704}
705
706fn context_roots(
707 current_about: &str,
708 selection: &DimensionSelection,
709) -> Result<Vec<String>, ApplicationError> {
710 match selection.scope_mode() {
711 DimensionScopeMode::Abouts if !selection.abouts().is_empty() => {
712 Ok(selection.abouts().iter().cloned().collect())
713 }
714 DimensionScopeMode::CurrentAbout => Ok(vec![current_about.to_string()]),
715 DimensionScopeMode::Abouts => Err(ApplicationError::Validation(
716 "dimension scope ABOUTS requires at least one about".to_string(),
717 )),
718 DimensionScopeMode::AllAbouts => Err(ApplicationError::Validation(
719 "dimension scope ALL_ABOUTS must be resolved through the memory about index"
720 .to_string(),
721 )),
722 }
723}
724
725fn apply_dimension_selection(
726 mut result: GetContextResult,
727 dimensions: &DimensionSelection,
728 render_options: &ContextRenderOptions,
729) -> Result<GetContextResult, ApplicationError> {
730 result.bundle = filter_bundle_by_memory_dimensions(&result.bundle, dimensions)?;
731 result.rendered = render_graph_bundle_with_options(&result.bundle, render_options);
732 Ok(result)
733}
734
735fn normalize_about_roots(values: Vec<String>) -> Vec<String> {
736 values
737 .into_iter()
738 .map(|value| value.trim().to_string())
739 .filter(|value| !value.is_empty())
740 .collect::<BTreeSet<_>>()
741 .into_iter()
742 .collect()
743}
744
745fn prioritize_current_about(mut roots: Vec<String>, current_about: &str) -> Vec<String> {
746 let current_about = current_about.trim();
747 if current_about.is_empty() {
748 return roots;
749 }
750 if let Some(position) = roots.iter().position(|root| root == current_about) {
751 let root = roots.remove(position);
752 roots.insert(0, root);
753 }
754 roots
755}
756
757fn filter_bundle_by_memory_dimensions(
758 bundle: &KmpBundle,
759 dimensions: &DimensionSelection,
760) -> Result<KmpBundle, ApplicationError> {
761 let mut included_node_ids = BTreeSet::from([bundle.root_node().node_id().to_string()]);
762 let mut selected_entry_ids = BTreeSet::new();
763 let node_kinds = bundle_node_kinds(bundle);
764 let labels = labels_by_entry(bundle);
765
766 for relationship in bundle
767 .relationships()
768 .iter()
769 .filter(|relationship| relationship.relationship_type() == "contains_entry")
770 {
771 if contains_entry_selected(relationship, dimensions, &labels) {
772 included_node_ids.insert(relationship.source_node_id().to_string());
773 included_node_ids.insert(relationship.target_node_id().to_string());
774 selected_entry_ids.insert(relationship.target_node_id().to_string());
775 }
776 }
777
778 for relationship in bundle.relationships().iter().filter(|relationship| {
779 relationship.relationship_type() == "supports"
780 && selected_entry_ids.contains(relationship.target_node_id())
781 && node_kinds
782 .get(relationship.source_node_id())
783 .is_some_and(|kind| is_memory_evidence_kind(kind))
784 }) {
785 included_node_ids.insert(relationship.source_node_id().to_string());
786 }
787
788 let neighbor_nodes = bundle
789 .neighbor_nodes()
790 .iter()
791 .filter(|node| included_node_ids.contains(node.node_id()))
792 .cloned()
793 .collect::<Vec<_>>();
794 let relationships = bundle
795 .relationships()
796 .iter()
797 .filter(|relationship| {
798 if relationship.relationship_type() == "contains_entry" {
799 return contains_entry_selected(relationship, dimensions, &labels);
800 }
801 included_node_ids.contains(relationship.source_node_id())
802 && included_node_ids.contains(relationship.target_node_id())
803 })
804 .cloned()
805 .collect::<Vec<_>>();
806 let node_details = bundle
807 .node_details()
808 .iter()
809 .filter(|detail| included_node_ids.contains(detail.node_id()))
810 .cloned()
811 .collect::<Vec<_>>();
812
813 KmpBundle::new(
814 bundle.root_node_id().clone(),
815 bundle.role().clone(),
816 bundle.root_node().clone(),
817 neighbor_nodes,
818 relationships,
819 node_details,
820 bundle.metadata().clone(),
821 )
822 .map_err(Into::into)
823}
824
825fn contains_entry_selected(
829 relationship: &BundleRelationship,
830 dimensions: &DimensionSelection,
831 labels: &BTreeMap<String, EntryLabels>,
832) -> bool {
833 let explanation = relationship.explanation();
834 let coordinate_passes = dimensions.includes_coordinate(
835 explanation.dimension().unwrap_or_default(),
836 explanation.scope_id().unwrap_or_default(),
837 );
838 coordinate_passes
839 && (!dimensions.has_selectors()
840 || dimensions.admits(
841 labels
842 .get(relationship.target_node_id())
843 .unwrap_or(&EntryLabels::default()),
844 ))
845}
846
847fn bundle_node_kinds(bundle: &KmpBundle) -> BTreeMap<&str, &str> {
848 let mut node_kinds =
849 BTreeMap::from([(bundle.root_node().node_id(), bundle.root_node().node_kind())]);
850 for node in bundle.neighbor_nodes() {
851 node_kinds.insert(node.node_id(), node.node_kind());
852 }
853 node_kinds
854}
855
856fn is_memory_evidence_kind(kind: &str) -> bool {
857 matches!(kind, "memory_evidence" | "evidence")
858}
859
860fn existing_refs_from_bundle(bundle: &KmpBundle) -> ExistingMemoryRefs {
861 let mut refs = BTreeSet::from([bundle.root_node().node_id().to_string()]);
862 let mut dimensions = BTreeSet::new();
863 let mut max_sequences = BTreeMap::new();
864
865 let mut labels = BTreeSet::new();
866 for node in bundle.neighbor_nodes() {
867 refs.insert(node.node_id().to_string());
868 if node.node_kind() == "memory_dimension" {
869 dimensions.insert(node.node_id().to_string());
870 if let Some(kind) = node.properties().get("dimension_kind") {
871 let value = MemoryDimensionIdentity::parse(node.node_id())
872 .map(|identity| identity.dimension_id().to_string())
873 .unwrap_or_else(|| node.node_id().to_string());
874 labels.insert((kind.clone(), value));
875 }
876 }
877 }
878
879 for relationship in bundle
880 .relationships()
881 .iter()
882 .filter(|relationship| relationship.relationship_type() == "contains_entry")
883 {
884 dimensions.insert(relationship.source_node_id().to_string());
885 let explanation = relationship.explanation();
886 if let (Some(dimension), Some(scope_id), Some(sequence)) = (
887 explanation.dimension(),
888 explanation.scope_id(),
889 explanation.sequence(),
890 ) {
891 max_sequences
892 .entry((dimension.to_string(), scope_id.to_string()))
893 .and_modify(|current: &mut u32| *current = (*current).max(sequence))
894 .or_insert(sequence);
895 }
896 }
897
898 ExistingMemoryRefs {
899 refs,
900 dimensions,
901 labels,
902 max_sequences,
903 foreign: BTreeSet::new(),
904 }
905}
906
907fn merge_context_results(
908 mut results: Vec<GetContextResult>,
909 render_options: &ContextRenderOptions,
910) -> Result<GetContextResult, ApplicationError> {
911 let mut result = results.remove(0);
912 if results.is_empty() {
913 return Ok(result);
914 }
915
916 let mut node_ids = BTreeSet::from([result.bundle.root_node().node_id().to_string()]);
917 let mut neighbor_nodes = result.bundle.neighbor_nodes().to_vec();
918 for node in &neighbor_nodes {
919 node_ids.insert(node.node_id().to_string());
920 }
921
922 let mut relationships = result.bundle.relationships().to_vec();
923 let mut relationship_ids = relationships
924 .iter()
925 .map(relationship_key)
926 .collect::<BTreeSet<_>>();
927 let mut node_details = result.bundle.node_details().to_vec();
928 let mut detail_ids = node_details
929 .iter()
930 .map(|detail| detail.node_id().to_string())
931 .collect::<BTreeSet<_>>();
932
933 for other in results {
934 push_node(&mut neighbor_nodes, &mut node_ids, other.bundle.root_node());
935 for node in other.bundle.neighbor_nodes() {
936 push_node(&mut neighbor_nodes, &mut node_ids, node);
937 }
938 for relationship in other.bundle.relationships() {
939 if relationship_ids.insert(relationship_key(relationship)) {
940 relationships.push(relationship.clone());
941 }
942 }
943 for detail in other.bundle.node_details() {
944 if detail_ids.insert(detail.node_id().to_string()) {
945 node_details.push(detail.clone());
946 }
947 }
948 }
949
950 result.bundle = KmpBundle::new(
951 result.bundle.root_node_id().clone(),
952 result.bundle.role().clone(),
953 result.bundle.root_node().clone(),
954 neighbor_nodes,
955 relationships,
956 node_details,
957 result.bundle.metadata().clone(),
958 )
959 .map_err(ApplicationError::Domain)?;
960 result.rendered = render_graph_bundle_with_options(&result.bundle, render_options);
961 Ok(result)
962}
963
964fn push_node(
965 neighbor_nodes: &mut Vec<BundleNode>,
966 node_ids: &mut BTreeSet<String>,
967 node: &BundleNode,
968) {
969 if node_ids.insert(node.node_id().to_string()) {
970 neighbor_nodes.push(node.clone());
971 }
972}
973
974fn relationship_key(relationship: &BundleRelationship) -> (String, String, String) {
975 (
976 relationship.source_node_id().to_string(),
977 relationship.target_node_id().to_string(),
978 relationship.relationship_type().to_string(),
979 )
980}
981
982#[cfg(test)]
983mod tests {
984 use std::collections::BTreeMap;
985
986 use kmp_domain::{BundleMetadata, CaseId, RelationExplanation, RelationSemanticClass, Role};
987
988 use super::*;
989
990 #[test]
991 fn all_abouts_scope_requires_about_index_resolution() {
992 let selection = DimensionSelection::all().with_all_about_scope();
993 let error = context_roots("question:current", &selection)
994 .expect_err("ALL_ABOUTS must not fall back to current about directly");
995
996 assert!(matches!(
997 error,
998 ApplicationError::Validation(message)
999 if message.contains("resolved through the memory about index")
1000 ));
1001 }
1002
1003 #[test]
1004 fn requested_scopes_preserves_keys_as_index_hints() {
1005 let selection = DimensionSelection::only(["timeline"]).with_all_about_scope();
1006 let scopes = requested_dimension_scopes(
1007 "question:current",
1008 &selection,
1009 &["question:a".to_string(), "question:b".to_string()],
1010 );
1011
1012 assert_eq!(scopes, vec!["timeline".to_string()]);
1013 }
1014
1015 #[test]
1016 fn requested_scopes_preserves_values_and_exact_refs_as_index_hints() {
1017 let selection = DimensionSelection::only(["conversation"])
1018 .with_about_scope(["question:a", "question:b"])
1019 .with_scope_ids([
1020 "conversation:alpha",
1021 "label:v1:question%3Ab:conversation:conversation%3Abeta",
1022 ]);
1023 let scopes = requested_dimension_scopes("question:current", &selection, &[]);
1024
1025 assert_eq!(
1026 scopes,
1027 vec![
1028 "conversation:alpha".to_string(),
1029 "label:v1:question%3Ab:conversation:conversation%3Abeta".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(&"label:v1:question%3Aa:conversation:conversation%3Aalpha"));
1061 assert!(node_ids.contains(&"claim:alpha"));
1062 assert!(!node_ids.contains(&"label:v1:question%3Aa:conversation:conversation%3Abeta"));
1063 assert!(!node_ids.contains(&"claim:beta"));
1064 assert_eq!(
1065 relationships,
1066 vec![(
1067 "label:v1:question%3Aa:conversation:conversation%3Aalpha",
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 "label:v1:question%3Aa:conversation:conversation%3Aalpha",
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("label:v1:question%3Aa:conversation:conversation%3Aalpha"),
1261 memory_dimension_node("label:v1:question%3Aa:conversation:conversation%3Abeta"),
1262 claim_node("claim:alpha"),
1263 claim_node("claim:beta"),
1264 ],
1265 vec![
1266 contains_entry(
1267 "label:v1:question%3Aa:conversation:conversation%3Aalpha",
1268 "claim:alpha",
1269 1,
1270 ),
1271 contains_entry(
1272 "label:v1:question%3Aa:conversation:conversation%3Abeta",
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("label:v1:question%3Aa:conversation:conversation%3Aalpha"),
1299 memory_dimension_node("label:v1:question%3Aa:conversation:conversation%3Abeta"),
1300 claim_node("claim:alpha"),
1301 claim_node("claim:beta"),
1302 evidence_node("evidence:alpha"),
1303 ],
1304 vec![
1305 contains_entry(
1306 "label:v1:question%3Aa:conversation:conversation%3Aalpha",
1307 "claim:alpha",
1308 1,
1309 ),
1310 contains_entry(
1311 "label:v1:question%3Aa:conversation:conversation%3Abeta",
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}