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