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