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