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