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