1use std::collections::{BTreeMap, BTreeSet};
2use std::sync::Arc;
3
4use kmp_domain::{
5 BundleNode, BundleRelationship, ContextEventStore, DimensionScopeMode, DimensionSelection,
6 DimensionSelectionMode, GraphNeighborhoodReader, KmpBundle, KmpMode, MemoryAboutIndexReader,
7 MemoryDimensionIdentity, NodeDetailReader, NodeRelationshipReader, ProjectionWriter,
8 ResolutionTier, SnapshotStore, TemporalCoordinate, TemporalMemoryTraversal,
9 TemporalTraversalRequest,
10};
11
12use crate::ApplicationError;
13use crate::commands::CommandApplicationService;
14use crate::memory::{
15 AskMemoryQuery, ExistingMemoryRefs, InspectMemoryQuery, InspectMemoryResult, InspectedEvidence,
16 MemoryIngestCommand, MemoryIngestOutcome, TemporalMemoryQuery, TemporalMemoryResult,
17 TraceMemoryQuery, VisualProjectionQuery, VisualProjectionResult, WakeMemoryQuery,
18 build_visual_projection, translate_memory_ingest,
19};
20use crate::queries::{
21 ContextRenderOptions, EndpointHint, GetContextPathQuery, GetContextPathResult, GetContextQuery,
22 GetContextResult, GetNodeDetailQuery, GetNodeRelationshipsQuery, QueryApplicationService,
23 render_graph_bundle_with_options,
24};
25
26const MEMORY_EXISTING_REFS_LOOKUP_DEPTH: u32 = 1;
27
28pub struct KernelMemoryApplicationService<G, D, S, E, W> {
29 query_application: Arc<QueryApplicationService<G, D, S>>,
30 command_application: Arc<CommandApplicationService<E, W>>,
31}
32
33impl<G, D, S, E, W> KernelMemoryApplicationService<G, D, S, E, W> {
34 pub fn new(
35 query_application: Arc<QueryApplicationService<G, D, S>>,
36 command_application: Arc<CommandApplicationService<E, W>>,
37 ) -> Self {
38 Self {
39 query_application,
40 command_application,
41 }
42 }
43}
44
45impl<G, D, S, E, W> KernelMemoryApplicationService<G, D, S, E, W>
46where
47 G: GraphNeighborhoodReader + MemoryAboutIndexReader + NodeRelationshipReader + Send + Sync,
48 D: NodeDetailReader + Send + Sync,
49 S: SnapshotStore + Send + Sync,
50 E: ContextEventStore + Send + Sync,
51 W: ProjectionWriter + Send + Sync,
52{
53 pub async fn ingest(
54 &self,
55 command: MemoryIngestCommand,
56 ) -> Result<MemoryIngestOutcome, ApplicationError> {
57 let existing = self.existing_memory_refs(&command.about).await?;
58 let (update_context, mut outcome) = translate_memory_ingest(&command, &existing)?;
59 if command.dry_run {
60 outcome
61 .warnings
62 .push("dry_run=true; validated memory without writing to the kernel".to_string());
63 return Ok(outcome);
64 }
65
66 let accepted = self
67 .command_application
68 .update_context(update_context)
69 .await?;
70 outcome.read_after_write_ready = true;
71 outcome.warnings = accepted.warnings;
72 Ok(outcome)
73 }
74
75 pub async fn list_abouts(&self) -> Result<Vec<String>, ApplicationError> {
78 self.query_application.list_memory_abouts().await
79 }
80
81 pub async fn wake(&self, query: WakeMemoryQuery) -> Result<GetContextResult, ApplicationError> {
82 let render_options = memory_render_options(
83 query.token_budget,
84 query.max_tier,
85 KmpMode::ResumeFocused,
86 EndpointHint::Neighborhood,
87 );
88 let dimensions = query.dimensions.resolve_current_about(&query.about);
89 let result = self
90 .memory_context(
91 &query.about,
92 &query.role,
93 query.depth,
94 &dimensions,
95 &render_options,
96 )
97 .await?;
98 apply_dimension_selection(result, &dimensions, &render_options)
99 }
100
101 pub async fn ask(&self, query: AskMemoryQuery) -> Result<GetContextResult, ApplicationError> {
102 let render_options = memory_render_options(
103 query.token_budget,
104 query.max_tier,
105 KmpMode::ReasonPreserving,
106 EndpointHint::Neighborhood,
107 );
108 let dimensions = query.dimensions.resolve_current_about(&query.about);
109 let result = self
110 .memory_context(
111 &query.about,
112 "answerer",
113 query.depth,
114 &dimensions,
115 &render_options,
116 )
117 .await?;
118 apply_dimension_selection(result, &dimensions, &render_options)
119 }
120
121 pub async fn temporal(
122 &self,
123 query: TemporalMemoryQuery,
124 ) -> Result<TemporalMemoryResult, ApplicationError> {
125 let render_options = memory_render_options(
126 query.token_budget,
127 query.max_tier,
128 KmpMode::ReasonPreserving,
129 EndpointHint::Neighborhood,
130 );
131 let dimensions = query.dimensions.resolve_current_about(&query.about);
132 let context = self
133 .memory_context(
134 &query.about,
135 "temporal-reader",
136 query.depth,
137 &dimensions,
138 &render_options,
139 )
140 .await?;
141 let quality = context.rendered.quality.clone();
142 let source_bundle = filter_bundle_by_memory_dimensions(&context.bundle, &dimensions)?;
143
144 let request = TemporalTraversalRequest::new(query.direction, query.cursor)
145 .with_axis(query.axis)
146 .with_dimensions(dimensions.clone())
147 .with_requested_dimensions(query.dimensions.clone())
148 .with_window(query.window);
149 let request = if let Some(limit_entries) = query.limit_entries {
150 request.with_limit_entries(limit_entries)?
151 } else {
152 request
153 };
154
155 let traversal = TemporalMemoryTraversal::traverse(&source_bundle, &request)?;
156
157 Ok(TemporalMemoryResult {
158 traversal,
159 source_bundle,
160 include: query.include,
161 quality,
162 })
163 }
164
165 pub async fn visual_projection(
172 &self,
173 query: VisualProjectionQuery,
174 ) -> Result<VisualProjectionResult, ApplicationError> {
175 let temporal_query = query.temporal_query()?;
176 let temporal = self.temporal(temporal_query).await?;
177 build_visual_projection(&query, temporal)
178 }
179
180 pub async fn trace(
181 &self,
182 query: TraceMemoryQuery,
183 ) -> Result<GetContextPathResult, ApplicationError> {
184 self.query_application
185 .get_context_path(GetContextPathQuery {
186 root_node_id: query.from,
187 target_node_id: query.to,
188 role: query.role,
189 subtree_depth: Some(0),
190 render_options: ContextRenderOptions {
191 focus_node_id: None,
192 token_budget: (query.token_budget > 0).then_some(query.token_budget),
193 max_tier: Some(ResolutionTier::L2EvidencePack),
194 rehydration_mode: KmpMode::ReasonPreserving,
195 endpoint_hint: EndpointHint::FocusedPath,
196 },
197 })
198 .await
199 }
200
201 pub async fn inspect(
202 &self,
203 query: InspectMemoryQuery,
204 ) -> Result<InspectMemoryResult, ApplicationError> {
205 let include_incoming = query.include_incoming;
206 let include_outgoing = query.include_outgoing;
207 let include_details = query.include_details;
208 let detail = self
209 .query_application
210 .get_node_detail(GetNodeDetailQuery {
211 node_id: query.ref_id.clone(),
212 })
213 .await?;
214
215 let links = self
219 .query_application
220 .get_node_relationships(GetNodeRelationshipsQuery {
221 node_id: query.ref_id.clone(),
222 })
223 .await?;
224 let mut evidence = Vec::new();
225 let supporting_refs = links
226 .incoming
227 .iter()
228 .filter(|relationship| relationship.relationship_type == "supports")
229 .map(|relationship| relationship.source_node_id.clone())
230 .collect::<BTreeSet<_>>();
231 for evidence_ref in supporting_refs {
232 let evidence_detail = match self
233 .query_application
234 .get_node_detail(GetNodeDetailQuery {
235 node_id: evidence_ref,
236 })
237 .await
238 {
239 Ok(detail) => detail,
240 Err(ApplicationError::NotFound(_)) => continue,
243 Err(error) => return Err(error),
244 };
245 if !is_memory_evidence_kind(&evidence_detail.node.node_kind) {
246 continue;
247 }
248 evidence.push(InspectedEvidence {
249 supports: projected_evidence_supports(&evidence_detail, &query.ref_id),
250 detail: evidence_detail,
251 });
252 }
253 let raw_coordinates = if query.include_raw {
254 inspect_raw_coordinates(&query.ref_id, Some(&links))?
255 } else {
256 Vec::new()
257 };
258
259 Ok(InspectMemoryResult {
260 detail,
261 incoming: if include_incoming {
262 links.incoming.clone()
263 } else {
264 Vec::new()
265 },
266 outgoing: if include_outgoing {
267 links.outgoing.clone()
268 } else {
269 Vec::new()
270 },
271 evidence,
272 raw_coordinates,
273 include_details,
274 include_raw: query.include_raw,
275 })
276 }
277
278 async fn existing_memory_refs(
279 &self,
280 about: &str,
281 ) -> Result<ExistingMemoryRefs, ApplicationError> {
282 match self
283 .query_application
284 .get_context(GetContextQuery {
285 root_node_id: about.to_string(),
286 role: "memory".to_string(),
287 depth: MEMORY_EXISTING_REFS_LOOKUP_DEPTH,
292 requested_scopes: Vec::new(),
293 render_options: ContextRenderOptions::default(),
294 })
295 .await
296 {
297 Ok(result) => Ok(existing_refs_from_bundle(&result.bundle)),
298 Err(ApplicationError::NotFound(_)) => Ok(ExistingMemoryRefs::default()),
299 Err(error) => Err(error),
300 }
301 }
302
303 async fn memory_context(
304 &self,
305 about: &str,
306 role: &str,
307 depth: u32,
308 dimensions: &DimensionSelection,
309 render_options: &ContextRenderOptions,
310 ) -> Result<GetContextResult, ApplicationError> {
311 let roots = self.memory_context_roots(about, dimensions).await?;
312 let requested_scopes = requested_dimension_scopes(about, dimensions, &roots);
313 let mut results = Vec::new();
314 for root in &roots {
315 results.push(
316 self.query_application
317 .get_context(GetContextQuery {
318 root_node_id: root.clone(),
319 role: role.to_string(),
320 depth,
321 requested_scopes: requested_scopes.clone(),
322 render_options: render_options.clone(),
323 })
324 .await?,
325 );
326 }
327
328 merge_context_results(results, render_options)
329 }
330
331 async fn memory_context_roots(
332 &self,
333 current_about: &str,
334 selection: &DimensionSelection,
335 ) -> Result<Vec<String>, ApplicationError> {
336 if selection.scope_mode() != DimensionScopeMode::AllAbouts {
337 return context_roots(current_about, selection);
338 }
339
340 let roots = if should_filter_all_abouts_by_dimensions(selection) {
341 let dimension_ids = selection.dimensions().iter().cloned().collect::<Vec<_>>();
342 self.query_application
343 .list_memory_abouts_by_dimensions(&dimension_ids)
344 .await?
345 } else {
346 self.query_application.list_memory_abouts().await?
347 };
348
349 let roots = prioritize_current_about(normalize_about_roots(roots), current_about);
350 if roots.is_empty() {
351 return Err(ApplicationError::NotFound(
352 "no memory abouts found for ALL_ABOUTS scope".to_string(),
353 ));
354 }
355 Ok(roots)
356 }
357}
358
359fn projected_evidence_supports(
360 evidence: &crate::queries::GetNodeDetailResult,
361 inspected_ref: &str,
362) -> Vec<String> {
363 evidence
364 .node
365 .properties
366 .get("payload_supports")
367 .and_then(|value| serde_json::from_str::<Vec<String>>(value).ok())
368 .filter(|supports| !supports.is_empty())
369 .unwrap_or_else(|| vec![inspected_ref.to_string()])
370}
371
372fn should_filter_all_abouts_by_dimensions(selection: &DimensionSelection) -> bool {
373 selection.scope_mode() == DimensionScopeMode::AllAbouts
374 && selection.mode() == DimensionSelectionMode::Only
375 && !selection.dimensions().is_empty()
376}
377
378fn inspect_raw_coordinates(
379 ref_id: &str,
380 links: Option<&crate::queries::GetNodeRelationshipsResult>,
381) -> Result<Vec<TemporalCoordinate>, ApplicationError> {
382 let Some(links) = links else {
383 return Ok(Vec::new());
384 };
385
386 let mut coordinates = Vec::new();
387 for relationship in links.incoming.iter().chain(links.outgoing.iter()) {
388 if relationship.relationship_type != "contains_entry"
389 || relationship.target_node_id != ref_id
390 {
391 continue;
392 }
393 if let Some(coordinate) =
394 TemporalCoordinate::from_relation_explanation(&relationship.explanation)?
395 {
396 coordinates.push(coordinate);
397 }
398 }
399
400 Ok(coordinates)
401}
402
403fn memory_render_options(
404 token_budget: u32,
405 max_tier: Option<ResolutionTier>,
406 rehydration_mode: KmpMode,
407 endpoint_hint: EndpointHint,
408) -> ContextRenderOptions {
409 ContextRenderOptions {
410 focus_node_id: None,
411 token_budget: (token_budget > 0).then_some(token_budget),
412 max_tier,
413 rehydration_mode,
414 endpoint_hint,
415 }
416}
417
418fn requested_dimension_scopes(
419 current_about: &str,
420 selection: &DimensionSelection,
421 context_roots: &[String],
422) -> Vec<String> {
423 if !selection.scope_ids().is_empty() {
424 return requested_explicit_dimension_scopes(current_about, selection, context_roots);
425 }
426
427 match selection.mode() {
428 DimensionSelectionMode::Only => match selection.scope_mode() {
429 DimensionScopeMode::CurrentAbout => selection
430 .dimensions()
431 .iter()
432 .filter_map(|dimension| namespaced_dimension_id(current_about, dimension))
433 .collect(),
434 DimensionScopeMode::Abouts => selection
435 .abouts()
436 .iter()
437 .flat_map(|about| {
438 selection
439 .dimensions()
440 .iter()
441 .filter_map(|dimension| namespaced_dimension_id(about, dimension))
442 .collect::<Vec<_>>()
443 })
444 .collect(),
445 DimensionScopeMode::AllAbouts => context_roots
446 .iter()
447 .flat_map(|about| {
448 selection
449 .dimensions()
450 .iter()
451 .filter_map(|dimension| namespaced_dimension_id(about, dimension))
452 .collect::<Vec<_>>()
453 })
454 .collect(),
455 },
456 DimensionSelectionMode::Except | DimensionSelectionMode::All => Vec::new(),
457 }
458}
459
460fn requested_explicit_dimension_scopes(
461 current_about: &str,
462 selection: &DimensionSelection,
463 context_roots: &[String],
464) -> Vec<String> {
465 let abouts = match selection.scope_mode() {
466 DimensionScopeMode::CurrentAbout => vec![current_about.to_string()],
467 DimensionScopeMode::Abouts => selection.abouts().iter().cloned().collect(),
468 DimensionScopeMode::AllAbouts => context_roots.to_vec(),
469 };
470
471 abouts
472 .iter()
473 .flat_map(|about| {
474 selection
475 .scope_ids()
476 .iter()
477 .filter_map(|scope_id| resolve_dimension_scope_id(about, scope_id))
478 .collect::<Vec<_>>()
479 })
480 .collect::<BTreeSet<_>>()
481 .into_iter()
482 .collect()
483}
484
485fn context_roots(
486 current_about: &str,
487 selection: &DimensionSelection,
488) -> Result<Vec<String>, ApplicationError> {
489 match selection.scope_mode() {
490 DimensionScopeMode::Abouts if !selection.abouts().is_empty() => {
491 Ok(selection.abouts().iter().cloned().collect())
492 }
493 DimensionScopeMode::CurrentAbout => Ok(vec![current_about.to_string()]),
494 DimensionScopeMode::Abouts => Err(ApplicationError::Validation(
495 "dimension scope ABOUTS requires at least one about".to_string(),
496 )),
497 DimensionScopeMode::AllAbouts => Err(ApplicationError::Validation(
498 "dimension scope ALL_ABOUTS must be resolved through the memory about index"
499 .to_string(),
500 )),
501 }
502}
503
504fn apply_dimension_selection(
505 mut result: GetContextResult,
506 dimensions: &DimensionSelection,
507 render_options: &ContextRenderOptions,
508) -> Result<GetContextResult, ApplicationError> {
509 result.bundle = filter_bundle_by_memory_dimensions(&result.bundle, dimensions)?;
510 result.rendered = render_graph_bundle_with_options(&result.bundle, render_options);
511 Ok(result)
512}
513
514fn normalize_about_roots(values: Vec<String>) -> Vec<String> {
515 values
516 .into_iter()
517 .map(|value| value.trim().to_string())
518 .filter(|value| !value.is_empty())
519 .collect::<BTreeSet<_>>()
520 .into_iter()
521 .collect()
522}
523
524fn prioritize_current_about(mut roots: Vec<String>, current_about: &str) -> Vec<String> {
525 let current_about = current_about.trim();
526 if current_about.is_empty() {
527 return roots;
528 }
529 if let Some(position) = roots.iter().position(|root| root == current_about) {
530 let root = roots.remove(position);
531 roots.insert(0, root);
532 }
533 roots
534}
535
536fn filter_bundle_by_memory_dimensions(
537 bundle: &KmpBundle,
538 dimensions: &DimensionSelection,
539) -> Result<KmpBundle, ApplicationError> {
540 let mut included_node_ids = BTreeSet::from([bundle.root_node().node_id().to_string()]);
541 let mut selected_entry_ids = BTreeSet::new();
542 let node_kinds = bundle_node_kinds(bundle);
543
544 for relationship in bundle
545 .relationships()
546 .iter()
547 .filter(|relationship| relationship.relationship_type() == "contains_entry")
548 {
549 let explanation = relationship.explanation();
550 if dimensions.includes_coordinate(
551 explanation.dimension().unwrap_or_default(),
552 explanation.scope_id().unwrap_or_default(),
553 ) {
554 included_node_ids.insert(relationship.source_node_id().to_string());
555 included_node_ids.insert(relationship.target_node_id().to_string());
556 selected_entry_ids.insert(relationship.target_node_id().to_string());
557 }
558 }
559
560 for relationship in bundle.relationships().iter().filter(|relationship| {
561 relationship.relationship_type() == "supports"
562 && selected_entry_ids.contains(relationship.target_node_id())
563 && node_kinds
564 .get(relationship.source_node_id())
565 .is_some_and(|kind| is_memory_evidence_kind(kind))
566 }) {
567 included_node_ids.insert(relationship.source_node_id().to_string());
568 }
569
570 let neighbor_nodes = bundle
571 .neighbor_nodes()
572 .iter()
573 .filter(|node| included_node_ids.contains(node.node_id()))
574 .cloned()
575 .collect::<Vec<_>>();
576 let relationships = bundle
577 .relationships()
578 .iter()
579 .filter(|relationship| {
580 if relationship.relationship_type() == "contains_entry" {
581 let explanation = relationship.explanation();
582 return dimensions.includes_coordinate(
583 explanation.dimension().unwrap_or_default(),
584 explanation.scope_id().unwrap_or_default(),
585 );
586 }
587 included_node_ids.contains(relationship.source_node_id())
588 && included_node_ids.contains(relationship.target_node_id())
589 })
590 .cloned()
591 .collect::<Vec<_>>();
592 let node_details = bundle
593 .node_details()
594 .iter()
595 .filter(|detail| included_node_ids.contains(detail.node_id()))
596 .cloned()
597 .collect::<Vec<_>>();
598
599 KmpBundle::new(
600 bundle.root_node_id().clone(),
601 bundle.role().clone(),
602 bundle.root_node().clone(),
603 neighbor_nodes,
604 relationships,
605 node_details,
606 bundle.metadata().clone(),
607 )
608 .map_err(Into::into)
609}
610
611fn bundle_node_kinds(bundle: &KmpBundle) -> BTreeMap<&str, &str> {
612 let mut node_kinds =
613 BTreeMap::from([(bundle.root_node().node_id(), bundle.root_node().node_kind())]);
614 for node in bundle.neighbor_nodes() {
615 node_kinds.insert(node.node_id(), node.node_kind());
616 }
617 node_kinds
618}
619
620fn is_memory_evidence_kind(kind: &str) -> bool {
621 matches!(kind, "memory_evidence" | "evidence")
622}
623
624fn existing_refs_from_bundle(bundle: &KmpBundle) -> ExistingMemoryRefs {
625 let mut refs = BTreeSet::from([bundle.root_node().node_id().to_string()]);
626 let mut dimensions = BTreeSet::new();
627 let mut max_sequences = BTreeMap::new();
628
629 for node in bundle.neighbor_nodes() {
630 refs.insert(node.node_id().to_string());
631 if node.node_kind() == "memory_dimension" {
632 dimensions.insert(node.node_id().to_string());
633 }
634 }
635
636 for relationship in bundle
637 .relationships()
638 .iter()
639 .filter(|relationship| relationship.relationship_type() == "contains_entry")
640 {
641 dimensions.insert(relationship.source_node_id().to_string());
642 let explanation = relationship.explanation();
643 if let (Some(dimension), Some(scope_id), Some(sequence)) = (
644 explanation.dimension(),
645 explanation.scope_id(),
646 explanation.sequence(),
647 ) {
648 max_sequences
649 .entry((dimension.to_string(), scope_id.to_string()))
650 .and_modify(|current: &mut u32| *current = (*current).max(sequence))
651 .or_insert(sequence);
652 }
653 }
654
655 ExistingMemoryRefs {
656 refs,
657 dimensions,
658 max_sequences,
659 }
660}
661
662fn merge_context_results(
663 mut results: Vec<GetContextResult>,
664 render_options: &ContextRenderOptions,
665) -> Result<GetContextResult, ApplicationError> {
666 let mut result = results.remove(0);
667 if results.is_empty() {
668 return Ok(result);
669 }
670
671 let mut node_ids = BTreeSet::from([result.bundle.root_node().node_id().to_string()]);
672 let mut neighbor_nodes = result.bundle.neighbor_nodes().to_vec();
673 for node in &neighbor_nodes {
674 node_ids.insert(node.node_id().to_string());
675 }
676
677 let mut relationships = result.bundle.relationships().to_vec();
678 let mut relationship_ids = relationships
679 .iter()
680 .map(relationship_key)
681 .collect::<BTreeSet<_>>();
682 let mut node_details = result.bundle.node_details().to_vec();
683 let mut detail_ids = node_details
684 .iter()
685 .map(|detail| detail.node_id().to_string())
686 .collect::<BTreeSet<_>>();
687
688 for other in results {
689 push_node(&mut neighbor_nodes, &mut node_ids, other.bundle.root_node());
690 for node in other.bundle.neighbor_nodes() {
691 push_node(&mut neighbor_nodes, &mut node_ids, node);
692 }
693 for relationship in other.bundle.relationships() {
694 if relationship_ids.insert(relationship_key(relationship)) {
695 relationships.push(relationship.clone());
696 }
697 }
698 for detail in other.bundle.node_details() {
699 if detail_ids.insert(detail.node_id().to_string()) {
700 node_details.push(detail.clone());
701 }
702 }
703 }
704
705 result.bundle = KmpBundle::new(
706 result.bundle.root_node_id().clone(),
707 result.bundle.role().clone(),
708 result.bundle.root_node().clone(),
709 neighbor_nodes,
710 relationships,
711 node_details,
712 result.bundle.metadata().clone(),
713 )
714 .map_err(ApplicationError::Domain)?;
715 result.rendered = render_graph_bundle_with_options(&result.bundle, render_options);
716 Ok(result)
717}
718
719fn push_node(
720 neighbor_nodes: &mut Vec<BundleNode>,
721 node_ids: &mut BTreeSet<String>,
722 node: &BundleNode,
723) {
724 if node_ids.insert(node.node_id().to_string()) {
725 neighbor_nodes.push(node.clone());
726 }
727}
728
729fn relationship_key(relationship: &BundleRelationship) -> (String, String, String) {
730 (
731 relationship.source_node_id().to_string(),
732 relationship.target_node_id().to_string(),
733 relationship.relationship_type().to_string(),
734 )
735}
736
737fn namespaced_dimension_id(about: &str, dimension: &str) -> Option<String> {
738 MemoryDimensionIdentity::new(about, dimension)
739 .ok()
740 .map(|identity| identity.node_id())
741}
742
743fn resolve_dimension_scope_id(about: &str, scope_id: &str) -> Option<String> {
744 let scope_id = scope_id.trim();
745 if scope_id.is_empty() {
746 return None;
747 }
748 if let Some(identity) = MemoryDimensionIdentity::parse(scope_id) {
749 if identity.about() == about {
750 return Some(identity.node_id());
751 }
752 return None;
753 }
754 namespaced_dimension_id(about, scope_id)
755}
756
757#[cfg(test)]
758mod tests {
759 use std::collections::BTreeMap;
760
761 use kmp_domain::{BundleMetadata, CaseId, RelationExplanation, RelationSemanticClass, Role};
762
763 use super::*;
764
765 #[test]
766 fn all_abouts_scope_requires_about_index_resolution() {
767 let selection = DimensionSelection::all().with_all_about_scope();
768 let error = context_roots("question:current", &selection)
769 .expect_err("ALL_ABOUTS must not fall back to current about directly");
770
771 assert!(matches!(
772 error,
773 ApplicationError::Validation(message)
774 if message.contains("resolved through the memory about index")
775 ));
776 }
777
778 #[test]
779 fn requested_scopes_expands_all_abouts_from_indexed_roots() {
780 let selection = DimensionSelection::only(["timeline"]).with_all_about_scope();
781 let scopes = requested_dimension_scopes(
782 "question:current",
783 &selection,
784 &["question:a".to_string(), "question:b".to_string()],
785 );
786
787 assert_eq!(
788 scopes,
789 vec![
790 "about:question:a:dimension:timeline".to_string(),
791 "about:question:b:dimension:timeline".to_string()
792 ]
793 );
794 }
795
796 #[test]
797 fn requested_scopes_expand_explicit_scope_ids_against_selected_abouts() {
798 let selection = DimensionSelection::only(["conversation"])
799 .with_about_scope(["question:a", "question:b"])
800 .with_scope_ids([
801 "conversation:alpha",
802 "about:question:b:dimension:conversation:beta",
803 ]);
804 let scopes = requested_dimension_scopes("question:current", &selection, &[]);
805
806 assert_eq!(
807 scopes,
808 vec![
809 "about:question:a:dimension:conversation:alpha".to_string(),
810 "about:question:b:dimension:conversation:alpha".to_string(),
811 "about:question:b:dimension:conversation:beta".to_string()
812 ]
813 );
814 }
815
816 #[test]
817 fn bundle_filter_narrows_same_dimension_kind_by_exact_scope_id() {
818 let bundle = scoped_conversation_bundle();
819 let selection = DimensionSelection::only(["conversation"])
820 .resolve_current_about("question:a")
821 .with_scope_ids(["conversation:alpha"]);
822
823 let filtered =
824 filter_bundle_by_memory_dimensions(&bundle, &selection).expect("bundle should filter");
825 let node_ids = filtered
826 .neighbor_nodes()
827 .iter()
828 .map(|node| node.node_id())
829 .collect::<Vec<_>>();
830 let relationships = filtered
831 .relationships()
832 .iter()
833 .map(|relationship| {
834 (
835 relationship.source_node_id(),
836 relationship.target_node_id(),
837 relationship.relationship_type(),
838 )
839 })
840 .collect::<Vec<_>>();
841
842 assert!(node_ids.contains(&"about:question:a:dimension:conversation:alpha"));
843 assert!(node_ids.contains(&"claim:alpha"));
844 assert!(!node_ids.contains(&"about:question:a:dimension:conversation:beta"));
845 assert!(!node_ids.contains(&"claim:beta"));
846 assert_eq!(
847 relationships,
848 vec![(
849 "about:question:a:dimension:conversation:alpha",
850 "claim:alpha",
851 "contains_entry"
852 )]
853 );
854 }
855
856 #[test]
857 fn bundle_filter_only_pulls_support_sources_when_source_is_memory_evidence() {
858 let bundle = scoped_conversation_bundle_with_supports();
859 let selection = DimensionSelection::only(["conversation"])
860 .resolve_current_about("question:a")
861 .with_scope_ids(["conversation:alpha"]);
862
863 let filtered =
864 filter_bundle_by_memory_dimensions(&bundle, &selection).expect("bundle should filter");
865 let node_ids = filtered
866 .neighbor_nodes()
867 .iter()
868 .map(|node| node.node_id())
869 .collect::<Vec<_>>();
870 let relationships = filtered
871 .relationships()
872 .iter()
873 .map(|relationship| {
874 (
875 relationship.source_node_id(),
876 relationship.target_node_id(),
877 relationship.relationship_type(),
878 )
879 })
880 .collect::<Vec<_>>();
881
882 assert!(node_ids.contains(&"claim:alpha"));
883 assert!(node_ids.contains(&"evidence:alpha"));
884 assert!(!node_ids.contains(&"claim:beta"));
885 assert_eq!(
886 relationships,
887 vec![
888 (
889 "about:question:a:dimension:conversation:alpha",
890 "claim:alpha",
891 "contains_entry"
892 ),
893 ("evidence:alpha", "claim:alpha", "supports")
894 ]
895 );
896 }
897
898 #[test]
899 fn normalize_about_roots_trims_sorts_and_deduplicates() {
900 assert_eq!(
901 normalize_about_roots(vec![
902 " question:b ".to_string(),
903 String::new(),
904 "question:a".to_string(),
905 "question:b".to_string(),
906 ]),
907 vec!["question:a".to_string(), "question:b".to_string()]
908 );
909 }
910
911 #[test]
912 fn prioritize_current_about_keeps_all_roots_but_moves_current_first() {
913 assert_eq!(
914 prioritize_current_about(
915 vec![
916 "question:a".to_string(),
917 "question:current".to_string(),
918 "question:z".to_string(),
919 ],
920 "question:current",
921 ),
922 vec![
923 "question:current".to_string(),
924 "question:a".to_string(),
925 "question:z".to_string()
926 ]
927 );
928 }
929
930 fn scoped_conversation_bundle() -> KmpBundle {
931 KmpBundle::new(
932 CaseId::new("question:a").expect("case id should be valid"),
933 Role::new("temporal-reader").expect("role should be valid"),
934 BundleNode::new(
935 "question:a",
936 "question",
937 "Question A",
938 "Test question",
939 "ACTIVE",
940 Vec::new(),
941 BTreeMap::new(),
942 ),
943 vec![
944 memory_dimension_node("about:question:a:dimension:conversation:alpha"),
945 memory_dimension_node("about:question:a:dimension:conversation:beta"),
946 claim_node("claim:alpha"),
947 claim_node("claim:beta"),
948 ],
949 vec![
950 contains_entry(
951 "about:question:a:dimension:conversation:alpha",
952 "claim:alpha",
953 1,
954 ),
955 contains_entry(
956 "about:question:a:dimension:conversation:beta",
957 "claim:beta",
958 2,
959 ),
960 cross_scope_constraint("claim:beta", "claim:alpha"),
961 ],
962 Vec::new(),
963 BundleMetadata::initial("test"),
964 )
965 .expect("test bundle should be valid")
966 }
967
968 fn scoped_conversation_bundle_with_supports() -> KmpBundle {
969 KmpBundle::new(
970 CaseId::new("question:a").expect("case id should be valid"),
971 Role::new("temporal-reader").expect("role should be valid"),
972 BundleNode::new(
973 "question:a",
974 "question",
975 "Question A",
976 "Test question",
977 "ACTIVE",
978 Vec::new(),
979 BTreeMap::new(),
980 ),
981 vec![
982 memory_dimension_node("about:question:a:dimension:conversation:alpha"),
983 memory_dimension_node("about:question:a:dimension:conversation:beta"),
984 claim_node("claim:alpha"),
985 claim_node("claim:beta"),
986 evidence_node("evidence:alpha"),
987 ],
988 vec![
989 contains_entry(
990 "about:question:a:dimension:conversation:alpha",
991 "claim:alpha",
992 1,
993 ),
994 contains_entry(
995 "about:question:a:dimension:conversation:beta",
996 "claim:beta",
997 2,
998 ),
999 supports("claim:beta", "claim:alpha"),
1000 supports("evidence:alpha", "claim:alpha"),
1001 ],
1002 Vec::new(),
1003 BundleMetadata::initial("test"),
1004 )
1005 .expect("test bundle should be valid")
1006 }
1007
1008 fn memory_dimension_node(node_id: &str) -> BundleNode {
1009 BundleNode::new(
1010 node_id,
1011 "memory_dimension",
1012 node_id,
1013 "Conversation scope",
1014 "ACTIVE",
1015 Vec::new(),
1016 BTreeMap::new(),
1017 )
1018 }
1019
1020 fn claim_node(node_id: &str) -> BundleNode {
1021 BundleNode::new(
1022 node_id,
1023 "claim",
1024 node_id,
1025 "Claim",
1026 "ACTIVE",
1027 Vec::new(),
1028 BTreeMap::new(),
1029 )
1030 }
1031
1032 fn evidence_node(node_id: &str) -> BundleNode {
1033 BundleNode::new(
1034 node_id,
1035 "memory_evidence",
1036 node_id,
1037 "Evidence",
1038 "ACTIVE",
1039 Vec::new(),
1040 BTreeMap::new(),
1041 )
1042 }
1043
1044 fn contains_entry(scope_id: &str, target_node_id: &str, sequence: u32) -> BundleRelationship {
1045 BundleRelationship::new(
1046 scope_id,
1047 target_node_id,
1048 "contains_entry",
1049 RelationExplanation::new(RelationSemanticClass::Structural)
1050 .with_dimension("conversation")
1051 .with_scope_id(scope_id)
1052 .with_sequence(sequence),
1053 )
1054 }
1055
1056 fn cross_scope_constraint(source_node_id: &str, target_node_id: &str) -> BundleRelationship {
1057 BundleRelationship::new(
1058 source_node_id,
1059 target_node_id,
1060 "contextual_constraint",
1061 RelationExplanation::new(RelationSemanticClass::Constraint)
1062 .with_rationale("Off-scope relation must not leak through exact scope filtering.")
1063 .with_confidence("medium"),
1064 )
1065 }
1066
1067 fn supports(source_node_id: &str, target_node_id: &str) -> BundleRelationship {
1068 BundleRelationship::new(
1069 source_node_id,
1070 target_node_id,
1071 "supports",
1072 RelationExplanation::new(RelationSemanticClass::Evidential)
1073 .with_rationale("Support relation for scoped filtering.")
1074 .with_confidence("medium"),
1075 )
1076 }
1077}