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