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