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, 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 = if include_incoming || include_outgoing || query.include_raw {
216 Some(
217 self.query_application
218 .get_node_relationships(GetNodeRelationshipsQuery {
219 node_id: query.ref_id.clone(),
220 })
221 .await?,
222 )
223 } else {
224 None
225 };
226 let raw_coordinates = if query.include_raw {
227 inspect_raw_coordinates(&query.ref_id, links.as_ref())?
228 } else {
229 Vec::new()
230 };
231
232 Ok(InspectMemoryResult {
233 detail,
234 incoming: links
235 .as_ref()
236 .filter(|_| include_incoming)
237 .map(|links| links.incoming.clone())
238 .unwrap_or_default(),
239 outgoing: links
240 .as_ref()
241 .filter(|_| include_outgoing)
242 .map(|links| links.outgoing.clone())
243 .unwrap_or_default(),
244 raw_coordinates,
245 include_details,
246 include_raw: query.include_raw,
247 })
248 }
249
250 async fn existing_memory_refs(
251 &self,
252 about: &str,
253 ) -> Result<ExistingMemoryRefs, ApplicationError> {
254 match self
255 .query_application
256 .get_context(GetContextQuery {
257 root_node_id: about.to_string(),
258 role: "memory".to_string(),
259 depth: MEMORY_EXISTING_REFS_LOOKUP_DEPTH,
264 requested_scopes: Vec::new(),
265 render_options: ContextRenderOptions::default(),
266 })
267 .await
268 {
269 Ok(result) => Ok(existing_refs_from_bundle(&result.bundle)),
270 Err(ApplicationError::NotFound(_)) => Ok(ExistingMemoryRefs::default()),
271 Err(error) => Err(error),
272 }
273 }
274
275 async fn memory_context(
276 &self,
277 about: &str,
278 role: &str,
279 depth: u32,
280 dimensions: &DimensionSelection,
281 render_options: &ContextRenderOptions,
282 ) -> Result<GetContextResult, ApplicationError> {
283 let roots = self.memory_context_roots(about, dimensions).await?;
284 let requested_scopes = requested_dimension_scopes(about, dimensions, &roots);
285 let mut results = Vec::new();
286 for root in &roots {
287 results.push(
288 self.query_application
289 .get_context(GetContextQuery {
290 root_node_id: root.clone(),
291 role: role.to_string(),
292 depth,
293 requested_scopes: requested_scopes.clone(),
294 render_options: render_options.clone(),
295 })
296 .await?,
297 );
298 }
299
300 merge_context_results(results, render_options)
301 }
302
303 async fn memory_context_roots(
304 &self,
305 current_about: &str,
306 selection: &DimensionSelection,
307 ) -> Result<Vec<String>, ApplicationError> {
308 if selection.scope_mode() != DimensionScopeMode::AllAbouts {
309 return context_roots(current_about, selection);
310 }
311
312 let roots = if should_filter_all_abouts_by_dimensions(selection) {
313 let dimension_ids = selection.dimensions().iter().cloned().collect::<Vec<_>>();
314 self.query_application
315 .list_memory_abouts_by_dimensions(&dimension_ids)
316 .await?
317 } else {
318 self.query_application.list_memory_abouts().await?
319 };
320
321 let roots = prioritize_current_about(normalize_about_roots(roots), current_about);
322 if roots.is_empty() {
323 return Err(ApplicationError::NotFound(
324 "no memory abouts found for ALL_ABOUTS scope".to_string(),
325 ));
326 }
327 Ok(roots)
328 }
329}
330
331fn should_filter_all_abouts_by_dimensions(selection: &DimensionSelection) -> bool {
332 selection.scope_mode() == DimensionScopeMode::AllAbouts
333 && selection.mode() == DimensionSelectionMode::Only
334 && !selection.dimensions().is_empty()
335}
336
337fn inspect_raw_coordinates(
338 ref_id: &str,
339 links: Option<&crate::queries::GetNodeRelationshipsResult>,
340) -> Result<Vec<TemporalCoordinate>, ApplicationError> {
341 let Some(links) = links else {
342 return Ok(Vec::new());
343 };
344
345 let mut coordinates = Vec::new();
346 for relationship in links.incoming.iter().chain(links.outgoing.iter()) {
347 if relationship.relationship_type != "contains_entry"
348 || relationship.target_node_id != ref_id
349 {
350 continue;
351 }
352 if let Some(coordinate) =
353 TemporalCoordinate::from_relation_explanation(&relationship.explanation)?
354 {
355 coordinates.push(coordinate);
356 }
357 }
358
359 Ok(coordinates)
360}
361
362fn memory_render_options(
363 token_budget: u32,
364 max_tier: Option<ResolutionTier>,
365 rehydration_mode: KmpMode,
366 endpoint_hint: EndpointHint,
367) -> ContextRenderOptions {
368 ContextRenderOptions {
369 focus_node_id: None,
370 token_budget: (token_budget > 0).then_some(token_budget),
371 max_tier,
372 rehydration_mode,
373 endpoint_hint,
374 }
375}
376
377fn requested_dimension_scopes(
378 current_about: &str,
379 selection: &DimensionSelection,
380 context_roots: &[String],
381) -> Vec<String> {
382 if !selection.scope_ids().is_empty() {
383 return requested_explicit_dimension_scopes(current_about, selection, context_roots);
384 }
385
386 match selection.mode() {
387 DimensionSelectionMode::Only => match selection.scope_mode() {
388 DimensionScopeMode::CurrentAbout => selection
389 .dimensions()
390 .iter()
391 .filter_map(|dimension| namespaced_dimension_id(current_about, dimension))
392 .collect(),
393 DimensionScopeMode::Abouts => selection
394 .abouts()
395 .iter()
396 .flat_map(|about| {
397 selection
398 .dimensions()
399 .iter()
400 .filter_map(|dimension| namespaced_dimension_id(about, dimension))
401 .collect::<Vec<_>>()
402 })
403 .collect(),
404 DimensionScopeMode::AllAbouts => context_roots
405 .iter()
406 .flat_map(|about| {
407 selection
408 .dimensions()
409 .iter()
410 .filter_map(|dimension| namespaced_dimension_id(about, dimension))
411 .collect::<Vec<_>>()
412 })
413 .collect(),
414 },
415 DimensionSelectionMode::Except | DimensionSelectionMode::All => Vec::new(),
416 }
417}
418
419fn requested_explicit_dimension_scopes(
420 current_about: &str,
421 selection: &DimensionSelection,
422 context_roots: &[String],
423) -> Vec<String> {
424 let abouts = match selection.scope_mode() {
425 DimensionScopeMode::CurrentAbout => vec![current_about.to_string()],
426 DimensionScopeMode::Abouts => selection.abouts().iter().cloned().collect(),
427 DimensionScopeMode::AllAbouts => context_roots.to_vec(),
428 };
429
430 abouts
431 .iter()
432 .flat_map(|about| {
433 selection
434 .scope_ids()
435 .iter()
436 .filter_map(|scope_id| resolve_dimension_scope_id(about, scope_id))
437 .collect::<Vec<_>>()
438 })
439 .collect::<BTreeSet<_>>()
440 .into_iter()
441 .collect()
442}
443
444fn context_roots(
445 current_about: &str,
446 selection: &DimensionSelection,
447) -> Result<Vec<String>, ApplicationError> {
448 match selection.scope_mode() {
449 DimensionScopeMode::Abouts if !selection.abouts().is_empty() => {
450 Ok(selection.abouts().iter().cloned().collect())
451 }
452 DimensionScopeMode::CurrentAbout => Ok(vec![current_about.to_string()]),
453 DimensionScopeMode::Abouts => Err(ApplicationError::Validation(
454 "dimension scope ABOUTS requires at least one about".to_string(),
455 )),
456 DimensionScopeMode::AllAbouts => Err(ApplicationError::Validation(
457 "dimension scope ALL_ABOUTS must be resolved through the memory about index"
458 .to_string(),
459 )),
460 }
461}
462
463fn apply_dimension_selection(
464 mut result: GetContextResult,
465 dimensions: &DimensionSelection,
466 render_options: &ContextRenderOptions,
467) -> Result<GetContextResult, ApplicationError> {
468 result.bundle = filter_bundle_by_memory_dimensions(&result.bundle, dimensions)?;
469 result.rendered = render_graph_bundle_with_options(&result.bundle, render_options);
470 Ok(result)
471}
472
473fn normalize_about_roots(values: Vec<String>) -> Vec<String> {
474 values
475 .into_iter()
476 .map(|value| value.trim().to_string())
477 .filter(|value| !value.is_empty())
478 .collect::<BTreeSet<_>>()
479 .into_iter()
480 .collect()
481}
482
483fn prioritize_current_about(mut roots: Vec<String>, current_about: &str) -> Vec<String> {
484 let current_about = current_about.trim();
485 if current_about.is_empty() {
486 return roots;
487 }
488 if let Some(position) = roots.iter().position(|root| root == current_about) {
489 let root = roots.remove(position);
490 roots.insert(0, root);
491 }
492 roots
493}
494
495fn filter_bundle_by_memory_dimensions(
496 bundle: &KmpBundle,
497 dimensions: &DimensionSelection,
498) -> Result<KmpBundle, ApplicationError> {
499 let mut included_node_ids = BTreeSet::from([bundle.root_node().node_id().to_string()]);
500 let mut selected_entry_ids = BTreeSet::new();
501 let node_kinds = bundle_node_kinds(bundle);
502
503 for relationship in bundle
504 .relationships()
505 .iter()
506 .filter(|relationship| relationship.relationship_type() == "contains_entry")
507 {
508 let explanation = relationship.explanation();
509 if dimensions.includes_coordinate(
510 explanation.dimension().unwrap_or_default(),
511 explanation.scope_id().unwrap_or_default(),
512 ) {
513 included_node_ids.insert(relationship.source_node_id().to_string());
514 included_node_ids.insert(relationship.target_node_id().to_string());
515 selected_entry_ids.insert(relationship.target_node_id().to_string());
516 }
517 }
518
519 for relationship in bundle.relationships().iter().filter(|relationship| {
520 relationship.relationship_type() == "supports"
521 && selected_entry_ids.contains(relationship.target_node_id())
522 && node_kinds
523 .get(relationship.source_node_id())
524 .is_some_and(|kind| is_memory_evidence_kind(kind))
525 }) {
526 included_node_ids.insert(relationship.source_node_id().to_string());
527 }
528
529 let neighbor_nodes = bundle
530 .neighbor_nodes()
531 .iter()
532 .filter(|node| included_node_ids.contains(node.node_id()))
533 .cloned()
534 .collect::<Vec<_>>();
535 let relationships = bundle
536 .relationships()
537 .iter()
538 .filter(|relationship| {
539 if relationship.relationship_type() == "contains_entry" {
540 let explanation = relationship.explanation();
541 return dimensions.includes_coordinate(
542 explanation.dimension().unwrap_or_default(),
543 explanation.scope_id().unwrap_or_default(),
544 );
545 }
546 included_node_ids.contains(relationship.source_node_id())
547 && included_node_ids.contains(relationship.target_node_id())
548 })
549 .cloned()
550 .collect::<Vec<_>>();
551 let node_details = bundle
552 .node_details()
553 .iter()
554 .filter(|detail| included_node_ids.contains(detail.node_id()))
555 .cloned()
556 .collect::<Vec<_>>();
557
558 KmpBundle::new(
559 bundle.root_node_id().clone(),
560 bundle.role().clone(),
561 bundle.root_node().clone(),
562 neighbor_nodes,
563 relationships,
564 node_details,
565 bundle.metadata().clone(),
566 )
567 .map_err(Into::into)
568}
569
570fn bundle_node_kinds(bundle: &KmpBundle) -> BTreeMap<&str, &str> {
571 let mut node_kinds =
572 BTreeMap::from([(bundle.root_node().node_id(), bundle.root_node().node_kind())]);
573 for node in bundle.neighbor_nodes() {
574 node_kinds.insert(node.node_id(), node.node_kind());
575 }
576 node_kinds
577}
578
579fn is_memory_evidence_kind(kind: &str) -> bool {
580 matches!(kind, "memory_evidence" | "evidence")
581}
582
583fn existing_refs_from_bundle(bundle: &KmpBundle) -> ExistingMemoryRefs {
584 let mut refs = BTreeSet::from([bundle.root_node().node_id().to_string()]);
585 let mut dimensions = BTreeSet::new();
586 let mut max_sequences = BTreeMap::new();
587
588 for node in bundle.neighbor_nodes() {
589 refs.insert(node.node_id().to_string());
590 if node.node_kind() == "memory_dimension" {
591 dimensions.insert(node.node_id().to_string());
592 }
593 }
594
595 for relationship in bundle
596 .relationships()
597 .iter()
598 .filter(|relationship| relationship.relationship_type() == "contains_entry")
599 {
600 dimensions.insert(relationship.source_node_id().to_string());
601 let explanation = relationship.explanation();
602 if let (Some(dimension), Some(scope_id), Some(sequence)) = (
603 explanation.dimension(),
604 explanation.scope_id(),
605 explanation.sequence(),
606 ) {
607 max_sequences
608 .entry((dimension.to_string(), scope_id.to_string()))
609 .and_modify(|current: &mut u32| *current = (*current).max(sequence))
610 .or_insert(sequence);
611 }
612 }
613
614 ExistingMemoryRefs {
615 refs,
616 dimensions,
617 max_sequences,
618 }
619}
620
621fn merge_context_results(
622 mut results: Vec<GetContextResult>,
623 render_options: &ContextRenderOptions,
624) -> Result<GetContextResult, ApplicationError> {
625 let mut result = results.remove(0);
626 if results.is_empty() {
627 return Ok(result);
628 }
629
630 let mut node_ids = BTreeSet::from([result.bundle.root_node().node_id().to_string()]);
631 let mut neighbor_nodes = result.bundle.neighbor_nodes().to_vec();
632 for node in &neighbor_nodes {
633 node_ids.insert(node.node_id().to_string());
634 }
635
636 let mut relationships = result.bundle.relationships().to_vec();
637 let mut relationship_ids = relationships
638 .iter()
639 .map(relationship_key)
640 .collect::<BTreeSet<_>>();
641 let mut node_details = result.bundle.node_details().to_vec();
642 let mut detail_ids = node_details
643 .iter()
644 .map(|detail| detail.node_id().to_string())
645 .collect::<BTreeSet<_>>();
646
647 for other in results {
648 push_node(&mut neighbor_nodes, &mut node_ids, other.bundle.root_node());
649 for node in other.bundle.neighbor_nodes() {
650 push_node(&mut neighbor_nodes, &mut node_ids, node);
651 }
652 for relationship in other.bundle.relationships() {
653 if relationship_ids.insert(relationship_key(relationship)) {
654 relationships.push(relationship.clone());
655 }
656 }
657 for detail in other.bundle.node_details() {
658 if detail_ids.insert(detail.node_id().to_string()) {
659 node_details.push(detail.clone());
660 }
661 }
662 }
663
664 result.bundle = KmpBundle::new(
665 result.bundle.root_node_id().clone(),
666 result.bundle.role().clone(),
667 result.bundle.root_node().clone(),
668 neighbor_nodes,
669 relationships,
670 node_details,
671 result.bundle.metadata().clone(),
672 )
673 .map_err(ApplicationError::Domain)?;
674 result.rendered = render_graph_bundle_with_options(&result.bundle, render_options);
675 Ok(result)
676}
677
678fn push_node(
679 neighbor_nodes: &mut Vec<BundleNode>,
680 node_ids: &mut BTreeSet<String>,
681 node: &BundleNode,
682) {
683 if node_ids.insert(node.node_id().to_string()) {
684 neighbor_nodes.push(node.clone());
685 }
686}
687
688fn relationship_key(relationship: &BundleRelationship) -> (String, String, String) {
689 (
690 relationship.source_node_id().to_string(),
691 relationship.target_node_id().to_string(),
692 relationship.relationship_type().to_string(),
693 )
694}
695
696fn namespaced_dimension_id(about: &str, dimension: &str) -> Option<String> {
697 MemoryDimensionIdentity::new(about, dimension)
698 .ok()
699 .map(|identity| identity.node_id())
700}
701
702fn resolve_dimension_scope_id(about: &str, scope_id: &str) -> Option<String> {
703 let scope_id = scope_id.trim();
704 if scope_id.is_empty() {
705 return None;
706 }
707 if let Some(identity) = MemoryDimensionIdentity::parse(scope_id) {
708 if identity.about() == about {
709 return Some(identity.node_id());
710 }
711 return None;
712 }
713 namespaced_dimension_id(about, scope_id)
714}
715
716#[cfg(test)]
717mod tests {
718 use std::collections::BTreeMap;
719
720 use kmp_domain::{BundleMetadata, CaseId, RelationExplanation, RelationSemanticClass, Role};
721
722 use super::*;
723
724 #[test]
725 fn all_abouts_scope_requires_about_index_resolution() {
726 let selection = DimensionSelection::all().with_all_about_scope();
727 let error = context_roots("question:current", &selection)
728 .expect_err("ALL_ABOUTS must not fall back to current about directly");
729
730 assert!(matches!(
731 error,
732 ApplicationError::Validation(message)
733 if message.contains("resolved through the memory about index")
734 ));
735 }
736
737 #[test]
738 fn requested_scopes_expands_all_abouts_from_indexed_roots() {
739 let selection = DimensionSelection::only(["timeline"]).with_all_about_scope();
740 let scopes = requested_dimension_scopes(
741 "question:current",
742 &selection,
743 &["question:a".to_string(), "question:b".to_string()],
744 );
745
746 assert_eq!(
747 scopes,
748 vec![
749 "about:question:a:dimension:timeline".to_string(),
750 "about:question:b:dimension:timeline".to_string()
751 ]
752 );
753 }
754
755 #[test]
756 fn requested_scopes_expand_explicit_scope_ids_against_selected_abouts() {
757 let selection = DimensionSelection::only(["conversation"])
758 .with_about_scope(["question:a", "question:b"])
759 .with_scope_ids([
760 "conversation:alpha",
761 "about:question:b:dimension:conversation:beta",
762 ]);
763 let scopes = requested_dimension_scopes("question:current", &selection, &[]);
764
765 assert_eq!(
766 scopes,
767 vec![
768 "about:question:a:dimension:conversation:alpha".to_string(),
769 "about:question:b:dimension:conversation:alpha".to_string(),
770 "about:question:b:dimension:conversation:beta".to_string()
771 ]
772 );
773 }
774
775 #[test]
776 fn bundle_filter_narrows_same_dimension_kind_by_exact_scope_id() {
777 let bundle = scoped_conversation_bundle();
778 let selection = DimensionSelection::only(["conversation"])
779 .resolve_current_about("question:a")
780 .with_scope_ids(["conversation:alpha"]);
781
782 let filtered =
783 filter_bundle_by_memory_dimensions(&bundle, &selection).expect("bundle should filter");
784 let node_ids = filtered
785 .neighbor_nodes()
786 .iter()
787 .map(|node| node.node_id())
788 .collect::<Vec<_>>();
789 let relationships = filtered
790 .relationships()
791 .iter()
792 .map(|relationship| {
793 (
794 relationship.source_node_id(),
795 relationship.target_node_id(),
796 relationship.relationship_type(),
797 )
798 })
799 .collect::<Vec<_>>();
800
801 assert!(node_ids.contains(&"about:question:a:dimension:conversation:alpha"));
802 assert!(node_ids.contains(&"claim:alpha"));
803 assert!(!node_ids.contains(&"about:question:a:dimension:conversation:beta"));
804 assert!(!node_ids.contains(&"claim:beta"));
805 assert_eq!(
806 relationships,
807 vec![(
808 "about:question:a:dimension:conversation:alpha",
809 "claim:alpha",
810 "contains_entry"
811 )]
812 );
813 }
814
815 #[test]
816 fn bundle_filter_only_pulls_support_sources_when_source_is_memory_evidence() {
817 let bundle = scoped_conversation_bundle_with_supports();
818 let selection = DimensionSelection::only(["conversation"])
819 .resolve_current_about("question:a")
820 .with_scope_ids(["conversation:alpha"]);
821
822 let filtered =
823 filter_bundle_by_memory_dimensions(&bundle, &selection).expect("bundle should filter");
824 let node_ids = filtered
825 .neighbor_nodes()
826 .iter()
827 .map(|node| node.node_id())
828 .collect::<Vec<_>>();
829 let relationships = filtered
830 .relationships()
831 .iter()
832 .map(|relationship| {
833 (
834 relationship.source_node_id(),
835 relationship.target_node_id(),
836 relationship.relationship_type(),
837 )
838 })
839 .collect::<Vec<_>>();
840
841 assert!(node_ids.contains(&"claim:alpha"));
842 assert!(node_ids.contains(&"evidence:alpha"));
843 assert!(!node_ids.contains(&"claim:beta"));
844 assert_eq!(
845 relationships,
846 vec![
847 (
848 "about:question:a:dimension:conversation:alpha",
849 "claim:alpha",
850 "contains_entry"
851 ),
852 ("evidence:alpha", "claim:alpha", "supports")
853 ]
854 );
855 }
856
857 #[test]
858 fn normalize_about_roots_trims_sorts_and_deduplicates() {
859 assert_eq!(
860 normalize_about_roots(vec![
861 " question:b ".to_string(),
862 String::new(),
863 "question:a".to_string(),
864 "question:b".to_string(),
865 ]),
866 vec!["question:a".to_string(), "question:b".to_string()]
867 );
868 }
869
870 #[test]
871 fn prioritize_current_about_keeps_all_roots_but_moves_current_first() {
872 assert_eq!(
873 prioritize_current_about(
874 vec![
875 "question:a".to_string(),
876 "question:current".to_string(),
877 "question:z".to_string(),
878 ],
879 "question:current",
880 ),
881 vec![
882 "question:current".to_string(),
883 "question:a".to_string(),
884 "question:z".to_string()
885 ]
886 );
887 }
888
889 fn scoped_conversation_bundle() -> KmpBundle {
890 KmpBundle::new(
891 CaseId::new("question:a").expect("case id should be valid"),
892 Role::new("temporal-reader").expect("role should be valid"),
893 BundleNode::new(
894 "question:a",
895 "question",
896 "Question A",
897 "Test question",
898 "ACTIVE",
899 Vec::new(),
900 BTreeMap::new(),
901 ),
902 vec![
903 memory_dimension_node("about:question:a:dimension:conversation:alpha"),
904 memory_dimension_node("about:question:a:dimension:conversation:beta"),
905 claim_node("claim:alpha"),
906 claim_node("claim:beta"),
907 ],
908 vec![
909 contains_entry(
910 "about:question:a:dimension:conversation:alpha",
911 "claim:alpha",
912 1,
913 ),
914 contains_entry(
915 "about:question:a:dimension:conversation:beta",
916 "claim:beta",
917 2,
918 ),
919 cross_scope_constraint("claim:beta", "claim:alpha"),
920 ],
921 Vec::new(),
922 BundleMetadata::initial("test"),
923 )
924 .expect("test bundle should be valid")
925 }
926
927 fn scoped_conversation_bundle_with_supports() -> KmpBundle {
928 KmpBundle::new(
929 CaseId::new("question:a").expect("case id should be valid"),
930 Role::new("temporal-reader").expect("role should be valid"),
931 BundleNode::new(
932 "question:a",
933 "question",
934 "Question A",
935 "Test question",
936 "ACTIVE",
937 Vec::new(),
938 BTreeMap::new(),
939 ),
940 vec![
941 memory_dimension_node("about:question:a:dimension:conversation:alpha"),
942 memory_dimension_node("about:question:a:dimension:conversation:beta"),
943 claim_node("claim:alpha"),
944 claim_node("claim:beta"),
945 evidence_node("evidence:alpha"),
946 ],
947 vec![
948 contains_entry(
949 "about:question:a:dimension:conversation:alpha",
950 "claim:alpha",
951 1,
952 ),
953 contains_entry(
954 "about:question:a:dimension:conversation:beta",
955 "claim:beta",
956 2,
957 ),
958 supports("claim:beta", "claim:alpha"),
959 supports("evidence:alpha", "claim:alpha"),
960 ],
961 Vec::new(),
962 BundleMetadata::initial("test"),
963 )
964 .expect("test bundle should be valid")
965 }
966
967 fn memory_dimension_node(node_id: &str) -> BundleNode {
968 BundleNode::new(
969 node_id,
970 "memory_dimension",
971 node_id,
972 "Conversation scope",
973 "ACTIVE",
974 Vec::new(),
975 BTreeMap::new(),
976 )
977 }
978
979 fn claim_node(node_id: &str) -> BundleNode {
980 BundleNode::new(
981 node_id,
982 "claim",
983 node_id,
984 "Claim",
985 "ACTIVE",
986 Vec::new(),
987 BTreeMap::new(),
988 )
989 }
990
991 fn evidence_node(node_id: &str) -> BundleNode {
992 BundleNode::new(
993 node_id,
994 "memory_evidence",
995 node_id,
996 "Evidence",
997 "ACTIVE",
998 Vec::new(),
999 BTreeMap::new(),
1000 )
1001 }
1002
1003 fn contains_entry(scope_id: &str, target_node_id: &str, sequence: u32) -> BundleRelationship {
1004 BundleRelationship::new(
1005 scope_id,
1006 target_node_id,
1007 "contains_entry",
1008 RelationExplanation::new(RelationSemanticClass::Structural)
1009 .with_dimension("conversation")
1010 .with_scope_id(scope_id)
1011 .with_sequence(sequence),
1012 )
1013 }
1014
1015 fn cross_scope_constraint(source_node_id: &str, target_node_id: &str) -> BundleRelationship {
1016 BundleRelationship::new(
1017 source_node_id,
1018 target_node_id,
1019 "contextual_constraint",
1020 RelationExplanation::new(RelationSemanticClass::Constraint)
1021 .with_rationale("Off-scope relation must not leak through exact scope filtering.")
1022 .with_confidence("medium"),
1023 )
1024 }
1025
1026 fn supports(source_node_id: &str, target_node_id: &str) -> BundleRelationship {
1027 BundleRelationship::new(
1028 source_node_id,
1029 target_node_id,
1030 "supports",
1031 RelationExplanation::new(RelationSemanticClass::Evidential)
1032 .with_rationale("Support relation for scoped filtering.")
1033 .with_confidence("medium"),
1034 )
1035 }
1036}