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, VisualLabel,
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.receipt_ref = None;
89            outcome
90                .warnings
91                .push("dry_run=true; validated memory without writing to the kernel".to_string());
92            return Ok(outcome);
93        }
94
95        let accepted = self
96            .command_application
97            .update_context(update_context)
98            .await?;
99        outcome.read_after_write_ready = true;
100        outcome.warnings.extend(accepted.warnings);
101        Ok(outcome)
102    }
103
104    /// Changes the labels one entry stands in without rewriting its text.
105    /// Read like a write: the about's catalogue and the entry's coordinates
106    /// come from the store, the translation refuses what only the caller
107    /// can fix, and one `memory_relabel` change goes to the log.
108    pub async fn relabel(
109        &self,
110        command: MemoryRelabelCommand,
111    ) -> Result<MemoryRelabelOutcome, ApplicationError> {
112        let existing = self.existing_memory_refs(&command.about).await?;
113        let current = self
114            .entry_coordinates(&command.about, &command.ref_id, &existing)
115            .await?;
116        // A relabel translated after its own first apply refuses the labels
117        // that apply put there, so an accepted key is answered before the
118        // translation, the way ingest's replay is answered after it.
119        if !command.idempotency_key.trim().is_empty()
120            && let Some(accepted) = self
121                .command_application
122                .accepted_outcome(&command.idempotency_key)
123                .await?
124        {
125            if accepted.logical_digest.as_deref() != Some(relabel_logical_digest(&command).as_str())
126            {
127                return Err(ApplicationError::Ports(kmp_domain::PortError::Conflict(
128                    format!(
129                        "idempotency key '{}' was already accepted with different content",
130                        command.idempotency_key
131                    ),
132                )));
133            }
134            return replayed_relabel_outcome(&command, &current);
135        }
136        let (update_context, mut outcome) =
137            translate_memory_relabel(&command, &existing, &current)?;
138        if command.dry_run {
139            outcome.warnings.push(
140                "dry_run=true; validated the relabel against the store without writing to the kernel"
141                    .to_string(),
142            );
143            return Ok(outcome);
144        }
145
146        let accepted = self
147            .command_application
148            .update_context(update_context)
149            .await?;
150        outcome.read_after_write_ready = true;
151        outcome.warnings.extend(accepted.warnings);
152        Ok(outcome)
153    }
154
155    /// The coordinates an entry stands in now, read off its `contains_entry`
156    /// edges — the same reading `kmp_inspect` returns as the raw record.
157    async fn entry_coordinates(
158        &self,
159        about: &str,
160        ref_id: &str,
161        existing: &ExistingMemoryRefs,
162    ) -> Result<Vec<TemporalCoordinate>, ApplicationError> {
163        validate_supplied_entry_ref(about, "ref", ref_id).map_err(ApplicationError::Validation)?;
164        if !existing.refs.contains(ref_id) {
165            return Err(ApplicationError::NotFound(format!(
166                "`{ref_id}` is not a memory of `{about}`"
167            )));
168        }
169        let links = self
170            .query_application
171            .get_node_relationships(GetNodeRelationshipsQuery {
172                node_id: ref_id.to_string(),
173            })
174            .await?;
175        inspect_raw_coordinates(ref_id, Some(&links))
176    }
177
178    /// The abouts the memory currently indexes, for consumers that render an
179    /// index of what can be recalled — a viewer's sidebar, a CLI listing.
180    pub async fn list_abouts(&self) -> Result<Vec<String>, ApplicationError> {
181        self.query_application.list_memory_abouts().await
182    }
183
184    pub async fn wake(&self, query: WakeMemoryQuery) -> Result<GetContextResult, ApplicationError> {
185        let render_options = memory_render_options(
186            query.token_budget,
187            query.max_tier,
188            KmpMode::ResumeFocused,
189            EndpointHint::Neighborhood,
190        );
191        let dimensions = query.dimensions.resolve_current_about(&query.about);
192        let result = self
193            .memory_context(
194                &query.about,
195                &query.role,
196                query.depth,
197                &dimensions,
198                &render_options,
199            )
200            .await?;
201        apply_dimension_selection(result, &dimensions, &render_options)
202    }
203
204    pub async fn ask(&self, query: AskMemoryQuery) -> Result<GetContextResult, ApplicationError> {
205        let render_options = memory_render_options(
206            query.token_budget,
207            query.max_tier,
208            KmpMode::ReasonPreserving,
209            EndpointHint::Neighborhood,
210        );
211        let dimensions = query.dimensions.resolve_current_about(&query.about);
212        let result = self
213            .memory_context(
214                &query.about,
215                "answerer",
216                query.depth,
217                &dimensions,
218                &render_options,
219            )
220            .await?;
221        apply_dimension_selection(result, &dimensions, &render_options)
222    }
223
224    pub async fn temporal(
225        &self,
226        query: TemporalMemoryQuery,
227    ) -> Result<TemporalMemoryResult, ApplicationError> {
228        let read = self.temporal_read(&query).await?;
229        temporal_result(query, read)
230    }
231
232    /// The context a temporal read traverses, as the graph returned it:
233    /// the read's dimension filter has not narrowed it yet, so the whole
234    /// catalogue of the about is still in the bundle.
235    async fn temporal_read(
236        &self,
237        query: &TemporalMemoryQuery,
238    ) -> Result<TemporalRead, ApplicationError> {
239        let render_options = memory_render_options(
240            query.token_budget,
241            query.max_tier,
242            KmpMode::ReasonPreserving,
243            EndpointHint::Neighborhood,
244        );
245        let dimensions = query.dimensions.resolve_current_about(&query.about);
246        let context = self
247            .memory_context(
248                &query.about,
249                "temporal-reader",
250                query.depth,
251                &dimensions,
252                &render_options,
253            )
254            .await?;
255        Ok(TemporalRead {
256            context,
257            dimensions,
258        })
259    }
260
261    /// A range- and level-of-detail-aware read model for renderers.
262    ///
263    /// This stays on the application facade: storage never gains a viewer
264    /// query and renderers never reach into an engine. The temporal move
265    /// narrows by the selected clock first; bins, clusters and moment entries
266    /// are then projected on a channel whose result is not a model prompt.
267    pub async fn visual_projection(
268        &self,
269        query: VisualProjectionQuery,
270    ) -> Result<VisualProjectionResult, ApplicationError> {
271        let temporal_query = query.temporal_query()?;
272        let read = self.temporal_read(&temporal_query).await?;
273        // The catalogue is read before the filter: a renderer draws the
274        // about's labels, and says which are empty in this range. Under
275        // `scope_ids` the graph read itself is narrowed to those scopes, so
276        // the catalogue is what that read reached.
277        let catalogue = VisualLabel::catalogue(&read.context.bundle);
278        let declarations = if query.level_of_detail == super::VisualLevelOfDetail::Moment {
279            super::visual_projection::declared_equivalences(&read.context.bundle)
280        } else {
281            Vec::new()
282        };
283        let temporal = temporal_result(temporal_query, read)?;
284        let mut projection = build_visual_projection(&query, temporal, catalogue)?;
285        super::visual_projection::include_owned_declarations(&mut projection, declarations);
286        Ok(projection)
287    }
288
289    /// The neighbourhood `relate` reads: the same load as `ask` over the
290    /// abouts the selection names or resolves. Where the facts fall in time,
291    /// and what they have to do with each other, is read from the bundle
292    /// afterwards; the store is asked for nothing it does not hold.
293    pub async fn relate(
294        &self,
295        query: RelateMemoryQuery,
296    ) -> Result<GetContextResult, ApplicationError> {
297        let render_options = memory_render_options(
298            query.token_budget,
299            query.max_tier,
300            KmpMode::ReasonPreserving,
301            EndpointHint::Neighborhood,
302        );
303        let dimensions = query.dimensions.resolve_current_about(&query.about);
304        let result = self
305            .memory_context(
306                &query.about,
307                "relater",
308                query.depth,
309                &dimensions,
310                &render_options,
311            )
312            .await?;
313        apply_dimension_selection(result, &dimensions, &render_options)
314    }
315
316    pub async fn trace(
317        &self,
318        query: TraceMemoryQuery,
319    ) -> Result<GetContextPathResult, ApplicationError> {
320        self.validate_read_members(
321            &query.about,
322            &[("from", query.from.as_str()), ("to", query.to.as_str())],
323        )
324        .await?;
325        self.query_application
326            .get_context_path(GetContextPathQuery {
327                root_node_id: query.from,
328                target_node_id: query.to,
329                role: query.role,
330                subtree_depth: Some(0),
331                render_options: ContextRenderOptions {
332                    focus_node_id: None,
333                    token_budget: (query.token_budget > 0).then_some(query.token_budget),
334                    max_tier: Some(ResolutionTier::L2EvidencePack),
335                    rehydration_mode: KmpMode::ReasonPreserving,
336                    endpoint_hint: EndpointHint::FocusedPath,
337                },
338            })
339            .await
340    }
341
342    pub async fn inspect(
343        &self,
344        query: InspectMemoryQuery,
345    ) -> Result<InspectMemoryResult, ApplicationError> {
346        if query.ref_id.starts_with("receipt:") {
347            let reference = kmp_domain::MemoryReceiptRef::parse(&query.ref_id)
348                .ok_or_else(|| ApplicationError::Validation("invalid receipt ref".into()))?;
349            if reference.about() != query.about {
350                return Err(ApplicationError::Validation(
351                    "receipt ref belongs to another about".into(),
352                ));
353            }
354            let accepted = self
355                .command_application
356                .accepted_outcome(reference.idempotency_key())
357                .await?
358                .ok_or_else(|| {
359                    ApplicationError::NotFound(format!("receipt not found: {}", query.ref_id))
360                })?;
361            return super::receipt::inspect_receipt(query, accepted);
362        }
363        self.validate_read_members(&query.about, &[("ref", query.ref_id.as_str())])
364            .await?;
365        let include_incoming = query.include_incoming;
366        let include_outgoing = query.include_outgoing;
367        let include_details = query.include_details;
368        let detail = self
369            .query_application
370            .get_node_detail(GetNodeDetailQuery {
371                node_id: query.ref_id.clone(),
372            })
373            .await?;
374
375        // Evidence is part of Inspect's contract independently of whether the
376        // caller asks to render the incoming links. Resolve the direct graph
377        // once, then follow only typed evidence sources.
378        let links = self
379            .query_application
380            .get_node_relationships(GetNodeRelationshipsQuery {
381                node_id: query.ref_id.clone(),
382            })
383            .await?;
384        let mut evidence = Vec::new();
385        let supporting_refs = links
386            .incoming
387            .iter()
388            .filter(|relationship| relationship.relationship_type == "supports")
389            .map(|relationship| relationship.source_node_id.clone())
390            .collect::<BTreeSet<_>>();
391        for evidence_ref in supporting_refs {
392            let evidence_detail = match self
393                .query_application
394                .get_node_detail(GetNodeDetailQuery {
395                    node_id: evidence_ref,
396                })
397                .await
398            {
399                Ok(detail) => detail,
400                // A stale edge must not make the inspected node disappear.
401                // It is not evidence unless its typed source still exists.
402                Err(ApplicationError::NotFound(_)) => continue,
403                Err(error) => return Err(error),
404            };
405            if !is_memory_evidence_kind(&evidence_detail.node.node_kind) {
406                continue;
407            }
408            evidence.push(InspectedEvidence {
409                supports: projected_evidence_supports(&evidence_detail, &query.ref_id),
410                detail: evidence_detail,
411            });
412        }
413        let raw_coordinates = if query.include_raw {
414            inspect_raw_coordinates(&query.ref_id, Some(&links))?
415        } else {
416            Vec::new()
417        };
418
419        Ok(InspectMemoryResult {
420            detail,
421            incoming: if include_incoming {
422                links.incoming.clone()
423            } else {
424                Vec::new()
425            },
426            outgoing: if include_outgoing {
427                links.outgoing.clone()
428            } else {
429                Vec::new()
430            },
431            evidence,
432            raw_coordinates,
433            include_details,
434            include_raw: query.include_raw,
435        })
436    }
437
438    async fn existing_memory_refs(
439        &self,
440        about: &str,
441    ) -> Result<ExistingMemoryRefs, ApplicationError> {
442        match self
443            .query_application
444            .get_context(GetContextQuery {
445                root_node_id: about.to_string(),
446                role: "memory".to_string(),
447                // Existing-ref validation only needs direct structural memory edges:
448                // anchor -> dimensions, anchor -> entries, and anchor -> evidence.
449                // Full semantic traversal here grows with every writer relation and
450                // makes repeated ingest progressively slower.
451                depth: MEMORY_EXISTING_REFS_LOOKUP_DEPTH,
452                requested_scopes: Vec::new(),
453                render_options: ContextRenderOptions::default(),
454            })
455            .await
456        {
457            Ok(result) => Ok(existing_refs_from_bundle(&result.bundle)),
458            Err(ApplicationError::NotFound(_)) => Ok(ExistingMemoryRefs::default()),
459            Err(error) => Err(error),
460        }
461    }
462
463    async fn validate_read_members(
464        &self,
465        about: &str,
466        members: &[(&str, &str)],
467    ) -> Result<(), ApplicationError> {
468        let mut graph_members = Vec::new();
469        for (path, member_ref) in members {
470            validate_ref_token(path, member_ref).map_err(ApplicationError::Validation)?;
471            if validate_supplied_member_ref(about, path, member_ref).is_err() {
472                graph_members.push((*path, *member_ref));
473            }
474        }
475        if graph_members.is_empty() {
476            return Ok(());
477        }
478
479        let visible = match self
480            .query_application
481            .get_context(GetContextQuery {
482                root_node_id: about.to_string(),
483                role: "memory-boundary".to_string(),
484                depth: MAX_NATIVE_GRAPH_TRAVERSAL_DEPTH,
485                requested_scopes: Vec::new(),
486                render_options: ContextRenderOptions::default(),
487            })
488            .await
489        {
490            Ok(result) => bundle_node_ids(&result.bundle),
491            Err(ApplicationError::NotFound(_)) => BTreeSet::new(),
492            Err(error) => return Err(error),
493        };
494        for (path, member_ref) in graph_members {
495            if !visible.contains(member_ref) {
496                return Err(ApplicationError::Validation(format!(
497                    "`{path}` `{member_ref}` does not belong to about `{about}`"
498                )));
499            }
500        }
501        Ok(())
502    }
503
504    async fn memory_context(
505        &self,
506        about: &str,
507        role: &str,
508        depth: u32,
509        dimensions: &DimensionSelection,
510        render_options: &ContextRenderOptions,
511    ) -> Result<GetContextResult, ApplicationError> {
512        let roots = self.memory_context_roots(about, dimensions).await?;
513        let requested_scopes = requested_dimension_scopes(about, dimensions, &roots);
514        let mut results = Vec::new();
515        for root in &roots {
516            results.push(
517                self.query_application
518                    .get_context(GetContextQuery {
519                        root_node_id: root.clone(),
520                        role: role.to_string(),
521                        depth,
522                        requested_scopes: requested_scopes.clone(),
523                        render_options: render_options.clone(),
524                    })
525                    .await?,
526            );
527        }
528
529        merge_context_results(results, render_options)
530    }
531
532    async fn memory_context_roots(
533        &self,
534        current_about: &str,
535        selection: &DimensionSelection,
536    ) -> Result<Vec<String>, ApplicationError> {
537        if selection.scope_mode() != DimensionScopeMode::AllAbouts {
538            return context_roots(current_about, selection);
539        }
540
541        let roots = if should_filter_all_abouts_by_dimensions(selection) {
542            // Kinds from `include`, exact ids from `scope_ids`, keys and
543            // values from the positive selectors: the index resolves any of
544            // them, and the filter that follows reads them all. The union is
545            // a superset of what the conjoined filter keeps, never less.
546            let dimension_ids = index_dimension_ids(selection);
547            self.query_application
548                .list_memory_abouts_by_dimensions(&dimension_ids)
549                .await?
550        } else {
551            self.query_application.list_memory_abouts().await?
552        };
553
554        let roots = prioritize_current_about(normalize_about_roots(roots), current_about);
555        if roots.is_empty() {
556            return Err(ApplicationError::NotFound(
557                "no memory abouts found for ALL_ABOUTS scope".to_string(),
558            ));
559        }
560        Ok(roots)
561    }
562}
563
564/// One temporal read before its filter: the context the graph returned and
565/// the selection resolved against the current about.
566struct TemporalRead {
567    context: GetContextResult,
568    dimensions: DimensionSelection,
569}
570
571/// Narrows the read to its selection and traverses it.
572fn temporal_result(
573    query: TemporalMemoryQuery,
574    read: TemporalRead,
575) -> Result<TemporalMemoryResult, ApplicationError> {
576    let TemporalRead {
577        context,
578        dimensions,
579    } = read;
580    let quality = context.rendered.quality.clone();
581    let source_bundle = filter_bundle_by_memory_dimensions(&context.bundle, &dimensions)?;
582
583    let request = TemporalTraversalRequest::new(query.direction, query.cursor)
584        .with_axis(query.axis)
585        .with_dimensions(dimensions.clone())
586        .with_requested_dimensions(query.dimensions.clone())
587        .with_window(query.window);
588    let request = if let Some(interval) = query.interval {
589        request.with_interval(interval)
590    } else {
591        request
592    };
593    let request = if let Some(limit_entries) = query.limit_entries {
594        request.with_limit_entries(limit_entries)?
595    } else {
596        request
597    };
598
599    // Select against the original label map. The returned source bundle has
600    // already dropped excluded lanes, which may carry labels a selector needs.
601    // Traversal applies the same coordinate and entry filters before paging.
602    let traversal = TemporalMemoryTraversal::traverse(&context.bundle, &request)?;
603
604    Ok(TemporalMemoryResult {
605        traversal,
606        source_bundle,
607        include: query.include,
608        quality,
609    })
610}
611
612fn bundle_node_ids(bundle: &KmpBundle) -> BTreeSet<String> {
613    std::iter::once(bundle.root_node().node_id())
614        .chain(bundle.neighbor_nodes().iter().map(BundleNode::node_id))
615        .map(ToString::to_string)
616        .collect()
617}
618
619fn projected_evidence_supports(
620    evidence: &crate::queries::GetNodeDetailResult,
621    inspected_ref: &str,
622) -> Vec<String> {
623    evidence
624        .node
625        .properties
626        .get("payload_supports")
627        .and_then(|value| serde_json::from_str::<Vec<String>>(value).ok())
628        .filter(|supports| !supports.is_empty())
629        .unwrap_or_else(|| vec![inspected_ref.to_string()])
630}
631
632fn should_filter_all_abouts_by_dimensions(selection: &DimensionSelection) -> bool {
633    selection.scope_mode() == DimensionScopeMode::AllAbouts
634        && ((selection.mode() == DimensionSelectionMode::Only
635            && !selection.dimensions().is_empty())
636            || !selection.scope_ids().is_empty()
637            || selection.selectors().iter().any(LabelSelector::is_positive))
638}
639
640fn index_dimension_ids(selection: &DimensionSelection) -> Vec<String> {
641    let mut dimension_ids = Vec::new();
642    if selection.mode() == DimensionSelectionMode::Only {
643        dimension_ids.extend(selection.dimensions().iter().cloned());
644    }
645    dimension_ids.extend(selection.scope_ids().iter().cloned());
646    dimension_ids.extend(selection.positive_selector_ids());
647    dimension_ids
648}
649
650fn inspect_raw_coordinates(
651    ref_id: &str,
652    links: Option<&crate::queries::GetNodeRelationshipsResult>,
653) -> Result<Vec<TemporalCoordinate>, ApplicationError> {
654    let Some(links) = links else {
655        return Ok(Vec::new());
656    };
657
658    let mut coordinates = Vec::new();
659    for relationship in links.incoming.iter().chain(links.outgoing.iter()) {
660        if relationship.relationship_type != "contains_entry"
661            || relationship.target_node_id != ref_id
662        {
663            continue;
664        }
665        if let Some(coordinate) =
666            TemporalCoordinate::from_relation_explanation(&relationship.explanation)?
667        {
668            coordinates.push(coordinate);
669        }
670    }
671
672    Ok(coordinates)
673}
674
675fn memory_render_options(
676    token_budget: u32,
677    max_tier: Option<ResolutionTier>,
678    rehydration_mode: KmpMode,
679    endpoint_hint: EndpointHint,
680) -> ContextRenderOptions {
681    ContextRenderOptions {
682        focus_node_id: None,
683        token_budget: (token_budget > 0).then_some(token_budget),
684        max_tier,
685        rehydration_mode,
686        endpoint_hint,
687    }
688}
689
690fn requested_dimension_scopes(
691    _current_about: &str,
692    selection: &DimensionSelection,
693    _context_roots: &[String],
694) -> Vec<String> {
695    // Hints are keys, bare values or complete refs. The adapter compares
696    // them to the decoded label identity; callers never manufacture refs.
697    if !selection.scope_ids().is_empty() {
698        return selection.scope_ids().iter().cloned().collect();
699    }
700    if selection.mode() == DimensionSelectionMode::Only {
701        return selection.dimensions().iter().cloned().collect();
702    }
703    Vec::new()
704}
705
706fn context_roots(
707    current_about: &str,
708    selection: &DimensionSelection,
709) -> Result<Vec<String>, ApplicationError> {
710    match selection.scope_mode() {
711        DimensionScopeMode::Abouts if !selection.abouts().is_empty() => {
712            Ok(selection.abouts().iter().cloned().collect())
713        }
714        DimensionScopeMode::CurrentAbout => Ok(vec![current_about.to_string()]),
715        DimensionScopeMode::Abouts => Err(ApplicationError::Validation(
716            "dimension scope ABOUTS requires at least one about".to_string(),
717        )),
718        DimensionScopeMode::AllAbouts => Err(ApplicationError::Validation(
719            "dimension scope ALL_ABOUTS must be resolved through the memory about index"
720                .to_string(),
721        )),
722    }
723}
724
725fn apply_dimension_selection(
726    mut result: GetContextResult,
727    dimensions: &DimensionSelection,
728    render_options: &ContextRenderOptions,
729) -> Result<GetContextResult, ApplicationError> {
730    result.bundle = filter_bundle_by_memory_dimensions(&result.bundle, dimensions)?;
731    result.rendered = render_graph_bundle_with_options(&result.bundle, render_options);
732    Ok(result)
733}
734
735fn normalize_about_roots(values: Vec<String>) -> Vec<String> {
736    values
737        .into_iter()
738        .map(|value| value.trim().to_string())
739        .filter(|value| !value.is_empty())
740        .collect::<BTreeSet<_>>()
741        .into_iter()
742        .collect()
743}
744
745fn prioritize_current_about(mut roots: Vec<String>, current_about: &str) -> Vec<String> {
746    let current_about = current_about.trim();
747    if current_about.is_empty() {
748        return roots;
749    }
750    if let Some(position) = roots.iter().position(|root| root == current_about) {
751        let root = roots.remove(position);
752        roots.insert(0, root);
753    }
754    roots
755}
756
757fn filter_bundle_by_memory_dimensions(
758    bundle: &KmpBundle,
759    dimensions: &DimensionSelection,
760) -> Result<KmpBundle, ApplicationError> {
761    let mut included_node_ids = BTreeSet::from([bundle.root_node().node_id().to_string()]);
762    let mut selected_entry_ids = BTreeSet::new();
763    let node_kinds = bundle_node_kinds(bundle);
764    let labels = labels_by_entry(bundle);
765
766    for relationship in bundle
767        .relationships()
768        .iter()
769        .filter(|relationship| relationship.relationship_type() == "contains_entry")
770    {
771        if contains_entry_selected(relationship, dimensions, &labels) {
772            included_node_ids.insert(relationship.source_node_id().to_string());
773            included_node_ids.insert(relationship.target_node_id().to_string());
774            selected_entry_ids.insert(relationship.target_node_id().to_string());
775        }
776    }
777
778    for relationship in bundle.relationships().iter().filter(|relationship| {
779        relationship.relationship_type() == "supports"
780            && selected_entry_ids.contains(relationship.target_node_id())
781            && node_kinds
782                .get(relationship.source_node_id())
783                .is_some_and(|kind| is_memory_evidence_kind(kind))
784    }) {
785        included_node_ids.insert(relationship.source_node_id().to_string());
786    }
787
788    let neighbor_nodes = bundle
789        .neighbor_nodes()
790        .iter()
791        .filter(|node| included_node_ids.contains(node.node_id()))
792        .cloned()
793        .collect::<Vec<_>>();
794    let relationships = bundle
795        .relationships()
796        .iter()
797        .filter(|relationship| {
798            if relationship.relationship_type() == "contains_entry" {
799                return contains_entry_selected(relationship, dimensions, &labels);
800            }
801            included_node_ids.contains(relationship.source_node_id())
802                && included_node_ids.contains(relationship.target_node_id())
803        })
804        .cloned()
805        .collect::<Vec<_>>();
806    let node_details = bundle
807        .node_details()
808        .iter()
809        .filter(|detail| included_node_ids.contains(detail.node_id()))
810        .cloned()
811        .collect::<Vec<_>>();
812
813    KmpBundle::new(
814        bundle.root_node_id().clone(),
815        bundle.role().clone(),
816        bundle.root_node().clone(),
817        neighbor_nodes,
818        relationships,
819        node_details,
820        bundle.metadata().clone(),
821    )
822    .map_err(Into::into)
823}
824
825/// A `contains_entry` edge survives when its coordinate passes the
826/// coordinate filters and the entry it points at passes every selector:
827/// the first reads one coordinate, the second the entry's whole label map.
828fn contains_entry_selected(
829    relationship: &BundleRelationship,
830    dimensions: &DimensionSelection,
831    labels: &BTreeMap<String, EntryLabels>,
832) -> bool {
833    let explanation = relationship.explanation();
834    let coordinate_passes = dimensions.includes_coordinate(
835        explanation.dimension().unwrap_or_default(),
836        explanation.scope_id().unwrap_or_default(),
837    );
838    coordinate_passes
839        && (!dimensions.has_selectors()
840            || dimensions.admits(
841                labels
842                    .get(relationship.target_node_id())
843                    .unwrap_or(&EntryLabels::default()),
844            ))
845}
846
847fn bundle_node_kinds(bundle: &KmpBundle) -> BTreeMap<&str, &str> {
848    let mut node_kinds =
849        BTreeMap::from([(bundle.root_node().node_id(), bundle.root_node().node_kind())]);
850    for node in bundle.neighbor_nodes() {
851        node_kinds.insert(node.node_id(), node.node_kind());
852    }
853    node_kinds
854}
855
856fn is_memory_evidence_kind(kind: &str) -> bool {
857    matches!(kind, "memory_evidence" | "evidence")
858}
859
860fn existing_refs_from_bundle(bundle: &KmpBundle) -> ExistingMemoryRefs {
861    let mut refs = BTreeSet::from([bundle.root_node().node_id().to_string()]);
862    let mut dimensions = BTreeSet::new();
863    let mut max_sequences = BTreeMap::new();
864
865    let mut labels = BTreeSet::new();
866    for node in bundle.neighbor_nodes() {
867        refs.insert(node.node_id().to_string());
868        if node.node_kind() == "memory_dimension" {
869            dimensions.insert(node.node_id().to_string());
870            if let Some(kind) = node.properties().get("dimension_kind") {
871                let value = MemoryDimensionIdentity::parse(node.node_id())
872                    .map(|identity| identity.dimension_id().to_string())
873                    .unwrap_or_else(|| node.node_id().to_string());
874                labels.insert((kind.clone(), value));
875            }
876        }
877    }
878
879    for relationship in bundle
880        .relationships()
881        .iter()
882        .filter(|relationship| relationship.relationship_type() == "contains_entry")
883    {
884        dimensions.insert(relationship.source_node_id().to_string());
885        let explanation = relationship.explanation();
886        if let (Some(dimension), Some(scope_id), Some(sequence)) = (
887            explanation.dimension(),
888            explanation.scope_id(),
889            explanation.sequence(),
890        ) {
891            max_sequences
892                .entry((dimension.to_string(), scope_id.to_string()))
893                .and_modify(|current: &mut u32| *current = (*current).max(sequence))
894                .or_insert(sequence);
895        }
896    }
897
898    ExistingMemoryRefs {
899        refs,
900        dimensions,
901        labels,
902        max_sequences,
903        foreign: BTreeSet::new(),
904    }
905}
906
907fn merge_context_results(
908    mut results: Vec<GetContextResult>,
909    render_options: &ContextRenderOptions,
910) -> Result<GetContextResult, ApplicationError> {
911    let mut result = results.remove(0);
912    if results.is_empty() {
913        return Ok(result);
914    }
915
916    let mut node_ids = BTreeSet::from([result.bundle.root_node().node_id().to_string()]);
917    let mut neighbor_nodes = result.bundle.neighbor_nodes().to_vec();
918    for node in &neighbor_nodes {
919        node_ids.insert(node.node_id().to_string());
920    }
921
922    let mut relationships = result.bundle.relationships().to_vec();
923    let mut relationship_ids = relationships
924        .iter()
925        .map(relationship_key)
926        .collect::<BTreeSet<_>>();
927    let mut node_details = result.bundle.node_details().to_vec();
928    let mut detail_ids = node_details
929        .iter()
930        .map(|detail| detail.node_id().to_string())
931        .collect::<BTreeSet<_>>();
932
933    for other in results {
934        push_node(&mut neighbor_nodes, &mut node_ids, other.bundle.root_node());
935        for node in other.bundle.neighbor_nodes() {
936            push_node(&mut neighbor_nodes, &mut node_ids, node);
937        }
938        for relationship in other.bundle.relationships() {
939            if relationship_ids.insert(relationship_key(relationship)) {
940                relationships.push(relationship.clone());
941            }
942        }
943        for detail in other.bundle.node_details() {
944            if detail_ids.insert(detail.node_id().to_string()) {
945                node_details.push(detail.clone());
946            }
947        }
948    }
949
950    result.bundle = KmpBundle::new(
951        result.bundle.root_node_id().clone(),
952        result.bundle.role().clone(),
953        result.bundle.root_node().clone(),
954        neighbor_nodes,
955        relationships,
956        node_details,
957        result.bundle.metadata().clone(),
958    )
959    .map_err(ApplicationError::Domain)?;
960    result.rendered = render_graph_bundle_with_options(&result.bundle, render_options);
961    Ok(result)
962}
963
964fn push_node(
965    neighbor_nodes: &mut Vec<BundleNode>,
966    node_ids: &mut BTreeSet<String>,
967    node: &BundleNode,
968) {
969    if node_ids.insert(node.node_id().to_string()) {
970        neighbor_nodes.push(node.clone());
971    }
972}
973
974fn relationship_key(relationship: &BundleRelationship) -> (String, String, String) {
975    (
976        relationship.source_node_id().to_string(),
977        relationship.target_node_id().to_string(),
978        relationship.relationship_type().to_string(),
979    )
980}
981
982#[cfg(test)]
983mod tests {
984    use std::collections::BTreeMap;
985
986    use kmp_domain::{BundleMetadata, CaseId, RelationExplanation, RelationSemanticClass, Role};
987
988    use super::*;
989
990    #[test]
991    fn all_abouts_scope_requires_about_index_resolution() {
992        let selection = DimensionSelection::all().with_all_about_scope();
993        let error = context_roots("question:current", &selection)
994            .expect_err("ALL_ABOUTS must not fall back to current about directly");
995
996        assert!(matches!(
997            error,
998            ApplicationError::Validation(message)
999                if message.contains("resolved through the memory about index")
1000        ));
1001    }
1002
1003    #[test]
1004    fn requested_scopes_preserves_keys_as_index_hints() {
1005        let selection = DimensionSelection::only(["timeline"]).with_all_about_scope();
1006        let scopes = requested_dimension_scopes(
1007            "question:current",
1008            &selection,
1009            &["question:a".to_string(), "question:b".to_string()],
1010        );
1011
1012        assert_eq!(scopes, vec!["timeline".to_string()]);
1013    }
1014
1015    #[test]
1016    fn requested_scopes_preserves_values_and_exact_refs_as_index_hints() {
1017        let selection = DimensionSelection::only(["conversation"])
1018            .with_about_scope(["question:a", "question:b"])
1019            .with_scope_ids([
1020                "conversation:alpha",
1021                "label:v1:question%3Ab:conversation:conversation%3Abeta",
1022            ]);
1023        let scopes = requested_dimension_scopes("question:current", &selection, &[]);
1024
1025        assert_eq!(
1026            scopes,
1027            vec![
1028                "conversation:alpha".to_string(),
1029                "label:v1:question%3Ab:conversation:conversation%3Abeta".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(&"label:v1:question%3Aa:conversation:conversation%3Aalpha"));
1061        assert!(node_ids.contains(&"claim:alpha"));
1062        assert!(!node_ids.contains(&"label:v1:question%3Aa:conversation:conversation%3Abeta"));
1063        assert!(!node_ids.contains(&"claim:beta"));
1064        assert_eq!(
1065            relationships,
1066            vec![(
1067                "label:v1:question%3Aa:conversation:conversation%3Aalpha",
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                    "label:v1:question%3Aa:conversation:conversation%3Aalpha",
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("label:v1:question%3Aa:conversation:conversation%3Aalpha"),
1261                memory_dimension_node("label:v1:question%3Aa:conversation:conversation%3Abeta"),
1262                claim_node("claim:alpha"),
1263                claim_node("claim:beta"),
1264            ],
1265            vec![
1266                contains_entry(
1267                    "label:v1:question%3Aa:conversation:conversation%3Aalpha",
1268                    "claim:alpha",
1269                    1,
1270                ),
1271                contains_entry(
1272                    "label:v1:question%3Aa:conversation:conversation%3Abeta",
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("label:v1:question%3Aa:conversation:conversation%3Aalpha"),
1299                memory_dimension_node("label:v1:question%3Aa:conversation:conversation%3Abeta"),
1300                claim_node("claim:alpha"),
1301                claim_node("claim:beta"),
1302                evidence_node("evidence:alpha"),
1303            ],
1304            vec![
1305                contains_entry(
1306                    "label:v1:question%3Aa:conversation:conversation%3Aalpha",
1307                    "claim:alpha",
1308                    1,
1309                ),
1310                contains_entry(
1311                    "label:v1:question%3Aa:conversation:conversation%3Abeta",
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}