Skip to main content

kmp_application/memory/
service.rs

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    /// The abouts the memory currently indexes, for consumers that render an
75    /// index of what can be recalled — a viewer's sidebar, a CLI listing.
76    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                // Existing-ref validation only needs direct structural memory edges:
243                // anchor -> dimensions, anchor -> entries, and anchor -> evidence.
244                // Full semantic traversal here grows with every writer relation and
245                // makes repeated ingest progressively slower.
246                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}