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