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    if let Some(identity) = MemoryDimensionIdentity::parse(scope_id) {
805        if identity.about() == about {
806            return Some(identity.node_id());
807        }
808        return None;
809    }
810    namespaced_dimension_id(about, scope_id)
811}
812
813#[cfg(test)]
814mod tests {
815    use std::collections::BTreeMap;
816
817    use kmp_domain::{BundleMetadata, CaseId, RelationExplanation, RelationSemanticClass, Role};
818
819    use super::*;
820
821    #[test]
822    fn all_abouts_scope_requires_about_index_resolution() {
823        let selection = DimensionSelection::all().with_all_about_scope();
824        let error = context_roots("question:current", &selection)
825            .expect_err("ALL_ABOUTS must not fall back to current about directly");
826
827        assert!(matches!(
828            error,
829            ApplicationError::Validation(message)
830                if message.contains("resolved through the memory about index")
831        ));
832    }
833
834    #[test]
835    fn requested_scopes_expands_all_abouts_from_indexed_roots() {
836        let selection = DimensionSelection::only(["timeline"]).with_all_about_scope();
837        let scopes = requested_dimension_scopes(
838            "question:current",
839            &selection,
840            &["question:a".to_string(), "question:b".to_string()],
841        );
842
843        assert_eq!(
844            scopes,
845            vec![
846                "about:question:a:dimension:timeline".to_string(),
847                "about:question:b:dimension:timeline".to_string()
848            ]
849        );
850    }
851
852    #[test]
853    fn requested_scopes_expand_explicit_scope_ids_against_selected_abouts() {
854        let selection = DimensionSelection::only(["conversation"])
855            .with_about_scope(["question:a", "question:b"])
856            .with_scope_ids([
857                "conversation:alpha",
858                "about:question:b:dimension:conversation:beta",
859            ]);
860        let scopes = requested_dimension_scopes("question:current", &selection, &[]);
861
862        assert_eq!(
863            scopes,
864            vec![
865                "about:question:a:dimension:conversation:alpha".to_string(),
866                "about:question:b:dimension:conversation:alpha".to_string(),
867                "about:question:b:dimension:conversation:beta".to_string()
868            ]
869        );
870    }
871
872    #[test]
873    fn bundle_filter_narrows_same_dimension_kind_by_exact_scope_id() {
874        let bundle = scoped_conversation_bundle();
875        let selection = DimensionSelection::only(["conversation"])
876            .resolve_current_about("question:a")
877            .with_scope_ids(["conversation:alpha"]);
878
879        let filtered =
880            filter_bundle_by_memory_dimensions(&bundle, &selection).expect("bundle should filter");
881        let node_ids = filtered
882            .neighbor_nodes()
883            .iter()
884            .map(|node| node.node_id())
885            .collect::<Vec<_>>();
886        let relationships = filtered
887            .relationships()
888            .iter()
889            .map(|relationship| {
890                (
891                    relationship.source_node_id(),
892                    relationship.target_node_id(),
893                    relationship.relationship_type(),
894                )
895            })
896            .collect::<Vec<_>>();
897
898        assert!(node_ids.contains(&"about:question:a:dimension:conversation:alpha"));
899        assert!(node_ids.contains(&"claim:alpha"));
900        assert!(!node_ids.contains(&"about:question:a:dimension:conversation:beta"));
901        assert!(!node_ids.contains(&"claim:beta"));
902        assert_eq!(
903            relationships,
904            vec![(
905                "about:question:a:dimension:conversation:alpha",
906                "claim:alpha",
907                "contains_entry"
908            )]
909        );
910    }
911
912    #[test]
913    fn bundle_filter_only_pulls_support_sources_when_source_is_memory_evidence() {
914        let bundle = scoped_conversation_bundle_with_supports();
915        let selection = DimensionSelection::only(["conversation"])
916            .resolve_current_about("question:a")
917            .with_scope_ids(["conversation:alpha"]);
918
919        let filtered =
920            filter_bundle_by_memory_dimensions(&bundle, &selection).expect("bundle should filter");
921        let node_ids = filtered
922            .neighbor_nodes()
923            .iter()
924            .map(|node| node.node_id())
925            .collect::<Vec<_>>();
926        let relationships = filtered
927            .relationships()
928            .iter()
929            .map(|relationship| {
930                (
931                    relationship.source_node_id(),
932                    relationship.target_node_id(),
933                    relationship.relationship_type(),
934                )
935            })
936            .collect::<Vec<_>>();
937
938        assert!(node_ids.contains(&"claim:alpha"));
939        assert!(node_ids.contains(&"evidence:alpha"));
940        assert!(!node_ids.contains(&"claim:beta"));
941        assert_eq!(
942            relationships,
943            vec![
944                (
945                    "about:question:a:dimension:conversation:alpha",
946                    "claim:alpha",
947                    "contains_entry"
948                ),
949                ("evidence:alpha", "claim:alpha", "supports")
950            ]
951        );
952    }
953
954    #[test]
955    fn normalize_about_roots_trims_sorts_and_deduplicates() {
956        assert_eq!(
957            normalize_about_roots(vec![
958                " question:b ".to_string(),
959                String::new(),
960                "question:a".to_string(),
961                "question:b".to_string(),
962            ]),
963            vec!["question:a".to_string(), "question:b".to_string()]
964        );
965    }
966
967    #[test]
968    fn prioritize_current_about_keeps_all_roots_but_moves_current_first() {
969        assert_eq!(
970            prioritize_current_about(
971                vec![
972                    "question:a".to_string(),
973                    "question:current".to_string(),
974                    "question:z".to_string(),
975                ],
976                "question:current",
977            ),
978            vec![
979                "question:current".to_string(),
980                "question:a".to_string(),
981                "question:z".to_string()
982            ]
983        );
984    }
985
986    fn scoped_conversation_bundle() -> KmpBundle {
987        KmpBundle::new(
988            CaseId::new("question:a").expect("case id should be valid"),
989            Role::new("temporal-reader").expect("role should be valid"),
990            BundleNode::new(
991                "question:a",
992                "question",
993                "Question A",
994                "Test question",
995                "ACTIVE",
996                Vec::new(),
997                BTreeMap::new(),
998            ),
999            vec![
1000                memory_dimension_node("about:question:a:dimension:conversation:alpha"),
1001                memory_dimension_node("about:question:a:dimension:conversation:beta"),
1002                claim_node("claim:alpha"),
1003                claim_node("claim:beta"),
1004            ],
1005            vec![
1006                contains_entry(
1007                    "about:question:a:dimension:conversation:alpha",
1008                    "claim:alpha",
1009                    1,
1010                ),
1011                contains_entry(
1012                    "about:question:a:dimension:conversation:beta",
1013                    "claim:beta",
1014                    2,
1015                ),
1016                cross_scope_constraint("claim:beta", "claim:alpha"),
1017            ],
1018            Vec::new(),
1019            BundleMetadata::initial("test"),
1020        )
1021        .expect("test bundle should be valid")
1022    }
1023
1024    fn scoped_conversation_bundle_with_supports() -> KmpBundle {
1025        KmpBundle::new(
1026            CaseId::new("question:a").expect("case id should be valid"),
1027            Role::new("temporal-reader").expect("role should be valid"),
1028            BundleNode::new(
1029                "question:a",
1030                "question",
1031                "Question A",
1032                "Test question",
1033                "ACTIVE",
1034                Vec::new(),
1035                BTreeMap::new(),
1036            ),
1037            vec![
1038                memory_dimension_node("about:question:a:dimension:conversation:alpha"),
1039                memory_dimension_node("about:question:a:dimension:conversation:beta"),
1040                claim_node("claim:alpha"),
1041                claim_node("claim:beta"),
1042                evidence_node("evidence:alpha"),
1043            ],
1044            vec![
1045                contains_entry(
1046                    "about:question:a:dimension:conversation:alpha",
1047                    "claim:alpha",
1048                    1,
1049                ),
1050                contains_entry(
1051                    "about:question:a:dimension:conversation:beta",
1052                    "claim:beta",
1053                    2,
1054                ),
1055                supports("claim:beta", "claim:alpha"),
1056                supports("evidence:alpha", "claim:alpha"),
1057            ],
1058            Vec::new(),
1059            BundleMetadata::initial("test"),
1060        )
1061        .expect("test bundle should be valid")
1062    }
1063
1064    fn memory_dimension_node(node_id: &str) -> BundleNode {
1065        BundleNode::new(
1066            node_id,
1067            "memory_dimension",
1068            node_id,
1069            "Conversation scope",
1070            "ACTIVE",
1071            Vec::new(),
1072            BTreeMap::new(),
1073        )
1074    }
1075
1076    fn claim_node(node_id: &str) -> BundleNode {
1077        BundleNode::new(
1078            node_id,
1079            "claim",
1080            node_id,
1081            "Claim",
1082            "ACTIVE",
1083            Vec::new(),
1084            BTreeMap::new(),
1085        )
1086    }
1087
1088    fn evidence_node(node_id: &str) -> BundleNode {
1089        BundleNode::new(
1090            node_id,
1091            "memory_evidence",
1092            node_id,
1093            "Evidence",
1094            "ACTIVE",
1095            Vec::new(),
1096            BTreeMap::new(),
1097        )
1098    }
1099
1100    fn contains_entry(scope_id: &str, target_node_id: &str, sequence: u32) -> BundleRelationship {
1101        BundleRelationship::new(
1102            scope_id,
1103            target_node_id,
1104            "contains_entry",
1105            RelationExplanation::new(RelationSemanticClass::Structural)
1106                .with_dimension("conversation")
1107                .with_scope_id(scope_id)
1108                .with_sequence(sequence),
1109        )
1110    }
1111
1112    fn cross_scope_constraint(source_node_id: &str, target_node_id: &str) -> BundleRelationship {
1113        BundleRelationship::new(
1114            source_node_id,
1115            target_node_id,
1116            "contextual_constraint",
1117            RelationExplanation::new(RelationSemanticClass::Constraint)
1118                .with_rationale("Off-scope relation must not leak through exact scope filtering.")
1119                .with_confidence("medium"),
1120        )
1121    }
1122
1123    fn supports(source_node_id: &str, target_node_id: &str) -> BundleRelationship {
1124        BundleRelationship::new(
1125            source_node_id,
1126            target_node_id,
1127            "supports",
1128            RelationExplanation::new(RelationSemanticClass::Evidential)
1129                .with_rationale("Support relation for scoped filtering.")
1130                .with_confidence("medium"),
1131        )
1132    }
1133}