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