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, 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    /// The abouts the memory currently indexes, for consumers that render an
76    /// index of what can be recalled — a viewer's sidebar, a CLI listing.
77    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    /// A range- and level-of-detail-aware read model for renderers.
166    ///
167    /// This stays on the application facade: storage never gains a viewer
168    /// query and renderers never reach into an engine. The temporal move
169    /// narrows by the selected clock first; bins, clusters and moment entries
170    /// are then projected on a channel whose result is not a model prompt.
171    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                // Existing-ref validation only needs direct structural memory edges:
260                // anchor -> dimensions, anchor -> entries, and anchor -> evidence.
261                // Full semantic traversal here grows with every writer relation and
262                // makes repeated ingest progressively slower.
263                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}