Skip to main content

kmp_application/memory/
service.rs

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